diff --git a/packages/shared/src/interfaces/adapter-policy-ruby.ts b/packages/shared/src/interfaces/adapter-policy-ruby.ts index 4bc2d015..58dfc5e3 100644 --- a/packages/shared/src/interfaces/adapter-policy-ruby.ts +++ b/packages/shared/src/interfaces/adapter-policy-ruby.ts @@ -215,7 +215,11 @@ export const RubyAdapterPolicy: AdapterPolicy = { // only ever appears on the adapter's pipes (issue #222). rdbg's own // stderr banners ("DEBUGGER: wait for debugger connection...") are // excluded from forwarding but still logged. - forwardStdio: { excludeStderrLinePattern: /^DEBUGGER: / } + forwardStdio: { excludeStderrLinePattern: /^DEBUGGER: / }, + // For the same reason the adapter process's exit status is the + // debuggee's, and rdbg never sends a DAP exited event — let the + // worker synthesize one (issue #258). + adapterExitCodeIsDebuggeeExitCode: true }; } }; diff --git a/packages/shared/src/interfaces/adapter-policy.ts b/packages/shared/src/interfaces/adapter-policy.ts index 61661453..2f5c10be 100644 --- a/packages/shared/src/interfaces/adapter-policy.ts +++ b/packages/shared/src/interfaces/adapter-policy.ts @@ -405,6 +405,16 @@ export type AdapterSpawnConfig = */ excludeStderrLinePattern?: RegExp; }; + /** + * The adapter process's exit code IS the debuggee's exit code (issue + * #258): rdbg -c runs the debuggee under the adapter process, so its + * exit status propagates. Lets the worker synthesize a DAP 'exited' + * event for adapters that never send one, giving the session a + * recorded debuggee exit code like Python/js. Deliberately separate + * from forwardStdio: CodeLLDB forwards stdio too, but its exit code + * is its own, not the debuggee's. + */ + adapterExitCodeIsDebuggeeExitCode?: boolean; } | { mode: 'connect'; diff --git a/src/dap-core/handlers.ts b/src/dap-core/handlers.ts index 2212d67e..c78f17f3 100644 --- a/src/dap-core/handlers.ts +++ b/src/dap-core/handlers.ts @@ -122,10 +122,13 @@ function handleStatusMessage( case 'terminated': commands.push( { type: 'log', level: 'info', message: `[ProxyManager] Status: ${message.status}` }, - { - type: 'emitEvent', - event: 'exit', - args: [message.code || 1, message.signal || undefined] + { + type: 'emitEvent', + event: 'exit', + // Pass the code through untouched (issue #258): only adapter_exited + // carries one, and fabricating 1 for the codeless closure statuses + // turned every clean rdbg run into a session error. + args: [message.code ?? null, message.signal || undefined, message.expected] } ); break; diff --git a/src/dap-core/types.ts b/src/dap-core/types.ts index 34f95bcc..f8b3c499 100644 --- a/src/dap-core/types.ts +++ b/src/dap-core/types.ts @@ -53,7 +53,7 @@ export type ProxyStatusMessage = | { type: 'status'; sessionId: string; status: 'adapter_connected'; data?: unknown } | { type: 'status'; sessionId: string; status: 'adapter_configured_and_launched'; data?: unknown } | { type: 'status'; sessionId: string; status: 'adapter_capabilities'; capabilities: DebugProtocol.Capabilities; data?: unknown } - | { type: 'status'; sessionId: string; status: 'adapter_exited' | 'dap_connection_closed' | 'terminated'; code?: number | null; signal?: NodeJS.Signals | null; data?: unknown }; + | { type: 'status'; sessionId: string; status: 'adapter_exited' | 'dap_connection_closed' | 'terminated'; code?: number | null; signal?: NodeJS.Signals | null; expected?: boolean; data?: unknown }; export type ProxyDapEventMessage = { type: 'dapEvent'; diff --git a/src/proxy/dap-proxy-interfaces.ts b/src/proxy/dap-proxy-interfaces.ts index 1b2e890b..aec8bf62 100644 --- a/src/proxy/dap-proxy-interfaces.ts +++ b/src/proxy/dap-proxy-interfaces.ts @@ -74,6 +74,13 @@ export interface StatusMessage extends ProxyMessage { script?: string; /** Adapter initialize response body, on 'adapter_capabilities' (issue #243) */ capabilities?: DebugProtocol.Capabilities; + /** + * On terminal statuses (issue #258): true when the worker had already seen + * orderly debuggee termination (a terminated/exited DAP event was forwarded + * or shutdown was underway), so the parent can distinguish a normal + * teardown from an adapter dying or dropping the socket mid-run. + */ + expected?: boolean; } export interface DapResponseMessage extends ProxyMessage { diff --git a/src/proxy/dap-proxy-worker.ts b/src/proxy/dap-proxy-worker.ts index 49d72b02..b02e5d18 100644 --- a/src/proxy/dap-proxy-worker.ts +++ b/src/proxy/dap-proxy-worker.ts @@ -72,6 +72,21 @@ export class DapProxyWorker { private exitSynthesisAttempted: boolean = false; /** Armed when adapter stdio forwarding is active (issue #222): resolves once both stdio streams close. */ private adapterStdioDrained: Promise | null = null; + // Terminal signals (exited/terminated DAP events, socket close, adapter + // process exit) all await the stdio drain barrier; without a queue their + // continuations resume in an order set by await counts, not arrival — a + // codeless dap_connection_closed outrunning terminated makes the parent + // end the session as an error (issue #258). + private terminalSignalQueue: Promise = Promise.resolve(); + /** True once an exited/terminated DAP event was forwarded to the parent (issue #258). */ + private terminalDapEventForwarded: boolean = false; + // Adapter-exit exitCode synthesis (issue #258): armed by policies whose + // adapter process exit status IS the debuggee's (rdbg -c). The exit is + // recorded synchronously so the terminated slot can synthesize a DAP + // exited event even though rdbg never sends one. + private adapterExitCodeIsDebuggeeExitCode: boolean = false; + private adapterExitCode: number | null | undefined = undefined; + private adapterExitSynthesisAttempted: boolean = false; private initializedEventPending: boolean = false; private deferInitializedHandling: boolean = false; private initializedEventHandled: boolean = false; @@ -386,6 +401,7 @@ export class DapProxyWorker { if (spawnConfig.forwardStdio) { this.adapterStdioDrained = this.createStdioDrainBarrier(spawnResult.process); } + this.adapterExitCodeIsDebuggeeExitCode = spawnConfig.adapterExitCodeIsDebuggeeExitCode === true; this.logger!.info(`[Worker] Adapter spawned with PID: ${spawnResult.pid}`); this.adapterProcess.on('error', (err) => { @@ -395,7 +411,13 @@ export class DapProxyWorker { this.adapterProcess.on('exit', (code, signal) => { this.logger!.info(`[Worker] Adapter process exited. Code: ${code}, Signal: ${signal}`); - this.sendStatus('adapter_exited', { code, signal }); + // Recorded synchronously: the terminated slot's synthesis wait may + // resolve on this very event (issue #258) + this.adapterExitCode = code; + this.enqueueTerminalSignal('adapter_exited', async () => { + await this.waitForAdapterStdioDrain(); + this.sendStatus('adapter_exited', { code, signal, expected: this.isTerminationExpected() }); + }); }); } else { // connect mode: an external DAP server is already listening (e.g. remote @@ -650,42 +672,51 @@ export class DapProxyWorker { this.logger!.debug('[Worker] DAP event: thread', body); this.sendDapEvent('thread', body); }, - onExited: async (body) => { + onExited: (body) => { this.logger!.info(`[Worker] DAP event: exited exitCode=${body.exitCode}`); // A real exited event stays authoritative - suppress synthesis (issue #247). // Set synchronously, before any await, so a racing terminated sees it. this.exitedEventSeen = true; - await this.waitForAdapterStdioDrain(); - this.sendDapEvent('exited', body); + return this.enqueueTerminalSignal('exited', async () => { + await this.waitForAdapterStdioDrain(); + this.terminalDapEventForwarded = true; + this.sendDapEvent('exited', body); + }); }, - onTerminated: async (body) => { + onTerminated: (body) => { this.logger!.info(`[Worker] DAP event: terminated body=${JSON.stringify(body)}`); // When adapter stdio is forwarded as debuggee output, the exit-time // flush of a block-buffered pipe arrives milliseconds AFTER the DAP // terminated event — but the SessionManager reacts to terminated by // stopping the proxy, which drops late messages. Hold terminated // until the streams have drained so the output wins the race. - await this.waitForAdapterStdioDrain(); - // 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(); + return this.enqueueTerminalSignal('terminated', async () => { + await this.waitForAdapterStdioDrain(); + // 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(); + await this.maybeSynthesizeExitedFromAdapterExit(); + this.terminalDapEventForwarded = true; + this.sendDapEvent('terminated', body); + this.shutdown(); + }); }, onError: (err) => { this.logger!.error('[Worker] DAP client error:', err); this.sendError(`DAP client error: ${err.message}`); }, - onClose: async () => { + onClose: () => { this.logger!.info('[Worker] DAP client connection closed.'); // Adapters that close the DAP socket at debuggee exit (rdbg) race the // exit-time stdio flush exactly like terminated does — the parent // reacts to dap_connection_closed by tearing down its listeners, so // hold this path behind the same drain barrier (issue #222). - await this.waitForAdapterStdioDrain(); - this.sendStatus('dap_connection_closed'); - this.shutdown(); + return this.enqueueTerminalSignal('dap_connection_closed', async () => { + await this.waitForAdapterStdioDrain(); + this.sendStatus('dap_connection_closed', { expected: this.isTerminationExpected() }); + this.shutdown(); + }); } }); } @@ -1042,7 +1073,7 @@ export class DapProxyWorker { } await this.shutdown(); - this.sendStatus('terminated'); + this.sendStatus('terminated', { expected: true }); } /** @@ -1191,6 +1222,60 @@ export class DapProxyWorker { } } + /** + * Synthesize a DAP 'exited' event from the adapter process's exit status + * (issue #258). Only for policies that declare + * adapterExitCodeIsDebuggeeExitCode — rdbg -c runs the debuggee under the + * adapter process, so its exit status propagates, and rdbg never sends an + * exited event of its own. The process usually dies moments after the + * terminated event, so wait briefly for its exit if it hasn't landed yet. + * On signal kill there is no code and the exitCode simply stays unknown, + * matching the js synthesis path. + */ + private async maybeSynthesizeExitedFromAdapterExit(): Promise { + if ( + this.exitedEventSeen || + this.adapterExitSynthesisAttempted || + !this.adapterExitCodeIsDebuggeeExitCode || + !this.adapterProcess + ) { + return; + } + this.adapterExitSynthesisAttempted = true; + + if (this.adapterExitCode === undefined) { + const proc = this.adapterProcess; + if (typeof proc.exitCode === 'number') { + this.adapterExitCode = proc.exitCode; + } else if (proc.signalCode) { + this.adapterExitCode = null; + } else { + await new Promise((resolve) => { + const onExit = () => { + clearTimeout(timer); + resolve(); + }; + const timer = setTimeout(() => { + proc.removeListener('exit', onExit); + resolve(); + }, 500); + // The recording 'exit' listener was registered first, so + // adapterExitCode is already set when this one resolves + proc.once('exit', onExit); + }); + } + } + + const exitCode = this.adapterExitCode; + if (typeof exitCode === 'number') { + this.exitedEventSeen = true; + this.logger?.info?.(`[Worker] Synthesizing 'exited' from adapter process exit code ${exitCode} (issue #258)`); + this.sendDapEvent('exited', { exitCode }); + } else { + this.logger?.info?.('[Worker] Adapter exit code unavailable (signal kill or still running); exitCode stays unknown'); + } + } + /** * Resolves when both adapter stdio streams have closed — i.e. every byte * the debuggee flushed on exit has been read and forwarded (issue #222). @@ -1233,6 +1318,35 @@ export class DapProxyWorker { } } + /** + * Chain a terminal signal onto the FIFO forwarding queue (issue #258). + * Every producer awaits the drain barrier, so without serialization the + * signal with the fewest awaits after the barrier wins — not the one that + * arrived first. Each slot is bounded (drain backstop 2s), and the chain + * swallows rejections so one failed slot cannot block the next. + */ + private enqueueTerminalSignal(label: string, task: () => Promise): Promise { + const tail = this.terminalSignalQueue.then(task).catch((err) => { + this.logger?.error(`[Worker] Terminal signal '${label}' failed:`, err); + }); + this.terminalSignalQueue = tail; + return tail; + } + + /** + * Whether a terminal status sent now reflects orderly debuggee termination + * (issue #258): a terminated/exited DAP event was already forwarded, or the + * worker itself initiated shutdown. False means the adapter died or dropped + * the socket mid-run — the parent maps that to a session error. + */ + private isTerminationExpected(): boolean { + return ( + this.terminalDapEventForwarded || + this.state === ProxyState.SHUTTING_DOWN || + this.state === ProxyState.TERMINATED + ); + } + /** * Build the raw-stdio → DAP 'output' forwarder for adapters whose debuggee * inherits the adapter process's stdio (issue #222: rdbg -c on all diff --git a/src/proxy/proxy-manager.ts b/src/proxy/proxy-manager.ts index 46b72553..1c81211b 100644 --- a/src/proxy/proxy-manager.ts +++ b/src/proxy/proxy-manager.ts @@ -51,7 +51,13 @@ export interface ProxyManagerEvents { 'initialized': () => void; 'init-received': () => void; 'error': (error: Error) => void; - 'exit': (code: number | null, signal?: string) => void; + /** + * Proxy or adapter teardown. `expected` is set on status-driven exits + * (issue #258): true = the worker saw orderly debuggee termination first; + * false = the adapter died or dropped the socket mid-run; undefined = the + * proxy process itself exited (legacy path). + */ + 'exit': (code: number | null, signal?: string, expected?: boolean) => void; // Status events 'dry-run-complete': (command: string, script: string) => void; @@ -888,6 +894,9 @@ export class ProxyManager extends EventEmitter implements IProxyManager { // Skip emitEvent commands for DAP events — they are already handled // by the fast-path handleDapEvent() call above to avoid double emission. if (message.type === 'dapEvent') break; + // Terminal statuses are emitted (and latched) by + // handleStatusMessage above — suppress the duplicate (issue #258). + if (command.event === 'exit' && this.exitEmitted) break; const args = (command.args as unknown[]) ?? []; this.emit(command.event as keyof ProxyManagerEvents, ...(args as never[])); } @@ -1078,7 +1087,9 @@ export class ProxyManager extends EventEmitter implements IProxyManager { this.logger.info(`[ProxyManager] Status: ${message.status}`); if (!this.exitEmitted) { this.exitEmitted = true; - this.emit('exit', message.code ?? 1, message.signal || undefined); + // No fabricated code (issue #258): the closure statuses are + // codeless, and inventing 1 made every clean rdbg run an error. + this.emit('exit', message.code ?? null, message.signal || undefined, message.expected); } break; } diff --git a/src/session/session-manager-core.ts b/src/session/session-manager-core.ts index faac5ece..77ad2ebb 100644 --- a/src/session/session-manager-core.ts +++ b/src/session/session-manager-core.ts @@ -537,18 +537,37 @@ export abstract class SessionManagerCore extends EventEmitter { handlers.set('error', handleError); // Named function for exit event - const handleExit = (code: number | null, signal?: string) => { - this.logger.debug(`[SessionManager] handleExit: session=${sessionId} currentState=${session.state} code=${code} signal=${signal}`); - this.logger.info(`[ProxyManager ${sessionId}] Exit: code=${code}, signal=${signal}`); + const handleExit = (code: number | null, signal?: string, expected?: boolean) => { + this.logger.debug(`[SessionManager] handleExit: session=${sessionId} currentState=${session.state} code=${code} signal=${signal} expected=${expected}`); + this.logger.info(`[ProxyManager ${sessionId}] Exit: code=${code}, signal=${signal}, expected=${expected}`); if (session.state !== SessionState.STOPPED && session.state !== SessionState.ERROR) { - // Clean exit (code 0 or null with no signal) is normal termination, not an error - if (code === 0 || (code === null && !signal)) { + if (expected === true) { + // Orderly debuggee termination (issue #258): the worker saw a + // terminated/exited DAP event or was already shutting down. A + // non-zero code here is the debuggee's own exit status (rdbg -c + // propagates it) — a normal debugging outcome, not an error. + if (typeof code === 'number' && session.exitCode === undefined) { + session.exitCode = code; + } this._updateSessionState(session, SessionState.STOPPED); + } else if (expected === false) { + // The adapter died or dropped the socket with no preceding + // terminal DAP event. Only a clean code 0 counts as normal. + this._updateSessionState( + session, + code === 0 ? SessionState.STOPPED : SessionState.ERROR + ); } else { - this._updateSessionState(session, SessionState.ERROR); + // Legacy path (real proxy-process exit): clean exit is code 0 or + // null with no signal; anything else is an infrastructure error. + if (code === 0 || (code === null && !signal)) { + this._updateSessionState(session, SessionState.STOPPED); + } else { + this._updateSessionState(session, SessionState.ERROR); + } } } - + // Clean up listeners since proxy is gone this.cleanupProxyEventHandlers(session, proxyManager); session.proxyManager = undefined; diff --git a/tests/adapters/ruby/unit/adapter-policy-ruby.test.ts b/tests/adapters/ruby/unit/adapter-policy-ruby.test.ts index c4828ce9..1ee8642e 100644 --- a/tests/adapters/ruby/unit/adapter-policy-ruby.test.ts +++ b/tests/adapters/ruby/unit/adapter-policy-ruby.test.ts @@ -94,7 +94,8 @@ describe('RubyAdapterPolicy.getAdapterSpawnConfig', () => { port: 4711, logDir: '/tmp/logs', env: { FOO: 'bar' }, - forwardStdio: { excludeStderrLinePattern: /^DEBUGGER: / } + forwardStdio: { excludeStderrLinePattern: /^DEBUGGER: / }, + adapterExitCodeIsDebuggeeExitCode: true }); }); diff --git a/tests/core/unit/session/session-manager-exit-mapping.test.ts b/tests/core/unit/session/session-manager-exit-mapping.test.ts new file mode 100644 index 00000000..01af3320 --- /dev/null +++ b/tests/core/unit/session/session-manager-exit-mapping.test.ts @@ -0,0 +1,124 @@ +/** + * SessionManager proxy-exit → session-state mapping (issue #258) + * + * The proxy's terminal statuses reach SessionManager as an 'exit' event + * carrying (code, signal, expected). `expected` distinguishes orderly + * debuggee termination (terminated/exited DAP event seen, or shutdown + * underway) from an adapter dying or dropping the socket mid-run. A clean + * or crashing debuggee must land the session in STOPPED — ERROR is + * reserved for genuine infrastructure failures. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { SessionManager, SessionManagerConfig } from '../../../../src/session/session-manager.js'; +import { DebugLanguage, SessionState } from '@debugmcp/shared'; +import { createMockDependencies } from './session-manager-test-utils.js'; + +describe('SessionManager - proxy exit mapping (issue #258)', () => { + let sessionManager: SessionManager; + let dependencies: ReturnType; + let config: SessionManagerConfig; + + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + dependencies = createMockDependencies(); + config = { + logDirBase: '/tmp/test-sessions', + defaultDapLaunchArgs: { + stopOnEntry: true, + justMyCode: true + } + }; + + sessionManager = new SessionManager(config, dependencies); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + dependencies.mockProxyManager.reset(); + }); + + /** Create a session and drive it into RUNNING (stopOnEntry: false). */ + async function startRunningSession(): Promise { + const session = await sessionManager.createSession({ + language: DebugLanguage.MOCK, + executablePath: 'python' + }); + await sessionManager.startDebugging(session.id, 'test.py', [], { stopOnEntry: false }); + await vi.runAllTimersAsync(); + expect(sessionManager.getSession(session.id)?.state).toBe(SessionState.RUNNING); + return session.id; + } + + it('maps an expected codeless exit to STOPPED (rdbg closes the socket after terminating)', async () => { + const sessionId = await startRunningSession(); + + dependencies.mockProxyManager.simulateEvent('exit', null, undefined, true); + + expect(sessionManager.getSession(sessionId)?.state).toBe(SessionState.STOPPED); + }); + + it('maps an expected non-zero exit to STOPPED and records the debuggee exit code', async () => { + const sessionId = await startRunningSession(); + + // rdbg -c propagates the debuggee's exit status: an unhandled raise is 1 + dependencies.mockProxyManager.simulateEvent('exit', 1, undefined, true); + + const session = sessionManager.getSession(sessionId); + expect(session?.state).toBe(SessionState.STOPPED); + expect(session?.exitCode).toBe(1); + }); + + it('maps an unexpected clean exit (code 0) to STOPPED', async () => { + const sessionId = await startRunningSession(); + + dependencies.mockProxyManager.simulateEvent('exit', 0, undefined, false); + + expect(sessionManager.getSession(sessionId)?.state).toBe(SessionState.STOPPED); + }); + + it('maps an unexpected codeless exit to ERROR (socket dropped mid-run)', async () => { + const sessionId = await startRunningSession(); + + dependencies.mockProxyManager.simulateEvent('exit', null, undefined, false); + + expect(sessionManager.getSession(sessionId)?.state).toBe(SessionState.ERROR); + }); + + it('maps an unexpected non-zero exit to ERROR (adapter died mid-run)', async () => { + const sessionId = await startRunningSession(); + + dependencies.mockProxyManager.simulateEvent('exit', 134, undefined, false); + + expect(sessionManager.getSession(sessionId)?.state).toBe(SessionState.ERROR); + }); + + it('keeps the legacy mapping when expected is absent: clean proxy exit → STOPPED', async () => { + const sessionId = await startRunningSession(); + + dependencies.mockProxyManager.simulateEvent('exit', 0, undefined, undefined); + + expect(sessionManager.getSession(sessionId)?.state).toBe(SessionState.STOPPED); + }); + + it('keeps the legacy mapping when expected is absent: proxy crash → ERROR', async () => { + const sessionId = await startRunningSession(); + + dependencies.mockProxyManager.simulateEvent('exit', 1, 'SIGKILL', undefined); + + expect(sessionManager.getSession(sessionId)?.state).toBe(SessionState.ERROR); + }); + + it('ignores a late exit after terminated already stopped the session', async () => { + const sessionId = await startRunningSession(); + + dependencies.mockProxyManager.simulateEvent('terminated'); + expect(sessionManager.getSession(sessionId)?.state).toBe(SessionState.STOPPED); + + // The trailing status-driven exit (listeners already stripped) must not + // flip the state to ERROR + dependencies.mockProxyManager.simulateEvent('exit', 1, undefined, false); + + expect(sessionManager.getSession(sessionId)?.state).toBe(SessionState.STOPPED); + }); +}); diff --git a/tests/e2e/mcp-server-ruby-run-to-completion.test.ts b/tests/e2e/mcp-server-ruby-run-to-completion.test.ts new file mode 100644 index 00000000..60cf80bd --- /dev/null +++ b/tests/e2e/mcp-server-ruby-run-to-completion.test.ts @@ -0,0 +1,146 @@ +/** + * Ruby run-to-completion terminal state (issue #258) + * + * A Ruby launch session whose script runs to completion — cleanly or via an + * unhandled raise — must end in session state 'stopped' with the debuggee's + * exit code recorded, never 'error'. Before the fix, rdbg closing the DAP + * socket at debuggee exit raced the terminated event, and the proxy's + * codeless dap_connection_closed status was mapped to a fabricated exit + * code 1 → session state 'error' (even for a clean run). + * + * Skips gracefully when Ruby/rdbg is not installed, matching + * mcp-server-smoke-ruby.test.ts. + */ + +import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { parseSdkToolResult, callToolSafely } from './smoke-test-utils.js'; +import { findRubyExecutable, findRdbgExecutable } from '@debugmcp/adapter-ruby'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const ROOT = path.resolve(__dirname, '../..'); + +const CLEAN_SCRIPT = path.resolve(ROOT, 'tests', 'fixtures', 'debug-scripts', 'ruby-clean-exit.rb'); +const RAISE_SCRIPT = path.resolve(ROOT, 'tests', 'fixtures', 'debug-scripts', 'ruby-raise-unhandled.rb'); + +interface SessionSnapshot { + id: string; + state?: string; + exitCode?: number; +} + +async function rubyToolchainAvailable(): Promise { + try { + await findRubyExecutable(); + await findRdbgExecutable(); + return true; + } catch { + return false; + } +} + +async function getSessionSnapshot(client: Client, sessionId: string): Promise { + const res = parseSdkToolResult(await client.callTool({ name: 'list_debug_sessions', arguments: {} })); + const sessions = (res.sessions ?? []) as SessionSnapshot[]; + return sessions.find(s => s.id === sessionId); +} + +async function pollUntil( + fn: () => Promise, + timeoutMs: number, + intervalMs = 250 +): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + const value = await fn(); + if (value !== undefined) return value; + if (Date.now() > deadline) return undefined; + await new Promise(r => setTimeout(r, intervalMs)); + } +} + +describe('Ruby run-to-completion terminal state (issue #258) @requires-ruby', () => { + let mcpClient: Client | null = null; + let transport: StdioClientTransport | null = null; + let sessionId: string | null = null; + + beforeAll(async () => { + transport = new StdioClientTransport({ + command: process.execPath, + args: [path.join(ROOT, 'dist', 'index.js'), '--log-level', 'info'], + env: { ...process.env, NODE_ENV: 'test' } + }); + mcpClient = new Client( + { name: 'ruby-run-to-completion-e2e', version: '1.0.0' }, + { capabilities: {} } + ); + await mcpClient.connect(transport); + }, 30000); + + afterAll(async () => { + if (mcpClient) await mcpClient.close(); + if (transport) await transport.close(); + }); + + afterEach(async () => { + if (sessionId && mcpClient) { + await callToolSafely(mcpClient, 'close_debug_session', { sessionId }); + sessionId = null; + } + }); + + async function runToCompletion(scriptPath: string, name: string): Promise { + const createRes = parseSdkToolResult(await mcpClient!.callTool({ + name: 'create_debug_session', + arguments: { language: 'ruby', name } + })); + expect(createRes.sessionId).toBeDefined(); + sessionId = createRes.sessionId as string; + + // No breakpoints, default stopOnEntry (false): the entry pause is + // auto-continued and the script runs straight to completion + const startRes = parseSdkToolResult(await mcpClient!.callTool({ + name: 'start_debugging', + arguments: { sessionId, scriptPath } + })) as { state?: string }; + expect(startRes.state).not.toBe('error'); + + // Wait for a terminal state; 'error' is terminal too, so a regression + // fails fast instead of timing out + const terminal = await pollUntil(async () => { + const snap = await getSessionSnapshot(mcpClient!, sessionId!); + return snap && (snap.state === 'stopped' || snap.state === 'error') ? snap : undefined; + }, 30000); + + expect(terminal).toBeDefined(); + return terminal!; + } + + it('clean script ends stopped with exitCode 0, not error', async () => { + if (!(await rubyToolchainAvailable())) { + console.log('[Ruby #258 e2e] Ruby/rdbg not available, skipping'); + return; + } + + const terminal = await runToCompletion(CLEAN_SCRIPT, 'ruby-258-clean'); + + expect(terminal.state).toBe('stopped'); + expect(terminal.exitCode).toBe(0); + }, 60000); + + it('unhandled raise ends stopped with non-zero exitCode, not error', async () => { + if (!(await rubyToolchainAvailable())) { + console.log('[Ruby #258 e2e] Ruby/rdbg not available, skipping'); + return; + } + + const terminal = await runToCompletion(RAISE_SCRIPT, 'ruby-258-raise'); + + expect(terminal.state).toBe('stopped'); + expect(terminal.exitCode).toBe(1); + }, 60000); +}); diff --git a/tests/fixtures/debug-scripts/ruby-clean-exit.rb b/tests/fixtures/debug-scripts/ruby-clean-exit.rb new file mode 100644 index 00000000..d79bfd8f --- /dev/null +++ b/tests/fixtures/debug-scripts/ruby-clean-exit.rb @@ -0,0 +1,4 @@ +# Minimal clean-exit fixture (issue #258): prints and exits 0. The session +# must end in state 'stopped' with exitCode 0, not 'error'. +puts 'ruby clean start' +puts 'ruby clean done' diff --git a/tests/fixtures/debug-scripts/ruby-raise-unhandled.rb b/tests/fixtures/debug-scripts/ruby-raise-unhandled.rb new file mode 100644 index 00000000..18c2cc0a --- /dev/null +++ b/tests/fixtures/debug-scripts/ruby-raise-unhandled.rb @@ -0,0 +1,5 @@ +# Unhandled-raise fixture (issue #258): rdbg -c propagates the debuggee's +# non-zero exit status. The session must end 'stopped' with exitCode 1 — +# a debuggee crash is a normal debugging outcome, not a session error. +puts 'about to raise' +raise 'boom' diff --git a/tests/proxy/dap-proxy-worker.test.ts b/tests/proxy/dap-proxy-worker.test.ts index db473854..4b37abbd 100644 --- a/tests/proxy/dap-proxy-worker.test.ts +++ b/tests/proxy/dap-proxy-worker.test.ts @@ -972,10 +972,11 @@ describe('DapProxyWorker', () => { expect(sentMessages().some(m => m.type === 'dapEvent' && m.event === 'terminated')).toBe(false); expect(sentMessages().some(m => m.type === 'status' && m.status === 'dap_connection_closed')).toBe(false); - // The exit-time flush arrives, then the streams close + // The exit-time flush arrives, then the streams close and rdbg dies spawnConfig.onStdioLine('stdout', '15: FizzBuzz'); adapterProcess.stdout.emit('close'); adapterProcess.stderr.emit('close'); + adapterProcess.emit('exit', 0, null); await flush(); const messages = sentMessages(); @@ -988,6 +989,426 @@ describe('DapProxyWorker', () => { expect(outputIdx).toBeLessThan(terminatedIdx); }); + it('forwards terminated before dap_connection_closed in arrival order (issue #258)', async () => { + // rdbg sends terminated and then closes the socket. Both handlers await + // the same stdio drain barrier; whichever continuation runs first wins. + // The parent maps dap_connection_closed to a session exit, stripping + // the terminated handler — so terminated must always be forwarded + // first, matching arrival order. + const payload: ProxyInitPayload = { + cmd: 'init', + sessionId: 'ruby-order-session', + language: 'ruby', + executablePath: 'ruby', + adapterHost: '127.0.0.1', + adapterPort: 8126, + logDir: '/logs', + scriptPath: '/path/to/fizzbuzz.rb', + launchConfig: { request: 'launch', type: 'rdbg' }, + adapterCommand: { command: 'rdbg', args: ['--open', '-c', '--', 'ruby', '/path/to/fizzbuzz.rb'] } + }; + + const adapterProcess = new EventEmitter() as any; + adapterProcess.pid = 4245; + adapterProcess.stdout = new EventEmitter(); + adapterProcess.stderr = new EventEmitter(); + adapterProcess.kill = vi.fn(); + adapterProcess.unref = vi.fn(); + + const processStub = { + spawn: vi.fn().mockResolvedValue({ process: adapterProcess as ChildProcess, pid: 4245 }), + shutdown: vi.fn().mockResolvedValue(undefined) + }; + + const connectionStub = { + connectWithRetry: vi.fn().mockResolvedValue(mockDapClient), + setAdapterPolicy: vi.fn(), + setupEventHandlers: vi.fn((client: EventEmitter, handlers: Record void>) => { + if (handlers.onInitialized) client.on('initialized', handlers.onInitialized); + if (handlers.onTerminated) client.on('terminated', handlers.onTerminated); + if (handlers.onClose) client.on('close', handlers.onClose); + }), + initializeSession: vi.fn().mockImplementation(async () => { + setImmediate(() => (mockDapClient as EventEmitter).emit('initialized')); + }), + sendLaunchRequest: vi.fn().mockResolvedValue(undefined), + setBreakpoints: vi.fn().mockResolvedValue(undefined), + sendConfigurationDone: vi.fn().mockResolvedValue(undefined), + disconnect: vi.fn().mockResolvedValue(undefined) + }; + + (worker as any).logger = mockLogger; + (worker as any).processManager = processStub; + (worker as any).connectionManager = connectionStub; + (worker as any).adapterPolicy = RubyAdapterPolicy; + (worker as any).adapterState = RubyAdapterPolicy.createInitialState(); + (worker as any).currentInitPayload = payload; + (worker as any).currentSessionId = payload.sessionId; + (worker as any).state = ProxyState.INITIALIZING; + + await (worker as any).startAdapterAndConnect(payload); + + const sentMessages = () => mockMessageSender.send.mock.calls.map(([m]) => m); + const flush = () => new Promise(resolve => setImmediate(resolve)); + + // Terminated arrives first, then the socket closes, then stdio drains + // and rdbg's process dies + (mockDapClient as EventEmitter).emit('terminated', {}); + (mockDapClient as EventEmitter).emit('close'); + await flush(); + adapterProcess.stdout.emit('close'); + adapterProcess.stderr.emit('close'); + adapterProcess.emit('exit', 0, null); + await flush(); + await flush(); + + const messages = sentMessages(); + const terminatedIdx = messages.findIndex(m => m.type === 'dapEvent' && m.event === 'terminated'); + const closedIdx = messages.findIndex(m => m.type === 'status' && m.status === 'dap_connection_closed'); + expect(terminatedIdx).toBeGreaterThanOrEqual(0); + expect(closedIdx).toBeGreaterThanOrEqual(0); + // Arrival order must be preserved: terminated was first + expect(terminatedIdx).toBeLessThan(closedIdx); + // Terminated was forwarded first, so the closure is an expected teardown + expect(messages[closedIdx].expected).toBe(true); + }); + + it('marks dap_connection_closed unexpected when the socket drops mid-run (issue #258)', async () => { + // No terminated/exited event and no shutdown underway: the adapter + // dropped the DAP socket while the debuggee was still running. The + // parent must be able to tell this apart from a normal teardown. + const payload: ProxyInitPayload = { + cmd: 'init', + sessionId: 'ruby-abnormal-close-session', + language: 'ruby', + executablePath: 'ruby', + adapterHost: '127.0.0.1', + adapterPort: 8128, + logDir: '/logs', + scriptPath: '/path/to/fizzbuzz.rb', + launchConfig: { request: 'launch', type: 'rdbg' }, + adapterCommand: { command: 'rdbg', args: ['--open', '-c', '--', 'ruby', '/path/to/fizzbuzz.rb'] } + }; + + const adapterProcess = new EventEmitter() as any; + adapterProcess.pid = 4247; + adapterProcess.stdout = new EventEmitter(); + adapterProcess.stderr = new EventEmitter(); + adapterProcess.kill = vi.fn(); + adapterProcess.unref = vi.fn(); + + const processStub = { + spawn: vi.fn().mockResolvedValue({ process: adapterProcess as ChildProcess, pid: 4247 }), + shutdown: vi.fn().mockResolvedValue(undefined) + }; + + const connectionStub = { + connectWithRetry: vi.fn().mockResolvedValue(mockDapClient), + setAdapterPolicy: vi.fn(), + setupEventHandlers: vi.fn((client: EventEmitter, handlers: Record void>) => { + if (handlers.onInitialized) client.on('initialized', handlers.onInitialized); + if (handlers.onTerminated) client.on('terminated', handlers.onTerminated); + if (handlers.onClose) client.on('close', handlers.onClose); + }), + initializeSession: vi.fn().mockImplementation(async () => { + setImmediate(() => (mockDapClient as EventEmitter).emit('initialized')); + }), + sendLaunchRequest: vi.fn().mockResolvedValue(undefined), + setBreakpoints: vi.fn().mockResolvedValue(undefined), + sendConfigurationDone: vi.fn().mockResolvedValue(undefined), + disconnect: vi.fn().mockResolvedValue(undefined) + }; + + (worker as any).logger = mockLogger; + (worker as any).processManager = processStub; + (worker as any).connectionManager = connectionStub; + (worker as any).adapterPolicy = RubyAdapterPolicy; + (worker as any).adapterState = RubyAdapterPolicy.createInitialState(); + (worker as any).currentInitPayload = payload; + (worker as any).currentSessionId = payload.sessionId; + (worker as any).state = ProxyState.INITIALIZING; + + await (worker as any).startAdapterAndConnect(payload); + + const sentMessages = () => mockMessageSender.send.mock.calls.map(([m]) => m); + const flush = () => new Promise(resolve => setImmediate(resolve)); + + (mockDapClient as EventEmitter).emit('close'); + adapterProcess.stdout.emit('close'); + adapterProcess.stderr.emit('close'); + await flush(); + await flush(); + + const closed = sentMessages().find(m => m.type === 'status' && m.status === 'dap_connection_closed'); + expect(closed).toBeDefined(); + expect(closed.expected).toBe(false); + }); + + describe('adapter-exit exitCode synthesis (issue #258)', () => { + // rdbg never sends a DAP exited event, but rdbg -c makes the adapter + // process's exit status the debuggee's. Policies that declare + // adapterExitCodeIsDebuggeeExitCode get a synthesized exited event so + // the session records the debuggee exit code like Python/js do. + const makeRubyHarness = async (sessionId: string, port: number) => { + const payload: ProxyInitPayload = { + cmd: 'init', + sessionId, + language: 'ruby', + executablePath: 'ruby', + adapterHost: '127.0.0.1', + adapterPort: port, + logDir: '/logs', + scriptPath: '/path/to/script.rb', + launchConfig: { request: 'launch', type: 'rdbg' }, + adapterCommand: { command: 'rdbg', args: ['--open', '-c', '--', 'ruby', '/path/to/script.rb'] } + }; + + const adapterProcess = new EventEmitter() as any; + adapterProcess.pid = 5000 + port; + adapterProcess.stdout = new EventEmitter(); + adapterProcess.stderr = new EventEmitter(); + adapterProcess.kill = vi.fn(); + adapterProcess.unref = vi.fn(); + adapterProcess.exitCode = null; + + const processStub = { + spawn: vi.fn().mockResolvedValue({ process: adapterProcess as ChildProcess, pid: adapterProcess.pid }), + shutdown: vi.fn().mockResolvedValue(undefined) + }; + + const connectionStub = { + connectWithRetry: vi.fn().mockResolvedValue(mockDapClient), + setAdapterPolicy: vi.fn(), + setupEventHandlers: vi.fn((client: EventEmitter, handlers: Record void>) => { + if (handlers.onInitialized) client.on('initialized', handlers.onInitialized); + if (handlers.onTerminated) client.on('terminated', handlers.onTerminated); + if (handlers.onClose) client.on('close', handlers.onClose); + }), + initializeSession: vi.fn().mockImplementation(async () => { + setImmediate(() => (mockDapClient as EventEmitter).emit('initialized')); + }), + sendLaunchRequest: vi.fn().mockResolvedValue(undefined), + setBreakpoints: vi.fn().mockResolvedValue(undefined), + sendConfigurationDone: vi.fn().mockResolvedValue(undefined), + disconnect: vi.fn().mockResolvedValue(undefined) + }; + + (worker as any).logger = mockLogger; + (worker as any).processManager = processStub; + (worker as any).connectionManager = connectionStub; + (worker as any).adapterPolicy = RubyAdapterPolicy; + (worker as any).adapterState = RubyAdapterPolicy.createInitialState(); + (worker as any).currentInitPayload = payload; + (worker as any).currentSessionId = payload.sessionId; + (worker as any).state = ProxyState.INITIALIZING; + + await (worker as any).startAdapterAndConnect(payload); + return { adapterProcess }; + }; + + const sentMessages = () => mockMessageSender.send.mock.calls.map(([m]: [any]) => m); + const flush = () => new Promise(resolve => setImmediate(resolve)); + + it('synthesizes exited from the adapter exit code before terminated (crash, code 1)', async () => { + const { adapterProcess } = await makeRubyHarness('ruby-synth-crash', 8130); + + (mockDapClient as EventEmitter).emit('terminated', {}); + adapterProcess.stdout.emit('close'); + adapterProcess.stderr.emit('close'); + await flush(); + // The adapter process dies moments after terminated (rdbg -c: exit + // status is the debuggee's) + adapterProcess.emit('exit', 1, null); + await flush(); + await flush(); + + const messages = sentMessages(); + const exitedIdx = messages.findIndex((m: any) => m.type === 'dapEvent' && m.event === 'exited'); + const terminatedIdx = messages.findIndex((m: any) => m.type === 'dapEvent' && m.event === 'terminated'); + expect(exitedIdx).toBeGreaterThanOrEqual(0); + expect(messages[exitedIdx].body).toEqual({ exitCode: 1 }); + expect(terminatedIdx).toBeGreaterThanOrEqual(0); + expect(exitedIdx).toBeLessThan(terminatedIdx); + }); + + it('synthesizes exited with code 0 for a clean run', async () => { + const { adapterProcess } = await makeRubyHarness('ruby-synth-clean', 8131); + + (mockDapClient as EventEmitter).emit('terminated', {}); + adapterProcess.stdout.emit('close'); + adapterProcess.stderr.emit('close'); + await flush(); + adapterProcess.emit('exit', 0, null); + await flush(); + await flush(); + + const messages = sentMessages(); + const exited = messages.find((m: any) => m.type === 'dapEvent' && m.event === 'exited'); + expect(exited).toBeDefined(); + expect(exited.body).toEqual({ exitCode: 0 }); + }); + + it('skips synthesis on signal kill (no exit code)', async () => { + const { adapterProcess } = await makeRubyHarness('ruby-synth-signal', 8132); + + (mockDapClient as EventEmitter).emit('terminated', {}); + adapterProcess.stdout.emit('close'); + adapterProcess.stderr.emit('close'); + await flush(); + adapterProcess.emit('exit', null, 'SIGKILL'); + await flush(); + await flush(); + + const messages = sentMessages(); + expect(messages.some((m: any) => m.type === 'dapEvent' && m.event === 'exited')).toBe(false); + expect(messages.some((m: any) => m.type === 'dapEvent' && m.event === 'terminated')).toBe(true); + }); + + it('skips synthesis for policies that do not opt in (python)', async () => { + const payload: ProxyInitPayload = { + cmd: 'init', + sessionId: 'py-no-synth-session', + executablePath: 'python', + adapterHost: 'localhost', + adapterPort: 8133, + logDir: '/logs', + scriptPath: '/path/to/script.py' + }; + + const adapterProcess = new EventEmitter() as any; + adapterProcess.pid = 6133; + adapterProcess.stdout = new EventEmitter(); + adapterProcess.stderr = new EventEmitter(); + adapterProcess.kill = vi.fn(); + adapterProcess.unref = vi.fn(); + adapterProcess.exitCode = null; + + const processStub = { + spawn: vi.fn().mockResolvedValue({ process: adapterProcess as ChildProcess, pid: 6133 }), + shutdown: vi.fn().mockResolvedValue(undefined) + }; + + const connectionStub = { + connectWithRetry: vi.fn().mockResolvedValue(mockDapClient), + setAdapterPolicy: vi.fn(), + setupEventHandlers: vi.fn((client: EventEmitter, handlers: Record void>) => { + if (handlers.onInitialized) client.on('initialized', handlers.onInitialized); + if (handlers.onTerminated) client.on('terminated', handlers.onTerminated); + if (handlers.onClose) client.on('close', handlers.onClose); + }), + initializeSession: vi.fn().mockImplementation(async () => { + setImmediate(() => (mockDapClient as EventEmitter).emit('initialized')); + }), + sendLaunchRequest: vi.fn().mockResolvedValue(undefined), + setBreakpoints: vi.fn().mockResolvedValue(undefined), + sendConfigurationDone: vi.fn().mockResolvedValue(undefined), + disconnect: vi.fn().mockResolvedValue(undefined) + }; + + (worker as any).logger = mockLogger; + (worker as any).processManager = processStub; + (worker as any).connectionManager = connectionStub; + (worker as any).adapterPolicy = PythonAdapterPolicy; + (worker as any).adapterState = PythonAdapterPolicy.createInitialState(); + (worker as any).currentInitPayload = payload; + (worker as any).currentSessionId = payload.sessionId; + (worker as any).state = ProxyState.INITIALIZING; + + await (worker as any).startAdapterAndConnect(payload); + + adapterProcess.emit('exit', 1, null); + (mockDapClient as EventEmitter).emit('terminated', {}); + await flush(); + await flush(); + + const messages = sentMessages(); + expect(messages.some((m: any) => m.type === 'dapEvent' && m.event === 'exited')).toBe(false); + expect(messages.some((m: any) => m.type === 'dapEvent' && m.event === 'terminated')).toBe(true); + }); + }); + + it('adapter_exited never overtakes a terminal DAP event (issue #258)', async () => { + // The adapter process's 'exit' fires while terminated is held behind + // the stdio drain barrier. If adapter_exited reaches the parent first, + // its exit code (the debuggee's, via rdbg -c) — or a fabricated one — + // ends the session before terminated can mark it stopped. + const payload: ProxyInitPayload = { + cmd: 'init', + sessionId: 'ruby-adapter-exit-order-session', + language: 'ruby', + executablePath: 'ruby', + adapterHost: '127.0.0.1', + adapterPort: 8127, + logDir: '/logs', + scriptPath: '/path/to/crash.rb', + launchConfig: { request: 'launch', type: 'rdbg' }, + adapterCommand: { command: 'rdbg', args: ['--open', '-c', '--', 'ruby', '/path/to/crash.rb'] } + }; + + const adapterProcess = new EventEmitter() as any; + adapterProcess.pid = 4246; + adapterProcess.stdout = new EventEmitter(); + adapterProcess.stderr = new EventEmitter(); + adapterProcess.kill = vi.fn(); + adapterProcess.unref = vi.fn(); + + const processStub = { + spawn: vi.fn().mockResolvedValue({ process: adapterProcess as ChildProcess, pid: 4246 }), + shutdown: vi.fn().mockResolvedValue(undefined) + }; + + const connectionStub = { + connectWithRetry: vi.fn().mockResolvedValue(mockDapClient), + setAdapterPolicy: vi.fn(), + setupEventHandlers: vi.fn((client: EventEmitter, handlers: Record void>) => { + if (handlers.onInitialized) client.on('initialized', handlers.onInitialized); + if (handlers.onTerminated) client.on('terminated', handlers.onTerminated); + if (handlers.onClose) client.on('close', handlers.onClose); + }), + initializeSession: vi.fn().mockImplementation(async () => { + setImmediate(() => (mockDapClient as EventEmitter).emit('initialized')); + }), + sendLaunchRequest: vi.fn().mockResolvedValue(undefined), + setBreakpoints: vi.fn().mockResolvedValue(undefined), + sendConfigurationDone: vi.fn().mockResolvedValue(undefined), + disconnect: vi.fn().mockResolvedValue(undefined) + }; + + (worker as any).logger = mockLogger; + (worker as any).processManager = processStub; + (worker as any).connectionManager = connectionStub; + (worker as any).adapterPolicy = RubyAdapterPolicy; + (worker as any).adapterState = RubyAdapterPolicy.createInitialState(); + (worker as any).currentInitPayload = payload; + (worker as any).currentSessionId = payload.sessionId; + (worker as any).state = ProxyState.INITIALIZING; + + await (worker as any).startAdapterAndConnect(payload); + + const sentMessages = () => mockMessageSender.send.mock.calls.map(([m]) => m); + const flush = () => new Promise(resolve => setImmediate(resolve)); + + // Terminated arrives, then the adapter process dies (code 1 = the + // debuggee's own exit status under rdbg -c), then stdio drains + (mockDapClient as EventEmitter).emit('terminated', {}); + adapterProcess.emit('exit', 1, null); + await flush(); + adapterProcess.stdout.emit('close'); + adapterProcess.stderr.emit('close'); + await flush(); + await flush(); + + const messages = sentMessages(); + const terminatedIdx = messages.findIndex(m => m.type === 'dapEvent' && m.event === 'terminated'); + const adapterExitedIdx = messages.findIndex(m => m.type === 'status' && m.status === 'adapter_exited'); + expect(terminatedIdx).toBeGreaterThanOrEqual(0); + expect(adapterExitedIdx).toBeGreaterThanOrEqual(0); + // The terminal DAP event arrived first and must be forwarded first + expect(terminatedIdx).toBeLessThan(adapterExitedIdx); + // The real exit code must survive the reordering + expect(messages[adapterExitedIdx].code).toBe(1); + }); + it('startAdapterAndConnect passes no stdio forwarder for policies that do not opt in', async () => { const payload: ProxyInitPayload = { cmd: 'init', @@ -1272,6 +1693,8 @@ describe('DapProxyWorker', () => { ); adapterEmitter.emit('exit', 0, null); + // adapter_exited now rides the terminal-signal queue (issue #258) + await new Promise(resolve => setImmediate(resolve)); expect(mockMessageSender.send).toHaveBeenCalledWith( expect.objectContaining({ type: 'status', diff --git a/tests/unit/dap-core/handlers.test.ts b/tests/unit/dap-core/handlers.test.ts index 92b14b04..34a75e6b 100644 --- a/tests/unit/dap-core/handlers.test.ts +++ b/tests/unit/dap-core/handlers.test.ts @@ -162,24 +162,58 @@ describe('DAP Core Handlers', () => { expect(result.commands[1]).toEqual({ type: 'emitEvent', event: 'exit', - args: [1, 'SIGTERM'] + args: [1, 'SIGTERM', undefined] }); }); }); - it('should use default code when missing', () => { + it('should pass null code through when missing instead of fabricating 1 (issue #258)', () => { const message: ProxyStatusMessage = { type: 'status', sessionId: 'test-session-123', - status: 'adapter_exited' + status: 'dap_connection_closed' }; - + const result = handleProxyMessage(state, message); - + + expect(result.commands[1]).toEqual({ + type: 'emitEvent', + event: 'exit', + args: [null, undefined, undefined] + }); + }); + + it('should preserve a real exit code of 0 (issue #258)', () => { + const message: ProxyStatusMessage = { + type: 'status', + sessionId: 'test-session-123', + status: 'adapter_exited', + code: 0 + }; + + const result = handleProxyMessage(state, message); + + expect(result.commands[1]).toEqual({ + type: 'emitEvent', + event: 'exit', + args: [0, undefined, undefined] + }); + }); + + it('should pass the expected flag through (issue #258)', () => { + const message: ProxyStatusMessage = { + type: 'status', + sessionId: 'test-session-123', + status: 'dap_connection_closed', + expected: true + }; + + const result = handleProxyMessage(state, message); + expect(result.commands[1]).toEqual({ type: 'emitEvent', event: 'exit', - args: [1, undefined] + args: [null, undefined, true] }); }); }); diff --git a/tests/unit/proxy/proxy-manager-message-handling.test.ts b/tests/unit/proxy/proxy-manager-message-handling.test.ts index 35ab8dc8..6f64e747 100644 --- a/tests/unit/proxy/proxy-manager-message-handling.test.ts +++ b/tests/unit/proxy/proxy-manager-message-handling.test.ts @@ -1106,7 +1106,87 @@ describe('ProxyManager Message Handling', () => { signal: 'SIGTERM' }); - expect(exitSpy).toHaveBeenCalledWith(7, 'SIGTERM'); + expect(exitSpy).toHaveBeenCalledWith(7, 'SIGTERM', undefined); + }); + + it('does not fabricate exit code 1 for a codeless dap_connection_closed (issue #258)', () => { + const logger = createMockLogger(); + const fileSystem = createMockFileSystem(); + + const proxyManager = new ProxyManager( + null, + { launchProxy: vi.fn() } as never, + fileSystem as never, + logger + ); + + const exitSpy = vi.fn(); + proxyManager.on('exit', exitSpy); + + (proxyManager as unknown as { + handleStatusMessage: (status: any) => void; + }).handleStatusMessage({ + type: 'status', + sessionId: 'status-session', + status: 'dap_connection_closed' + }); + + expect(exitSpy).toHaveBeenCalledWith(null, undefined, undefined); + }); + + it('passes the expected flag through to exit listeners (issue #258)', () => { + const logger = createMockLogger(); + const fileSystem = createMockFileSystem(); + + const proxyManager = new ProxyManager( + null, + { launchProxy: vi.fn() } as never, + fileSystem as never, + logger + ); + + const exitSpy = vi.fn(); + proxyManager.on('exit', exitSpy); + + (proxyManager as unknown as { + handleStatusMessage: (status: any) => void; + }).handleStatusMessage({ + type: 'status', + sessionId: 'status-session', + status: 'dap_connection_closed', + expected: true + }); + + expect(exitSpy).toHaveBeenCalledWith(null, undefined, true); + }); + + it('emits exit exactly once when a terminal status flows through the full message path (issue #258)', () => { + const logger = createMockLogger(); + const fileSystem = createMockFileSystem(); + + const proxyManager = new ProxyManager( + null, + { launchProxy: vi.fn() } as never, + fileSystem as never, + logger + ); + // Arm the functional core so both the imperative handler and the + // dap-core executor see the message — the duplicate-emit path. + (proxyManager as unknown as { dapState: unknown }).dapState = + createInitialState('status-session'); + + const exitSpy = vi.fn(); + proxyManager.on('exit', exitSpy); + + (proxyManager as unknown as { + handleProxyMessage: (message: any) => void; + }).handleProxyMessage({ + type: 'status', + sessionId: 'status-session', + status: 'dap_connection_closed' + }); + + expect(exitSpy).toHaveBeenCalledTimes(1); }); it('rejects pending requests when proxy exits', () => { diff --git a/tests/unit/proxy/proxy-manager.start.test.ts b/tests/unit/proxy/proxy-manager.start.test.ts index 63485868..10ba0b80 100644 --- a/tests/unit/proxy/proxy-manager.start.test.ts +++ b/tests/unit/proxy/proxy-manager.start.test.ts @@ -1151,7 +1151,7 @@ describe('ProxyManager.start', () => { signal: 'SIGTERM' }); - expect(exit).toHaveBeenCalledWith(9, 'SIGTERM'); + expect(exit).toHaveBeenCalledWith(9, 'SIGTERM', undefined); }); it('resolves DAP responses and captures thread ids', async () => {