diff --git a/docs/javascript/README.md b/docs/javascript/README.md index a525a81d..d1d9101a 100644 --- a/docs/javascript/README.md +++ b/docs/javascript/README.md @@ -219,6 +219,11 @@ If neither `tsx` nor `ts-node` is installed, the factory emits a warning (not an - Browser/Chrome debugging not yet supported (Node.js via `pwa-node` only) - Remote debugging requires manual configuration - Some advanced DAP features may not be exposed through MCP tools +- Debuggee exit codes are captured via an injected preload (js-debug itself + never emits a DAP `exited` event), so `exitCode` is unavailable in two + cases: attach mode (the target's environment is not under mcp-debugger's + control) and signal-killed debuggees (`process.on('exit')` never runs). + A missing `exitCode` is never replaced with a guessed value. ## Examples diff --git a/packages/adapter-javascript/assets/exitcode-shim.cjs b/packages/adapter-javascript/assets/exitcode-shim.cjs new file mode 100644 index 00000000..08ef6ba3 --- /dev/null +++ b/packages/adapter-javascript/assets/exitcode-shim.cjs @@ -0,0 +1,35 @@ +/** + * mcp-debugger exit-code shim (issue #247). + * + * vscode-js-debug never emits a DAP 'exited' event, so mcp-debugger cannot + * learn the debuggee's exit code from the protocol. This preload (injected + * via NODE_OPTIONS --require by the JavaScript adapter's launch transform) + * records the exit code to a per-session temp file; the proxy worker reads + * it when 'terminated' arrives and replays it as a synthesized 'exited'. + * + * Only the root debuggee writes: the shim claims the file via an env marker + * that descendants (spawned children, worker_threads, cluster workers) + * inherit, so wrappers like tsx — which propagate their child's exit code — + * still record the correct value at the outermost process. + * + * Must never break the debuggee: every step is wrapped, and a missing file + * variable makes the shim a no-op. + */ +(function () { + try { + var file = process.env.MCP_DEBUGGER_EXITCODE_FILE; + if (!file) return; + if (process.env.MCP_DEBUGGER_EXITCODE_CLAIMED === '1') return; + process.env.MCP_DEBUGGER_EXITCODE_CLAIMED = '1'; + var fs = require('fs'); + process.on('exit', function (code) { + try { + fs.writeFileSync(file, String(code)); + } catch (e) { + /* never break the debuggee */ + } + }); + } catch (e) { + /* never break the debuggee */ + } +})(); diff --git a/packages/adapter-javascript/package.json b/packages/adapter-javascript/package.json index 201b7522..f369a4c8 100644 --- a/packages/adapter-javascript/package.json +++ b/packages/adapter-javascript/package.json @@ -7,7 +7,8 @@ "types": "dist/index.d.ts", "files": [ "dist", - "vendor/js-debug" + "vendor/js-debug", + "assets" ], "exports": { ".": { diff --git a/packages/adapter-javascript/src/javascript-debug-adapter.ts b/packages/adapter-javascript/src/javascript-debug-adapter.ts index b2669ecd..d0d3fc98 100644 --- a/packages/adapter-javascript/src/javascript-debug-adapter.ts +++ b/packages/adapter-javascript/src/javascript-debug-adapter.ts @@ -4,7 +4,9 @@ * @since 0.1.0 */ import { EventEmitter } from 'events'; +import * as os from 'os'; import * as path from 'path'; +import { randomUUID } from 'crypto'; import { fileURLToPath } from 'url'; import type { DebugProtocol } from '@vscode/debugprotocol'; import { @@ -380,6 +382,12 @@ export class JavascriptDebugAdapter extends EventEmitter implements IDebugAdapte ? (userEnv.NODE_ENV as string) : 'development'; + // js-debug never emits a DAP 'exited' event, so preload a shim that + // records the debuggee's exit code for the proxy worker to replay as a + // synthesized event (issue #247). Launch mode only - attach targets run + // with an environment we don't control. + this.injectExitCodeShim(mergedEnv); + // Skip files defaults with optional user merge (dedupe) const defaultSkip = ['/**', '**/node_modules/**']; const userSkip = Array.isArray(u.skipFiles) ? (u.skipFiles as string[]) : undefined; @@ -559,6 +567,53 @@ export class JavascriptDebugAdapter extends EventEmitter implements IDebugAdapte }; } + /** + * Inject the exit-code preload shim into the debuggee's environment + * (issue #247). The shim writes the debuggee's exit code to a per-session + * temp file; the proxy worker reads it on 'terminated' and synthesizes the + * DAP 'exited' event js-debug never sends. Missing shim asset degrades + * gracefully to today's behavior (no exitCode), never a failed launch. + */ + private injectExitCodeShim(env: Record): void { + // Idempotency: the merged env starts from process.env, so a server + // itself launched with the shim must not append a second preload + if (/exitcode-shim\.cjs/.test(env.NODE_OPTIONS ?? '')) { + return; + } + + const __filename = fileURLToPath(import.meta.url); + const __dirname = path.dirname(__filename); + const candidates = [ + path.resolve(__dirname, '../assets/exitcode-shim.cjs'), + path.resolve(__dirname, '../../assets/exitcode-shim.cjs'), + // In bundled npx distribution + path.resolve(__dirname, 'assets/exitcode-shim.cjs'), + // In container builds + '/app/packages/adapter-javascript/assets/exitcode-shim.cjs', + '/app/node_modules/@debugmcp/adapter-javascript/assets/exitcode-shim.cjs' + ]; + + let shimPath: string | undefined; + try { + shimPath = candidates.find(p => this.dependencies.fileSystem?.existsSync?.(p)); + } catch { + shimPath = undefined; + } + + if (!shimPath) { + this.dependencies.logger?.warn?.( + '[JavascriptDebugAdapter] exitcode-shim.cjs not found; debuggee exit code will not be captured' + ); + return; + } + + env.MCP_DEBUGGER_EXITCODE_FILE = path.join(os.tmpdir(), `mcp-exitcode-${randomUUID()}.txt`); + // Double quotes survive NODE_OPTIONS parsing for paths with spaces; + // forward slashes sidestep backslash-escape ambiguity on Windows + const requireArg = `--require "${shimPath.replace(/\\/g, '/')}"`; + env.NODE_OPTIONS = env.NODE_OPTIONS ? `${env.NODE_OPTIONS} ${requireArg}`.trim() : requireArg; + } + // ===== Attach Support ===== supportsAttach(): boolean { diff --git a/packages/adapter-javascript/tests/unit/exitcode-shim.test.ts b/packages/adapter-javascript/tests/unit/exitcode-shim.test.ts new file mode 100644 index 00000000..4f6ae6a7 --- /dev/null +++ b/packages/adapter-javascript/tests/unit/exitcode-shim.test.ts @@ -0,0 +1,102 @@ +/** + * Tests for assets/exitcode-shim.cjs (issue #247) + * + * js-debug never emits a DAP 'exited' event, so the debuggee itself records + * its exit code via this NODE_OPTIONS preload; the proxy worker replays it as + * a synthesized 'exited' event. These tests exercise the shim against real + * node child processes. + */ +import { describe, it, expect, beforeEach, afterAll } from 'vitest'; +import { execFileSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const shimPath = path.resolve(__dirname, '../../assets/exitcode-shim.cjs'); + +const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-exitcode-shim-test-')); +let fileCounter = 0; + +function nextExitFile(): string { + return path.join(tempDir, `exit-${++fileCounter}.txt`); +} + +function nodeOptionsFor(shim: string): string { + // Same quoting the adapter uses: double quotes + forward slashes so + // Windows paths with spaces survive NODE_OPTIONS parsing + return `--require "${shim.replace(/\\/g, '/')}"`; +} + +function runNode(script: string, exitFile: string, extraEnv: Record = {}): number { + try { + execFileSync(process.execPath, ['-e', script], { + env: { + ...process.env, + NODE_OPTIONS: nodeOptionsFor(shimPath), + MCP_DEBUGGER_EXITCODE_FILE: exitFile, + ...extraEnv + }, + stdio: 'pipe' + }); + return 0; + } catch (err) { + return (err as { status?: number }).status ?? -1; + } +} + +describe('exitcode-shim.cjs', () => { + beforeEach(() => { + expect(fs.existsSync(shimPath), `shim asset missing at ${shimPath}`).toBe(true); + }); + + afterAll(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('records exit code 0 for a clean exit', () => { + const exitFile = nextExitFile(); + const status = runNode('process.exit(0)', exitFile); + expect(status).toBe(0); + expect(fs.readFileSync(exitFile, 'utf8').trim()).toBe('0'); + }); + + it('records a non-zero explicit exit code', () => { + const exitFile = nextExitFile(); + const status = runNode('process.exit(7)', exitFile); + expect(status).toBe(7); + expect(fs.readFileSync(exitFile, 'utf8').trim()).toBe('7'); + }); + + it('records exit code 1 for an uncaught throw', () => { + const exitFile = nextExitFile(); + const status = runNode('throw new Error("boom")', exitFile); + expect(status).toBe(1); + expect(fs.readFileSync(exitFile, 'utf8').trim()).toBe('1'); + }); + + it('only the root process writes; descendants inherit the claim and skip', () => { + const exitFile = nextExitFile(); + // Parent spawns a child that exits 3 (inheriting env incl. NODE_OPTIONS, + // so the child also loads the shim), then the parent exits 5. The file + // must hold the ROOT's code even though the child exited last-but-first. + const script = [ + "const { spawnSync } = require('child_process');", + "spawnSync(process.execPath, ['-e', 'process.exit(3)'], { env: process.env, stdio: 'ignore' });", + 'process.exit(5);' + ].join('\n'); + const status = runNode(script, exitFile); + expect(status).toBe(5); + expect(fs.readFileSync(exitFile, 'utf8').trim()).toBe('5'); + }); + + it('does nothing when MCP_DEBUGGER_EXITCODE_FILE is unset', () => { + const exitFile = nextExitFile(); + execFileSync(process.execPath, ['-e', 'process.exit(0)'], { + env: { ...process.env, NODE_OPTIONS: nodeOptionsFor(shimPath) }, + stdio: 'pipe' + }); + expect(fs.existsSync(exitFile)).toBe(false); + }); +}); diff --git a/packages/adapter-javascript/tests/unit/javascript-debug-adapter.transform.test.ts b/packages/adapter-javascript/tests/unit/javascript-debug-adapter.transform.test.ts index c9a0eb4f..b3c7f162 100644 --- a/packages/adapter-javascript/tests/unit/javascript-debug-adapter.transform.test.ts +++ b/packages/adapter-javascript/tests/unit/javascript-debug-adapter.transform.test.ts @@ -256,4 +256,78 @@ describe('JavascriptDebugAdapter.transformLaunchConfig', () => { expect(env.CUSTOM_ENV).toBe('1'); expect(process.env.CUSTOM_ENV).toBe(before.CUSTOM_ENV); }); + + describe('exit code shim injection (issue #247)', () => { + // The shim resolution consults dependencies.fileSystem.existsSync (same + // pattern as buildAdapterCommand's vendor lookup) + const depsWithFs = { + ...deps, + fileSystem: { existsSync: () => true } + } as unknown as import('@debugmcp/shared').AdapterDependencies; + + it('injects MCP_DEBUGGER_EXITCODE_FILE and a NODE_OPTIONS --require of the shim', async () => { + const withFs = new JavascriptDebugAdapter(depsWithFs); + const cfg = await withFs.transformLaunchConfig({ + program: path.resolve('/proj/app.js') + } as any); + + const env = cfg.env as Record; + expect(env.MCP_DEBUGGER_EXITCODE_FILE).toMatch(/mcp-exitcode-[0-9a-f-]+\.txt$/); + expect(env.NODE_OPTIONS ?? '').toMatch(/--require "[^"]*exitcode-shim\.cjs"/); + // Forward slashes only: backslash escaping in NODE_OPTIONS is ambiguous on Windows + const requireArg = /--require "([^"]*)"/.exec(env.NODE_OPTIONS)![1]; + expect(requireArg).not.toContain('\\'); + }); + + it('preserves pre-existing NODE_OPTIONS content', async () => { + const withFs = new JavascriptDebugAdapter(depsWithFs); + const cfg = await withFs.transformLaunchConfig({ + program: path.resolve('/proj/app.js'), + env: { NODE_OPTIONS: '--max-old-space-size=2048' } + } as any); + + const env = cfg.env as Record; + expect(env.NODE_OPTIONS).toContain('--max-old-space-size=2048'); + expect(env.NODE_OPTIONS).toMatch(/--require "[^"]*exitcode-shim\.cjs"/); + }); + + it('does not double-append when NODE_OPTIONS already carries the shim', async () => { + const withFs = new JavascriptDebugAdapter(depsWithFs); + const cfg = await withFs.transformLaunchConfig({ + program: path.resolve('/proj/app.js'), + env: { NODE_OPTIONS: '--require "/prior/exitcode-shim.cjs"' } + } as any); + + const env = cfg.env as Record; + const occurrences = env.NODE_OPTIONS.match(/exitcode-shim\.cjs/g) ?? []; + expect(occurrences.length).toBe(1); + }); + + it('skips injection cleanly when the shim asset cannot be resolved', async () => { + const depsNoShim = { + ...deps, + fileSystem: { existsSync: () => false } + } as unknown as import('@debugmcp/shared').AdapterDependencies; + const withoutShim = new JavascriptDebugAdapter(depsNoShim); + + const cfg = await withoutShim.transformLaunchConfig({ + program: path.resolve('/proj/app.js') + } as any); + + const env = cfg.env as Record; + expect(env.MCP_DEBUGGER_EXITCODE_FILE).toBeUndefined(); + expect(env.NODE_OPTIONS ?? '').not.toContain('exitcode-shim'); + }); + + it('leaves attach configs untouched', async () => { + const withFs = new JavascriptDebugAdapter(depsWithFs); + const cfg = await withFs.transformAttachConfig({ + port: 9229 + } as any); + + const env = (cfg.env ?? {}) as Record; + expect(env.MCP_DEBUGGER_EXITCODE_FILE).toBeUndefined(); + expect(env.NODE_OPTIONS ?? '').not.toContain('exitcode-shim'); + }); + }); }); diff --git a/src/proxy/dap-proxy-dependencies.ts b/src/proxy/dap-proxy-dependencies.ts index 10249548..f2b30040 100644 --- a/src/proxy/dap-proxy-dependencies.ts +++ b/src/proxy/dap-proxy-dependencies.ts @@ -37,7 +37,9 @@ export function createProductionDependencies( fileSystem: { ensureDir: (path: string) => fs.ensureDir(path), - pathExists: (path: string) => fs.pathExists(path) + pathExists: (path: string) => fs.pathExists(path), + readFile: (path: string, encoding: 'utf8') => fs.readFile(path, encoding), + remove: (path: string) => fs.remove(path) }, processSpawner: { diff --git a/src/proxy/dap-proxy-interfaces.ts b/src/proxy/dap-proxy-interfaces.ts index 9b7b1877..aaa658d6 100644 --- a/src/proxy/dap-proxy-interfaces.ts +++ b/src/proxy/dap-proxy-interfaces.ts @@ -112,6 +112,8 @@ export interface ILogger { export interface IFileSystem { ensureDir(path: string): Promise; pathExists(path: string): Promise; + readFile(path: string, encoding: 'utf8'): Promise; + remove(path: string): Promise; } /** diff --git a/src/proxy/dap-proxy-worker.ts b/src/proxy/dap-proxy-worker.ts index c5be0b13..35a1a115 100644 --- a/src/proxy/dap-proxy-worker.ts +++ b/src/proxy/dap-proxy-worker.ts @@ -64,6 +64,10 @@ export class DapProxyWorker { private currentInitPayload: ProxyInitPayload | null = null; private state: ProxyState = ProxyState.UNINITIALIZED; private isAttachMode: boolean = false; + // Exit-code synthesis bookkeeping (issue #247): a real DAP exited event + // wins over synthesis, and parent+child terminated events synthesize once + private exitedEventSeen: boolean = false; + private exitSynthesisAttempted: boolean = false; private initializedEventPending: boolean = false; private deferInitializedHandling: boolean = false; private initializedEventHandled: boolean = false; @@ -635,10 +639,16 @@ export class DapProxyWorker { }, onExited: (body) => { this.logger!.info(`[Worker] DAP event: exited exitCode=${body.exitCode}`); + // A real exited event stays authoritative - suppress synthesis (issue #247) + this.exitedEventSeen = true; this.sendDapEvent('exited', body); }, - onTerminated: (body) => { + onTerminated: async (body) => { this.logger!.info(`[Worker] DAP event: terminated body=${JSON.stringify(body)}`); + // Must complete before terminated is forwarded: whichever of + // exited/terminated reaches the SessionManager first strips the + // other's handler, and shutdown() below tears down the client + await this.maybeSynthesizeExitedEvent(); this.sendDapEvent('terminated', body); this.shutdown(); }, @@ -1083,6 +1093,51 @@ export class DapProxyWorker { this.dependencies.messageSender.send(message); } + /** + * Replay the debuggee's recorded exit code as a DAP 'exited' event + * (issue #247). js-debug never emits one; the JavaScript adapter's launch + * transform preloads a shim that writes the code to a per-session file + * whose path travels in the launch config env. Self-gating: sessions + * without the env marker (every other adapter) skip instantly, and a + * missing file (signal kill, user stop) is the normal no-exitCode path. + */ + private async maybeSynthesizeExitedEvent(): Promise { + if (this.exitedEventSeen || this.exitSynthesisAttempted) { + return; + } + this.exitSynthesisAttempted = true; + + const env = this.currentInitPayload?.launchConfig?.env as Record | undefined; + const exitFile = env?.MCP_DEBUGGER_EXITCODE_FILE; + if (!exitFile) { + return; + } + + try { + if (!(await this.dependencies.fileSystem.pathExists(exitFile))) { + this.logger?.info?.('[Worker] No recorded exit code (signal kill or user stop); exitCode stays unknown'); + return; + } + const raw = (await this.dependencies.fileSystem.readFile(exitFile, 'utf8')).trim(); + const exitCode = Number.parseInt(raw, 10); + if (Number.isFinite(exitCode)) { + this.logger?.info?.(`[Worker] Synthesizing 'exited' from recorded debuggee exit code ${exitCode}`); + this.sendDapEvent('exited', { exitCode }); + } else { + this.logger?.warn?.(`[Worker] Unparseable exit code file content: '${raw}'`); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + this.logger?.warn?.(`[Worker] Exit code synthesis failed: ${msg}`); + } finally { + try { + await this.dependencies.fileSystem.remove(exitFile); + } catch { + // Best effort - a stray ~3-byte temp file is acceptable + } + } + } + private sendDapEvent(event: string, body: unknown): void { const message: DapEventMessage = { type: 'dapEvent', diff --git a/tests/e2e/mcp-server-break-on-exceptions.test.ts b/tests/e2e/mcp-server-break-on-exceptions.test.ts index 30e5df7d..6e5bde31 100644 --- a/tests/e2e/mcp-server-break-on-exceptions.test.ts +++ b/tests/e2e/mcp-server-break-on-exceptions.test.ts @@ -26,6 +26,7 @@ const ROOT = path.resolve(__dirname, '../..'); const CRASHING_SCRIPT = path.resolve(ROOT, 'tests', 'fixtures', 'debug-scripts', 'with-errors.py'); const JS_CRASHING_SCRIPT = path.resolve(ROOT, 'tests', 'fixtures', 'debug-scripts', 'js-throws.js'); +const JS_CLEAN_SCRIPT = path.resolve(ROOT, 'tests', 'fixtures', 'debug-scripts', 'js-clean-exit.js'); const ATTACH_SCRIPT = path.resolve(ROOT, 'tests', 'fixtures', 'python', 'attach_then_raise.py'); const PYTHON = process.platform === 'win32' ? 'python' : 'python3'; @@ -321,6 +322,32 @@ describe('Break-on-exception (issue #220)', () => { expect(stopped, 'js session should terminate promptly instead of hanging (issue #242)').toBeDefined(); // No user-visible stop was recorded on the way down expect(stopped!.lastStop?.reason).not.toBe('exception'); + // Crash vs clean exit is distinguishable (issue #247): the exit-code + // shim records the debuggee's code and the worker replays it, matching + // the contract the python twin asserts above + expect(stopped!.exitCode).toBeDefined(); + expect(stopped!.exitCode).not.toBe(0); + }, 60000); + + it('reports exit code 0 for a clean run (issue #247)', async () => { + sessionId = await createSession('javascript', 'js-clean-exit-code'); + + const startRes = parseSdkToolResult(await mcpClient!.callTool({ + name: 'start_debugging', + arguments: { + sessionId, + scriptPath: JS_CLEAN_SCRIPT, + dapLaunchArgs: { stopOnEntry: false } + } + })); + expect(startRes.success).toBe(true); + + const stopped = await pollUntil(async () => { + const snap = await getSessionSnapshot(mcpClient!, sessionId!); + return snap?.state === 'stopped' ? snap : undefined; + }, 20000); + expect(stopped, 'js session should run to completion').toBeDefined(); + expect(stopped!.exitCode).toBe(0); }, 60000); }); diff --git a/tests/proxy/dap-proxy-worker.test.ts b/tests/proxy/dap-proxy-worker.test.ts index a61381bb..d8bd0809 100644 --- a/tests/proxy/dap-proxy-worker.test.ts +++ b/tests/proxy/dap-proxy-worker.test.ts @@ -40,7 +40,9 @@ const createMockLogger = (): ILogger => ({ const createMockFileSystem = (): IFileSystem => ({ ensureDir: vi.fn().mockResolvedValue(undefined), - pathExists: vi.fn().mockResolvedValue(true) + pathExists: vi.fn().mockResolvedValue(true), + readFile: vi.fn().mockResolvedValue(''), + remove: vi.fn().mockResolvedValue(undefined) }); const createMockProcessSpawner = (): IProcessSpawner => ({ @@ -2206,4 +2208,108 @@ describe('DapProxyWorker', () => { expect(worker.getState()).toBe(ProxyState.TERMINATED); }); }); + + describe('JS debuggee exit code synthesis (issue #247)', () => { + const exitFile = 'C:/tmp/mcp-exitcode-test.txt'; + let connectionHandlers: Record unknown>; + let shutdownSpy: ReturnType; + + const wireHandlers = (env?: Record) => { + connectionHandlers = {}; + const connectionStub = { + setupEventHandlers: vi.fn((_client: unknown, handlers: Record unknown>) => { + Object.assign(connectionHandlers, handlers); + }) + }; + (worker as any).logger = mockLogger; + (worker as any).dapClient = mockDapClient; + (worker as any).connectionManager = connectionStub; + (worker as any).adapterPolicy = JsDebugAdapterPolicy; + (worker as any).adapterState = JsDebugAdapterPolicy.createInitialState(); + (worker as any).currentInitPayload = { launchConfig: env ? { env } : {} }; + shutdownSpy = vi.spyOn(worker as any, 'shutdown').mockResolvedValue(undefined) as ReturnType; + (worker as any).setupDapEventHandlers(); + mockMessageSender.send.mockClear(); + }; + + const dapEvents = () => + mockMessageSender.send.mock.calls + .map(call => call[0] as { type: string; event?: string; body?: unknown }) + .filter(msg => msg.type === 'dapEvent'); + + afterEach(() => { + shutdownSpy?.mockRestore(); + }); + + it('synthesizes exited from the recorded exit code before forwarding terminated', async () => { + wireHandlers({ MCP_DEBUGGER_EXITCODE_FILE: exitFile }); + (dependencies.fileSystem.pathExists as Mock).mockResolvedValue(true); + (dependencies.fileSystem.readFile as Mock).mockResolvedValue('3'); + + await connectionHandlers.onTerminated?.({}); + + const events = dapEvents(); + expect(events[0]).toEqual(expect.objectContaining({ event: 'exited', body: { exitCode: 3 } })); + expect(events[1]).toEqual(expect.objectContaining({ event: 'terminated' })); + expect(dependencies.fileSystem.remove).toHaveBeenCalledWith(exitFile); + expect(shutdownSpy).toHaveBeenCalled(); + }); + + it('forwards only terminated when no exit code file exists (signal kill, user stop)', async () => { + wireHandlers({ MCP_DEBUGGER_EXITCODE_FILE: exitFile }); + (dependencies.fileSystem.pathExists as Mock).mockResolvedValue(false); + + await connectionHandlers.onTerminated?.({}); + + const events = dapEvents(); + expect(events.map(e => e.event)).toEqual(['terminated']); + }); + + it('keeps a real exited event authoritative and never double-fires', async () => { + wireHandlers({ MCP_DEBUGGER_EXITCODE_FILE: exitFile }); + (dependencies.fileSystem.pathExists as Mock).mockResolvedValue(true); + (dependencies.fileSystem.readFile as Mock).mockResolvedValue('3'); + + await connectionHandlers.onExited?.({ exitCode: 5 }); + await connectionHandlers.onTerminated?.({}); + + const events = dapEvents(); + const exitedEvents = events.filter(e => e.event === 'exited'); + expect(exitedEvents).toHaveLength(1); + expect(exitedEvents[0].body).toEqual({ exitCode: 5 }); + }); + + it('synthesizes at most once across parent and child terminated events', async () => { + wireHandlers({ MCP_DEBUGGER_EXITCODE_FILE: exitFile }); + (dependencies.fileSystem.pathExists as Mock).mockResolvedValue(true); + (dependencies.fileSystem.readFile as Mock).mockResolvedValue('2'); + + await connectionHandlers.onTerminated?.({}); + await connectionHandlers.onTerminated?.({}); + + const exitedEvents = dapEvents().filter(e => e.event === 'exited'); + expect(exitedEvents).toHaveLength(1); + }); + + it('skips synthesis on unparseable file content', async () => { + wireHandlers({ MCP_DEBUGGER_EXITCODE_FILE: exitFile }); + (dependencies.fileSystem.pathExists as Mock).mockResolvedValue(true); + (dependencies.fileSystem.readFile as Mock).mockResolvedValue('garbage'); + + await connectionHandlers.onTerminated?.({}); + + const events = dapEvents(); + expect(events.map(e => e.event)).toEqual(['terminated']); + }); + + it('skips synthesis for sessions without the env marker (non-js adapters)', async () => { + wireHandlers(); + + await connectionHandlers.onTerminated?.({}); + + const events = dapEvents(); + expect(events.map(e => e.event)).toEqual(['terminated']); + expect(dependencies.fileSystem.pathExists).not.toHaveBeenCalled(); + }); + }); });