From e147d7f304615d29cb469afee35c776c89ac42ab Mon Sep 17 00:00:00 2001 From: Sergei Kuzmin Date: Sat, 20 Jun 2026 23:49:24 -0700 Subject: [PATCH 1/2] Fix MCP connection errors, slow stepping, and stale debug state Three reliability/perf fixes to the extension: - Server: run StreamableHTTPServerTransport in stateful session mode so GET /mcp opens a real server->client SSE stream. Register GET/DELETE /mcp at startup (previously nested in the POST error handler, so GET returned a bare 404). Cursor's client treated the failed SSE open as a fatal transport error and tombstoned the connection as "errored" even though POST tool calls worked. - Stepping: replace the 1s blind-poll waitForStateChange with an event-driven wait on vscode.debug.onDidChangeActiveStackItem / onDidTerminateDebugSession, cutting per-step latency from ~1s to ms. - State: derive current file/line from the DAP top stack frame instead of scraping the active editor, fixing stale "paused at line N" reports. --- src/debugMCPServer.ts | 147 +++++++++++++++++++++++++++------------ src/debuggingExecutor.ts | 100 +++++++++++++++++--------- src/debuggingHandler.ts | 91 +++++++++++++++++------- 3 files changed, 234 insertions(+), 104 deletions(-) diff --git a/src/debugMCPServer.ts b/src/debugMCPServer.ts index 9e6264b..d356b05 100644 --- a/src/debugMCPServer.ts +++ b/src/debugMCPServer.ts @@ -3,6 +3,7 @@ import * as vscode from 'vscode'; import { z } from 'zod'; import * as http from 'http'; +import { randomUUID } from 'node:crypto'; import { DebuggingExecutor, ConfigurationManager, @@ -12,6 +13,7 @@ import { import { logger } from './utils/logger'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; /** * Main MCP server class that exposes debugging functionality as tools. @@ -103,6 +105,10 @@ export class DebugMCPServer { private hosts: string[]; private initialized: boolean = false; private debuggingHandler: IDebuggingHandler; + // Active Streamable-HTTP transports keyed by MCP session id. The transport + // is created on `initialize` and reused for that session's subsequent + // POST (requests), GET (server->client SSE stream), and DELETE (teardown). + private transports: Record = {}; constructor(port: number, timeoutInSeconds: number, host: string | string[] = ['127.0.0.1', '::1']) { // Initialize the debugging components with dependency injection @@ -116,11 +122,12 @@ export class DebugMCPServer { /** * Initialize the MCP server factory. * - * NOTE: We no longer hold a singleton McpServer here. The stateless - * StreamableHTTPServerTransport requires a fresh McpServer per request + * NOTE: We no longer hold a singleton McpServer here. In stateful session + * mode each `initialize` request gets its own transport + McpServer pair * (calling .connect() twice on the same server throws "Already connected - * to a transport"). The /mcp handler builds one on demand via - * createMcpServer(). + * to a transport"). The POST /mcp handler builds one per session via + * createMcpServer() and keeps it in `this.transports` for the session's + * lifetime. */ async initialize() { if (this.initialized) { @@ -131,7 +138,7 @@ export class DebugMCPServer { /** * Build a fresh McpServer with all tools registered. - * Called once per incoming MCP request. + * Called once per session, when an `initialize` request opens it. */ private createMcpServer(): McpServer { const server = new McpServer({ @@ -383,25 +390,62 @@ export class DebugMCPServer { }); // Streamable HTTP endpoint — handles MCP protocol messages. - // A fresh McpServer + transport pair is built per request because - // StreamableHTTPServerTransport in stateless mode (sessionIdGenerator: undefined) - // owns its connection; reusing a single McpServer across requests - // throws "Already connected to a transport" on the second call. + // A fresh McpServer + transport pair is built per session (on the + // `initialize` request) and reused for that session's subsequent + // requests; reusing one McpServer across sessions would throw + // "Already connected to a transport" on the second connect(). + // POST /mcp — client→server JSON-RPC. An `initialize` request with no + // session id creates a new session (transport + McpServer pair) and is + // remembered by its generated session id; subsequent requests carrying + // that `mcp-session-id` header reuse the same transport. + // + // NOTE: We run in *stateful* (session) mode rather than stateless. + // Stateless mode (sessionIdGenerator: undefined) cannot serve the + // server→client SSE stream that clients open via GET /mcp right after + // initialize. Cursor's MCP client treats a failed stream open as a fatal + // transport error and tombstones the connection after a few attempts, + // leaving the server permanently flagged "errored" even though POST tool + // calls still work. Session mode gives GET a real stream to attach to. app.post('/mcp', async (req: any, res: any) => { - logger.info('New MCP request received'); - - const server = this.createMcpServer(); - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: undefined, // Stateless mode - no session management - }); - res.on('close', () => { - transport.close(); - server.close(); - logger.info('MCP transport closed'); - }); - try { - await server.connect(transport); + const sessionId = req.headers['mcp-session-id'] as string | undefined; + let transport: StreamableHTTPServerTransport; + + if (sessionId && this.transports[sessionId]) { + // Reuse the transport for an established session. + transport = this.transports[sessionId]; + } else if (!sessionId && isInitializeRequest(req.body)) { + // Brand-new session: build a transport + server and register it + // once the SDK assigns a session id. + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (sid: string) => { + this.transports[sid] = transport; + logger.info(`MCP session initialized: ${sid}`); + }, + }); + transport.onclose = () => { + const sid = transport.sessionId; + if (sid && this.transports[sid]) { + delete this.transports[sid]; + logger.info(`MCP session closed: ${sid}`); + } + }; + const server = this.createMcpServer(); + await server.connect(transport); + } else { + // No session id and not an initialize request — invalid. + res.status(400).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Bad Request: no valid session ID provided' + }, + id: null + }); + return; + } + await transport.handleRequest(req, res, req.body); } catch (error) { logger.error('Error while handling MCP request', error); @@ -411,34 +455,37 @@ export class DebugMCPServer { error: { code: -32603, message: 'Internal MCP server error' - } - }); - - app.get('/mcp', async (_req: any, res: any) => { - res.status(405).json({ - jsonrpc: '2.0', - error: { - code: -32000, - message: 'Method not allowed. Use POST /mcp.' - }, - id: null - }); - }); - - app.delete('/mcp', async (_req: any, res: any) => { - res.status(405).json({ - jsonrpc: '2.0', - error: { - code: -32000, - message: 'Method not allowed. Use POST /mcp.' - }, - id: null - }); + }, + id: null }); } } }); + // GET /mcp opens the server→client SSE notification stream for an + // existing session; DELETE /mcp terminates a session. Both require a + // valid mcp-session-id and are delegated to that session's transport. + // These MUST be registered at startup (a prior bug registered them + // lazily inside the POST error handler, so GET /mcp returned a bare 404 + // under normal operation and clients reported the server as errored). + const handleSessionRequest = async (req: any, res: any) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (!sessionId || !this.transports[sessionId]) { + res.status(400).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Bad Request: invalid or missing session ID' + }, + id: null + }); + return; + } + await this.transports[sessionId].handleRequest(req, res); + }; + app.get('/mcp', handleSessionRequest); + app.delete('/mcp', handleSessionRequest); + // Legacy SSE endpoint for backward compatibility // Redirects to the new /mcp endpoint with appropriate headers app.get('/sse', async (req: any, res: any) => { @@ -484,6 +531,16 @@ export class DebugMCPServer { * Stop the MCP server */ async stop() { + // Tear down any open MCP sessions (closes their SSE streams) first. + for (const sessionId of Object.keys(this.transports)) { + try { + this.transports[sessionId].close(); + } catch (error) { + logger.warn(`Error closing MCP session ${sessionId}`, error); + } + } + this.transports = {}; + // Close all HTTP servers if (this.httpServers.length > 0) { await Promise.all(this.httpServers.map(server => diff --git a/src/debuggingExecutor.ts b/src/debuggingExecutor.ts index bd77419..2d82244 100644 --- a/src/debuggingExecutor.ts +++ b/src/debuggingExecutor.ts @@ -335,36 +335,18 @@ export class DebuggingExecutor implements IDebuggingExecutor { const activeStackItem = vscode.debug.activeStackItem; if (activeStackItem && 'frameId' in activeStackItem) { state.updateContext(activeStackItem.frameId, activeStackItem.threadId); - - // Extract frame name from stack frame - await this.extractFrameName(activeSession, activeStackItem.frameId, state); - - // Get the active editor - const activeEditor = vscode.window.activeTextEditor; - if (activeEditor) { - const fileName = activeEditor.document.fileName.split(/[/\\]/).pop() || ''; - const currentLine = activeEditor.selection.active.line + 1; // 1-based line number - const currentLineContent = activeEditor.document.lineAt(activeEditor.selection.active.line).text.trim(); - - // Get next non-empty lines - const nextLines = []; - let lineOffset = 1; - while (nextLines.length < numNextLines && - activeEditor.selection.active.line + lineOffset < activeEditor.document.lineCount) { - const lineText = activeEditor.document.lineAt(activeEditor.selection.active.line + lineOffset).text.trim(); - if (lineText.length > 0) { - nextLines.push(lineText); - } - lineOffset++; - } - - state.updateLocation( - activeEditor.document.fileName, - fileName, - currentLine, - currentLineContent, - nextLines - ); + + // Pull the current location from the debug adapter's top stack + // frame (via stackTrace) instead of scraping the active text + // editor. VS Code updates the editor cursor/selection + // asynchronously after a stop and only for the focused editor, + // so reading it here was both racy (it lagged the actual stop) + // and wrong when focus was elsewhere — the source of stale + // "current line" reports. The DAP frame is ground truth. + const topFrame = await this.extractFrameName(activeSession, activeStackItem.frameId, state); + + if (topFrame?.path && typeof topFrame.line === 'number') { + await this.populateLocationFromFrame(state, topFrame.path, topFrame.line, numNextLines); } } } @@ -387,9 +369,17 @@ export class DebuggingExecutor implements IDebuggingExecutor { } /** - * Extract frame name and stack trace from the current debug session + * Extract frame name and stack trace from the current debug session. + * + * Returns the top frame's source location ({ path, line, column }) so the + * caller can report the authoritative current position without scraping the + * editor. Returns undefined if no stack frame is available. */ - private async extractFrameName(session: vscode.DebugSession, frameId: number, state: DebugState): Promise { + private async extractFrameName( + session: vscode.DebugSession, + frameId: number, + state: DebugState + ): Promise<{ path?: string; line?: number; column?: number } | undefined> { try { const stackTraceResponse = await session.customRequest('stackTrace', { threadId: state.threadId, @@ -411,6 +401,14 @@ export class DebuggingExecutor implements IDebuggingExecutor { })); state.updateStackTrace(stackTrace); + + // DAP line/column are 1-based (VS Code's default). Hand the raw + // top-frame location back to the caller for location reporting. + return { + path: currentFrame.source?.path, + line: currentFrame.line, + column: currentFrame.column, + }; } } catch (error) { console.log('Unable to extract stack info:', error); @@ -418,6 +416,44 @@ export class DebuggingExecutor implements IDebuggingExecutor { state.updateFrameName(null); state.updateStackTrace([]); } + return undefined; + } + + /** + * Populate the DebugState location (file, current line + content, and the + * next few non-empty lines) by reading the source document at the debugger's + * current frame line. Uses the DAP-reported path/line rather than the active + * editor, so it's accurate regardless of which editor (if any) has focus. + */ + private async populateLocationFromFrame( + state: DebugState, + filePath: string, + line: number, + numNextLines: number + ): Promise { + try { + const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(filePath)); + const zeroBasedLine = Math.max(0, Math.min(line - 1, doc.lineCount - 1)); + const fileName = filePath.split(/[/\\]/).pop() || ''; + const currentLineContent = doc.lineAt(zeroBasedLine).text.trim(); + + // Collect the next non-empty lines for lookahead context. + const nextLines: string[] = []; + let lineOffset = 1; + while (nextLines.length < numNextLines && zeroBasedLine + lineOffset < doc.lineCount) { + const lineText = doc.lineAt(zeroBasedLine + lineOffset).text.trim(); + if (lineText.length > 0) { + nextLines.push(lineText); + } + lineOffset++; + } + + state.updateLocation(filePath, fileName, line, currentLineContent, nextLines); + } catch (error) { + // Native/library frames or paths VS Code can't open won't resolve to + // a document; degrade gracefully and leave location unset. + console.log('Unable to read frame source document:', error); + } } /** diff --git a/src/debuggingHandler.ts b/src/debuggingHandler.ts index a03b32f..c8e7998 100644 --- a/src/debuggingHandler.ts +++ b/src/debuggingHandler.ts @@ -468,38 +468,75 @@ export class DebuggingHandler implements IDebuggingHandler { } /** - * Wait for debugger state to change from the initial state using exponential backoff + * Wait for the debugger to reach a new stopped frame (or end the session) + * after a step/continue, driven by VS Code debug events. + * + * The previous implementation polled `getCurrentDebugState` on a fixed ~1s + * interval: it checked once immediately (almost always too early — the DAP + * `stopped` event hasn't landed yet), then blind-slept ~1s before looking + * again. That cost ~1s per step/continue even though the operation itself + * completes in tens of milliseconds. There is no early-wakeup — a state + * change 10ms into the sleep is ignored for the rest of the second. + * + * This version subscribes to the same events the start path already uses + * (`onDidChangeActiveStackItem` for a new stopped frame, plus session + * termination) so it reacts the instant the step lands. A fast-path check + * covers the case where the step already completed before we got here, and + * a timeout bounds the no-event/never-stops case. */ private async waitForStateChange(beforeState: DebugState): Promise { - const baseDelay = 1000; // Start with 1 second - const maxDelay = 1000; // Cap at 1 second - const startTime = Date.now(); - let attempt = 0; - - while (Date.now() - startTime < this.timeoutInSeconds * 1000) { - const currentState = await this.executor.getCurrentDebugState(this.numNextLines); - - if (this.hasStateChanged(beforeState, currentState)) { - return currentState; - } - - // If session ended, return immediately - if (!currentState.sessionActive) { - return currentState; - } - - logger.info(`[Attempt ${attempt + 1}] Waiting for debugger state to change...`); + const timeoutMs = this.timeoutInSeconds * 1000; + const subscriptions: vscode.Disposable[] = []; - // Calculate delay using exponential backoff with jitter (same as waitForActiveDebugSession) - const delay = Math.min(baseDelay * Math.pow(2, attempt), maxDelay); - const jitteredDelay = delay + Math.random() * 200; // Add up to 200ms jitter + try { + await new Promise(resolve => { + let settled = false; + const settle = () => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + resolve(); + }; + + const timer = setTimeout(() => { + logger.info('State change detection timed out, returning current state'); + settle(); + }, timeoutMs); + + // Register listeners BEFORE the fast-path check so a stop that + // lands during that async check can't slip through unobserved. + subscriptions.push( + vscode.debug.onDidChangeActiveStackItem(stackItem => { + // A newly focused stack frame is the signal that the + // step/continue has landed at its next stop. + if (stackItem && 'frameId' in stackItem) { + settle(); + } + }) + ); + subscriptions.push( + vscode.debug.onDidTerminateDebugSession(() => { + // continue/step that runs the program to completion. + if (!vscode.debug.activeDebugSession) { + settle(); + } + }) + ); - await new Promise(resolve => setTimeout(resolve, jitteredDelay)); - attempt++; + // Fast path: the step/continue may already have landed by the + // time we subscribed (e.g. a trivial single-line step). + void this.executor.getCurrentDebugState(this.numNextLines).then(currentState => { + if (this.hasStateChanged(beforeState, currentState) || !currentState.sessionActive) { + settle(); + } + }); + }); + } finally { + subscriptions.forEach(d => d.dispose()); } - - // If we timeout, return the current state (might be unchanged) - logger.info('State change detection timed out, returning current state'); + return await this.executor.getCurrentDebugState(this.numNextLines); } From 75ece8ec505d76da5bc54c705f64db89ab214840 Mon Sep 17 00:00:00 2001 From: Sergei Kuzmin Date: Sun, 21 Jun 2026 00:14:08 -0700 Subject: [PATCH 2/2] Add tests for stateful session lifecycle and event-driven step wait MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/test/debugMCPServer.security.test.ts | 124 +++++++++++++++++++++++ src/test/debuggingHandler.test.ts | 97 ++++++++++++++++++ 2 files changed, 221 insertions(+) diff --git a/src/test/debugMCPServer.security.test.ts b/src/test/debugMCPServer.security.test.ts index 16795c8..cb4ced6 100644 --- a/src/test/debugMCPServer.security.test.ts +++ b/src/test/debugMCPServer.security.test.ts @@ -169,4 +169,128 @@ suite('DebugMCPServer security', () => { } }); }); + + // Stateful Streamable-HTTP session lifecycle. Regression guard for the bug + // where the transport ran stateless and GET /mcp returned 404, so clients + // (Cursor) could never open the server->client SSE stream and tombstoned the + // connection as "errored". Here we drive the real handshake: initialize over + // POST mints a session id, and GET /mcp with that id must return a live + // text/event-stream. + suite('Streamable-HTTP session lifecycle', () => { + const port = 30100; + let server: DebugMCPServer; + + suiteSetup(async () => { + server = new DebugMCPServer(port, 60); + await server.initialize(); + await server.start(); + }); + + suiteTeardown(async () => { + await server.stop(); + }); + + // POST an `initialize` request and resolve with the response status plus + // the mcp-session-id header the server assigns to the new session. + function postInitialize(): Promise<{ status: number; sessionId?: string }> { + const body = JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2025-06-18', + capabilities: {}, + clientInfo: { name: 'lifecycle-test', version: '0.0.0' } + } + }); + const opts: http.RequestOptions = { + host: '127.0.0.1', + port, + path: '/mcp', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/event-stream', + 'Content-Length': Buffer.byteLength(body).toString(), + 'Host': `127.0.0.1:${port}` + } + }; + return new Promise((resolve, reject) => { + const req = http.request(opts, res => { + const sessionId = res.headers['mcp-session-id'] as string | undefined; + // Drain so the socket is released; we only need status + id. + res.on('data', () => { /* discard */ }); + res.on('end', () => resolve({ status: res.statusCode || 0, sessionId })); + // initialize responds on an SSE channel that may stay open; the + // headers (and session id) are already available, so resolve now + // and let the drain handler clean up. + resolve({ status: res.statusCode || 0, sessionId }); + }); + req.on('error', reject); + req.write(body); + req.end(); + }); + } + + // Open GET /mcp (the server->client SSE stream) and resolve with the + // status + content-type as soon as the response headers arrive, then + // tear down the long-lived stream. + function getMcp(headers: http.OutgoingHttpHeaders): Promise<{ status: number; contentType?: string }> { + const opts: http.RequestOptions = { + host: '127.0.0.1', + port, + path: '/mcp', + method: 'GET', + headers: { + 'Accept': 'text/event-stream', + 'Host': `127.0.0.1:${port}`, + ...headers + } + }; + return new Promise((resolve, reject) => { + const req = http.request(opts, res => { + const contentType = res.headers['content-type']; + resolve({ status: res.statusCode || 0, contentType }); + req.destroy(); // close the long-lived SSE stream + }); + req.on('error', err => { + // A destroy() after we resolved can surface as ECONNRESET; ignore. + if ((err as NodeJS.ErrnoException).code === 'ECONNRESET') { + return; + } + reject(err); + }); + req.end(); + }); + } + + test('initialize over POST mints an mcp-session-id', async () => { + const res = await postInitialize(); + assert.strictEqual(res.status, 200, `initialize should succeed, got ${res.status}`); + assert.ok(res.sessionId, 'server did not return an mcp-session-id header on initialize'); + }); + + test('GET /mcp with a valid session opens a text/event-stream', async () => { + const init = await postInitialize(); + assert.ok(init.sessionId, 'precondition: initialize must yield a session id'); + + const res = await getMcp({ 'mcp-session-id': init.sessionId }); + assert.strictEqual(res.status, 200, `GET /mcp should open the SSE stream (200), got ${res.status}`); + assert.match( + res.contentType || '', + /text\/event-stream/, + `GET /mcp must serve an SSE stream, got content-type: ${res.contentType}` + ); + }); + + test('GET /mcp without a session id is rejected (400)', async () => { + const res = await getMcp({}); + assert.strictEqual(res.status, 400, `GET /mcp with no session must be 400, got ${res.status}`); + }); + + test('GET /mcp with an unknown session id is rejected (400)', async () => { + const res = await getMcp({ 'mcp-session-id': 'does-not-exist' }); + assert.strictEqual(res.status, 400, `GET /mcp with bogus session must be 400, got ${res.status}`); + }); + }); }); diff --git a/src/test/debuggingHandler.test.ts b/src/test/debuggingHandler.test.ts index cea59db..d49e511 100644 --- a/src/test/debuggingHandler.test.ts +++ b/src/test/debuggingHandler.test.ts @@ -3,6 +3,7 @@ import * as assert from 'assert'; import { DebugState } from '../debugState'; import { DebuggingHandler } from '../debuggingHandler'; +import { IDebuggingExecutor } from '../debuggingExecutor'; /** * Test suite for DebuggingHandler state change detection @@ -106,3 +107,99 @@ suite('DebuggingHandler State Change Detection', () => { assert.strictEqual(hasStateChanged, true); }); }); + +/** + * Tests for the event-driven waitForStateChange (exercised via handleStepOver). + * + * The previous implementation blind-slept ~1s per step regardless of when the + * stop actually landed. The current one resolves the moment the new state is + * observable (fast-path) or the session ends, and only falls back to the + * configured timeout when nothing changes. These tests drive that logic through + * the injected executor's getCurrentDebugState — the real vscode.debug events + * can't be fired from a unit test, but the fast-path and timeout branches both + * funnel through the executor, which is what we assert on here. + */ +suite('DebuggingHandler waitForStateChange (event-driven)', () => { + + function lineState(line: number, active = true): DebugState { + const s = new DebugState(); + s.sessionActive = active; + if (active) { + s.updateLocation('/test/file.js', 'file.js', line, `let v = ${line};`, []); + s.updateContext(1, 1); + s.updateFrameName('main'); + } + return s; + } + + // Minimal executor whose getCurrentDebugState is driven by a per-call + // function (call index is 0-based). All other methods are inert. + function makeExecutor(getState: (call: number) => DebugState): IDebuggingExecutor { + let call = 0; + return { + startDebugging: async () => true, + debugTestAtCursor: async () => ({ started: true, runComplete: new Promise(() => { /* pending */ }) }), + stopDebugging: async () => { /* noop */ }, + stepOver: async () => { /* noop */ }, + stepInto: async () => { /* noop */ }, + stepOut: async () => { /* noop */ }, + continue: async () => { /* noop */ }, + restart: async () => { /* noop */ }, + addBreakpoint: async () => { /* noop */ }, + removeBreakpoint: async () => { /* noop */ }, + getCurrentDebugState: async () => getState(call++), + getVariables: async () => ({}), + evaluateExpression: async () => ({}), + getBreakpoints: () => [], + clearAllBreakpoints: () => { /* noop */ }, + hasActiveSession: async () => true, + getActiveSession: () => undefined, + waitForDebugSessionReady: async () => 'no-session' + }; + } + + test('fast-path: resolves immediately when the new line is already observable', async () => { + // call 0 = before (line 10); subsequent calls = after (line 11). + const executor = makeExecutor(call => (call === 0 ? lineState(10) : lineState(11))); + // Large timeout: if this resolved via the timer instead of the fast + // path, the test would take 30s and the latency assertion would fail. + const handler = new DebuggingHandler(executor, {} as any, 30); + + const started = Date.now(); + const result = await handler.handleStepOver(); + const elapsed = Date.now() - started; + + assert.match(result, /"currentLine": 11/, `expected to land on line 11, got: ${result}`); + assert.ok(elapsed < 2000, `fast-path should resolve in ms, took ${elapsed}ms (did it wait for the timeout?)`); + }); + + test('fast-path: resolves immediately when the session has ended', async () => { + // call 0 = active (line 10); subsequent calls = session inactive. + const executor = makeExecutor(call => (call === 0 ? lineState(10) : lineState(0, false))); + const handler = new DebuggingHandler(executor, {} as any, 30); + + const started = Date.now(); + const result = await handler.handleStepOver(); + const elapsed = Date.now() - started; + + assert.match(result, /"sessionActive": false/, `expected an inactive session, got: ${result}`); + assert.ok(elapsed < 2000, `termination should resolve in ms, took ${elapsed}ms`); + }); + + test('timeout: falls back to the bounded timeout when nothing changes', async () => { + // Every call reports the same active line — no change, no event. + const executor = makeExecutor(() => lineState(10)); + // 0.3s timeout so the fallback path is quick to exercise. + const handler = new DebuggingHandler(executor, {} as any, 0.3); + + const started = Date.now(); + const result = await handler.handleStepOver(); + const elapsed = Date.now() - started; + + assert.match(result, /"currentLine": 10/, `expected to stay on line 10, got: ${result}`); + // Must actually wait out the timer (not settle spuriously) but still be + // bounded by it (not hang). + assert.ok(elapsed >= 200, `should wait for the ~300ms timeout, only took ${elapsed}ms`); + assert.ok(elapsed < 3000, `timeout should bound the wait, took ${elapsed}ms`); + }); +});