Skip to content

Commit 75ece8e

Browse files
committed
Add tests for stateful session lifecycle and event-driven step wait
Cover the three reliability/perf fixes that the existing suite missed: - debugMCPServer.security.test.ts: a "Streamable-HTTP session lifecycle" suite that drives the real handshake — initialize over POST mints an mcp-session-id, GET /mcp with that id returns a live text/event-stream (the regression that left Cursor flagging the server "errored"), and GET /mcp with a missing/unknown session is rejected with 400. - debuggingHandler.test.ts: waitForStateChange tests (via handleStepOver) asserting the fast-path resolves in ms when the new line is already observable or the session ended, and that the bounded timeout (not a blind 1s sleep) governs the no-change case.
1 parent e147d7f commit 75ece8e

2 files changed

Lines changed: 221 additions & 0 deletions

File tree

src/test/debugMCPServer.security.test.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,4 +169,128 @@ suite('DebugMCPServer security', () => {
169169
}
170170
});
171171
});
172+
173+
// Stateful Streamable-HTTP session lifecycle. Regression guard for the bug
174+
// where the transport ran stateless and GET /mcp returned 404, so clients
175+
// (Cursor) could never open the server->client SSE stream and tombstoned the
176+
// connection as "errored". Here we drive the real handshake: initialize over
177+
// POST mints a session id, and GET /mcp with that id must return a live
178+
// text/event-stream.
179+
suite('Streamable-HTTP session lifecycle', () => {
180+
const port = 30100;
181+
let server: DebugMCPServer;
182+
183+
suiteSetup(async () => {
184+
server = new DebugMCPServer(port, 60);
185+
await server.initialize();
186+
await server.start();
187+
});
188+
189+
suiteTeardown(async () => {
190+
await server.stop();
191+
});
192+
193+
// POST an `initialize` request and resolve with the response status plus
194+
// the mcp-session-id header the server assigns to the new session.
195+
function postInitialize(): Promise<{ status: number; sessionId?: string }> {
196+
const body = JSON.stringify({
197+
jsonrpc: '2.0',
198+
id: 1,
199+
method: 'initialize',
200+
params: {
201+
protocolVersion: '2025-06-18',
202+
capabilities: {},
203+
clientInfo: { name: 'lifecycle-test', version: '0.0.0' }
204+
}
205+
});
206+
const opts: http.RequestOptions = {
207+
host: '127.0.0.1',
208+
port,
209+
path: '/mcp',
210+
method: 'POST',
211+
headers: {
212+
'Content-Type': 'application/json',
213+
'Accept': 'application/json, text/event-stream',
214+
'Content-Length': Buffer.byteLength(body).toString(),
215+
'Host': `127.0.0.1:${port}`
216+
}
217+
};
218+
return new Promise((resolve, reject) => {
219+
const req = http.request(opts, res => {
220+
const sessionId = res.headers['mcp-session-id'] as string | undefined;
221+
// Drain so the socket is released; we only need status + id.
222+
res.on('data', () => { /* discard */ });
223+
res.on('end', () => resolve({ status: res.statusCode || 0, sessionId }));
224+
// initialize responds on an SSE channel that may stay open; the
225+
// headers (and session id) are already available, so resolve now
226+
// and let the drain handler clean up.
227+
resolve({ status: res.statusCode || 0, sessionId });
228+
});
229+
req.on('error', reject);
230+
req.write(body);
231+
req.end();
232+
});
233+
}
234+
235+
// Open GET /mcp (the server->client SSE stream) and resolve with the
236+
// status + content-type as soon as the response headers arrive, then
237+
// tear down the long-lived stream.
238+
function getMcp(headers: http.OutgoingHttpHeaders): Promise<{ status: number; contentType?: string }> {
239+
const opts: http.RequestOptions = {
240+
host: '127.0.0.1',
241+
port,
242+
path: '/mcp',
243+
method: 'GET',
244+
headers: {
245+
'Accept': 'text/event-stream',
246+
'Host': `127.0.0.1:${port}`,
247+
...headers
248+
}
249+
};
250+
return new Promise((resolve, reject) => {
251+
const req = http.request(opts, res => {
252+
const contentType = res.headers['content-type'];
253+
resolve({ status: res.statusCode || 0, contentType });
254+
req.destroy(); // close the long-lived SSE stream
255+
});
256+
req.on('error', err => {
257+
// A destroy() after we resolved can surface as ECONNRESET; ignore.
258+
if ((err as NodeJS.ErrnoException).code === 'ECONNRESET') {
259+
return;
260+
}
261+
reject(err);
262+
});
263+
req.end();
264+
});
265+
}
266+
267+
test('initialize over POST mints an mcp-session-id', async () => {
268+
const res = await postInitialize();
269+
assert.strictEqual(res.status, 200, `initialize should succeed, got ${res.status}`);
270+
assert.ok(res.sessionId, 'server did not return an mcp-session-id header on initialize');
271+
});
272+
273+
test('GET /mcp with a valid session opens a text/event-stream', async () => {
274+
const init = await postInitialize();
275+
assert.ok(init.sessionId, 'precondition: initialize must yield a session id');
276+
277+
const res = await getMcp({ 'mcp-session-id': init.sessionId });
278+
assert.strictEqual(res.status, 200, `GET /mcp should open the SSE stream (200), got ${res.status}`);
279+
assert.match(
280+
res.contentType || '',
281+
/text\/event-stream/,
282+
`GET /mcp must serve an SSE stream, got content-type: ${res.contentType}`
283+
);
284+
});
285+
286+
test('GET /mcp without a session id is rejected (400)', async () => {
287+
const res = await getMcp({});
288+
assert.strictEqual(res.status, 400, `GET /mcp with no session must be 400, got ${res.status}`);
289+
});
290+
291+
test('GET /mcp with an unknown session id is rejected (400)', async () => {
292+
const res = await getMcp({ 'mcp-session-id': 'does-not-exist' });
293+
assert.strictEqual(res.status, 400, `GET /mcp with bogus session must be 400, got ${res.status}`);
294+
});
295+
});
172296
});

src/test/debuggingHandler.test.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import * as assert from 'assert';
44
import { DebugState } from '../debugState';
55
import { DebuggingHandler } from '../debuggingHandler';
6+
import { IDebuggingExecutor } from '../debuggingExecutor';
67

78
/**
89
* Test suite for DebuggingHandler state change detection
@@ -106,3 +107,99 @@ suite('DebuggingHandler State Change Detection', () => {
106107
assert.strictEqual(hasStateChanged, true);
107108
});
108109
});
110+
111+
/**
112+
* Tests for the event-driven waitForStateChange (exercised via handleStepOver).
113+
*
114+
* The previous implementation blind-slept ~1s per step regardless of when the
115+
* stop actually landed. The current one resolves the moment the new state is
116+
* observable (fast-path) or the session ends, and only falls back to the
117+
* configured timeout when nothing changes. These tests drive that logic through
118+
* the injected executor's getCurrentDebugState — the real vscode.debug events
119+
* can't be fired from a unit test, but the fast-path and timeout branches both
120+
* funnel through the executor, which is what we assert on here.
121+
*/
122+
suite('DebuggingHandler waitForStateChange (event-driven)', () => {
123+
124+
function lineState(line: number, active = true): DebugState {
125+
const s = new DebugState();
126+
s.sessionActive = active;
127+
if (active) {
128+
s.updateLocation('/test/file.js', 'file.js', line, `let v = ${line};`, []);
129+
s.updateContext(1, 1);
130+
s.updateFrameName('main');
131+
}
132+
return s;
133+
}
134+
135+
// Minimal executor whose getCurrentDebugState is driven by a per-call
136+
// function (call index is 0-based). All other methods are inert.
137+
function makeExecutor(getState: (call: number) => DebugState): IDebuggingExecutor {
138+
let call = 0;
139+
return {
140+
startDebugging: async () => true,
141+
debugTestAtCursor: async () => ({ started: true, runComplete: new Promise<void>(() => { /* pending */ }) }),
142+
stopDebugging: async () => { /* noop */ },
143+
stepOver: async () => { /* noop */ },
144+
stepInto: async () => { /* noop */ },
145+
stepOut: async () => { /* noop */ },
146+
continue: async () => { /* noop */ },
147+
restart: async () => { /* noop */ },
148+
addBreakpoint: async () => { /* noop */ },
149+
removeBreakpoint: async () => { /* noop */ },
150+
getCurrentDebugState: async () => getState(call++),
151+
getVariables: async () => ({}),
152+
evaluateExpression: async () => ({}),
153+
getBreakpoints: () => [],
154+
clearAllBreakpoints: () => { /* noop */ },
155+
hasActiveSession: async () => true,
156+
getActiveSession: () => undefined,
157+
waitForDebugSessionReady: async () => 'no-session'
158+
};
159+
}
160+
161+
test('fast-path: resolves immediately when the new line is already observable', async () => {
162+
// call 0 = before (line 10); subsequent calls = after (line 11).
163+
const executor = makeExecutor(call => (call === 0 ? lineState(10) : lineState(11)));
164+
// Large timeout: if this resolved via the timer instead of the fast
165+
// path, the test would take 30s and the latency assertion would fail.
166+
const handler = new DebuggingHandler(executor, {} as any, 30);
167+
168+
const started = Date.now();
169+
const result = await handler.handleStepOver();
170+
const elapsed = Date.now() - started;
171+
172+
assert.match(result, /"currentLine": 11/, `expected to land on line 11, got: ${result}`);
173+
assert.ok(elapsed < 2000, `fast-path should resolve in ms, took ${elapsed}ms (did it wait for the timeout?)`);
174+
});
175+
176+
test('fast-path: resolves immediately when the session has ended', async () => {
177+
// call 0 = active (line 10); subsequent calls = session inactive.
178+
const executor = makeExecutor(call => (call === 0 ? lineState(10) : lineState(0, false)));
179+
const handler = new DebuggingHandler(executor, {} as any, 30);
180+
181+
const started = Date.now();
182+
const result = await handler.handleStepOver();
183+
const elapsed = Date.now() - started;
184+
185+
assert.match(result, /"sessionActive": false/, `expected an inactive session, got: ${result}`);
186+
assert.ok(elapsed < 2000, `termination should resolve in ms, took ${elapsed}ms`);
187+
});
188+
189+
test('timeout: falls back to the bounded timeout when nothing changes', async () => {
190+
// Every call reports the same active line — no change, no event.
191+
const executor = makeExecutor(() => lineState(10));
192+
// 0.3s timeout so the fallback path is quick to exercise.
193+
const handler = new DebuggingHandler(executor, {} as any, 0.3);
194+
195+
const started = Date.now();
196+
const result = await handler.handleStepOver();
197+
const elapsed = Date.now() - started;
198+
199+
assert.match(result, /"currentLine": 10/, `expected to stay on line 10, got: ${result}`);
200+
// Must actually wait out the timer (not settle spuriously) but still be
201+
// bounded by it (not hang).
202+
assert.ok(elapsed >= 200, `should wait for the ~300ms timeout, only took ${elapsed}ms`);
203+
assert.ok(elapsed < 3000, `timeout should bound the wait, took ${elapsed}ms`);
204+
});
205+
});

0 commit comments

Comments
 (0)