Skip to content

fix: Ruby launch sessions end 'stopped' with real exit codes, not 'error' (fixes #258) - #263

Merged
debugmcpdev merged 1 commit into
mainfrom
fix/258-ruby-terminal-state
Aug 5, 2026
Merged

fix: Ruby launch sessions end 'stopped' with real exit codes, not 'error' (fixes #258)#263
debugmcpdev merged 1 commit into
mainfrom
fix/258-ruby-terminal-state

Conversation

@debugmcpdev

Copy link
Copy Markdown
Collaborator

Fixes #258: a Ruby launch session whose script ran to completion — cleanly or via an unhandled raise — ended in session state error instead of stopped, so callers polling for state === 'stopped' never saw it.

Root cause

The Exit: code=1 in the logs was not a real OS exit code (the proxy process was still heartbeating). Two defects, both exposed by the #254 stdio drain barrier:

  1. Ordering regression: onExited/onTerminated/onClose all await the same drain barrier, and onTerminated's extra await (maybeSynthesizeExitedEvent, feat: surface js debuggee exit code (exitCode) via preload shim synthesis #252) let onClose win by a microtask. The codeless dap_connection_closed status reached the parent before the terminated DAP event; SessionManager reacted by stripping all listeners, so the 1ms-late terminated (which would have set STOPPED) was never handled. Pre-feat: forward adapter-process stdio as debuggee output events — Ruby all platforms, Rust on Windows (#222, #223) #254 onClose was synchronous and always ran after terminated had been forwarded. rdbg closes the DAP socket the instant the debuggee exits, so Ruby hit this on every run.
  2. Fabricated exit code: proxy-manager.ts emitted exit with message.code ?? 1 (and dap-core/handlers.ts with message.code || 1, which also mangles a real code 0). Every codeless closure became "exit code 1" → SessionState.ERROR, even for a clean run. The functional core also emitted a duplicate, unlatched exit event.

Fix

  • FIFO terminal-signal queue in the proxy worker: exited/terminated DAP events, socket close, and the adapter-process exit status are forwarded in arrival order regardless of how many awaits each handler performs.
  • No fabricated codes: terminal statuses pass their code through untouched (?? null) and carry an explicit expected flag (terminal DAP event already forwarded, or shutdown underway).
  • SessionManager mapping: expected teardown → STOPPED (recording the debuggee exit code); unexpected closure (socket drop / adapter death mid-run with no terminal event) → ERROR; real proxy-process exits keep the legacy rule. The duplicate exit emit is suppressed.
  • Ruby exit codes: new adapterExitCodeIsDebuggeeExitCode spawn-config flag (Ruby launch only — rdbg -c propagates the debuggee's exit status, and rdbg never sends a DAP exited event). The worker synthesizes exited before forwarding terminated, so session.exitCode records 0 for a clean run and 1 for an unhandled raise, matching Python/js. Deliberately not implied by forwardStdio: CodeLLDB forwards stdio but its exit code is its own.

Tests (all watched red first)

  • Worker: terminal-signal order assertions (terminated before dap_connection_closed; adapter_exited never overtakes a terminal DAP event and keeps its real code), expected flag semantics, and adapter-exit exitCode synthesis (code 1 / 0 / signal-kill / non-opted-in policy).
  • ProxyManager/dap-core: codeless statuses no longer fabricate code 1, real code 0 preserved, expected passthrough, exactly-one exit emission through the full message path.
  • SessionManager: full expected-aware mapping table, legacy-path regression guards, late-exit-after-terminated guard.
  • E2E (mcp-server-ruby-run-to-completion.test.ts + 2 fixtures): clean script → stopped/exitCode 0; unhandled raise → stopped/exitCode 1. Verified failing against pre-fix build (state: 'error', the exact issue repro) and passing post-fix.

Verification

  • npm run lint clean; full unit project 2676/2676.
  • E2E regression sweep on Windows with real toolchains: ruby smoke, ruby-attach, break-on-exceptions (mock/python/js), python, javascript, go, rust, dotnet, java smokes — all green (every adapter traverses the new queue).
  • Live repro via dev-proxy MCP tools on Windows: clean fixture → stopped/exitCode 0, raise fixture → stopped/exitCode 1; start_debugging itself now returns the accurate terminal state for quick scripts.

Deferred

Ruby's isSessionReady (state === PAUSED) still resolves the start_debugging ready-wait via the exit listener for quick scripts, logging the now-cosmetic "proxy exited during startup" line. The returned state is accurate post-fix, so the policy change is deferred.

🤖 Generated with Claude Code

…h sessions end 'stopped', not 'error' (fixes #258)

Two defects, both exposed by the #254 stdio drain barrier:

1. Ordering: onExited/onTerminated/onClose all await the same drain
   barrier, and onTerminated's extra await (#252 synthesis) let onClose
   win by a microtask - the codeless dap_connection_closed status reached
   the parent before the terminated DAP event, stripping the handlers
   that would have marked the session stopped. A FIFO terminal-signal
   queue in the proxy worker now preserves arrival order, and the
   adapter process 'exit' status rides the same queue.

2. Fabricated exit code: ProxyManager emitted 'exit' with
   `message.code ?? 1` (and dap-core with `message.code || 1`, mangling
   a real 0), so every codeless closure became exit code 1 and
   SessionManager mapped it to ERROR - even for a clean run.

Terminal statuses now carry an explicit `expected` flag (terminal DAP
event already forwarded, or shutdown underway) and pass the code through
untouched. SessionManager maps expected teardowns to STOPPED (recording
the debuggee exit code), unexpected closures to ERROR, and keeps the
legacy rule for real proxy-process exits. The duplicate unlatched 'exit'
emit from the functional core is suppressed.

Ruby also gains adapterExitCodeIsDebuggeeExitCode: rdbg -c propagates
the debuggee's exit status but never sends a DAP exited event, so the
worker now synthesizes one - clean run records exitCode 0, unhandled
raise records exitCode 1, matching Python/js.

Deferred (cosmetic): Ruby's isSessionReady still resolves the
start_debugging ready-wait via the exit path for quick scripts, logging
'proxy exited during startup'; the reported state is now accurate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 5, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.11765% with 5 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/proxy/dap-proxy-worker.ts 92.75% 5 Missing ⚠️

📢 Thoughts on this report? Let us know!

@debugmcpdev
debugmcpdev merged commit 5bbb270 into main Aug 5, 2026
10 checks passed
@debugmcpdev
debugmcpdev deleted the fix/258-ruby-terminal-state branch August 5, 2026 02:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Ruby launch sessions on Windows end in state 'error' instead of 'stopped' — even for clean, successful scripts

2 participants