fix: Ruby launch sessions end 'stopped' with real exit codes, not 'error' (fixes #258) - #263
Merged
Merged
Conversation
…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 Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #258: a Ruby launch session whose script ran to completion — cleanly or via an unhandled raise — ended in session state
errorinstead ofstopped, so callers polling forstate === 'stopped'never saw it.Root cause
The
Exit: code=1in 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:onExited/onTerminated/onCloseall await the same drain barrier, andonTerminated's extra await (maybeSynthesizeExitedEvent, feat: surface js debuggee exit code (exitCode) via preload shim synthesis #252) letonClosewin by a microtask. The codelessdap_connection_closedstatus reached the parent before theterminatedDAP event; SessionManager reacted by stripping all listeners, so the 1ms-lateterminated(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) #254onClosewas synchronous and always ran afterterminatedhad been forwarded. rdbg closes the DAP socket the instant the debuggee exits, so Ruby hit this on every run.proxy-manager.tsemittedexitwithmessage.code ?? 1(anddap-core/handlers.tswithmessage.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, unlatchedexitevent.Fix
exited/terminatedDAP events, socket close, and the adapter-processexitstatus are forwarded in arrival order regardless of how many awaits each handler performs.?? null) and carry an explicitexpectedflag (terminal DAP event already forwarded, or shutdown underway).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 duplicateexitemit is suppressed.adapterExitCodeIsDebuggeeExitCodespawn-config flag (Ruby launch only — rdbg-cpropagates the debuggee's exit status, and rdbg never sends a DAPexitedevent). The worker synthesizesexitedbefore forwardingterminated, sosession.exitCoderecords 0 for a clean run and 1 for an unhandled raise, matching Python/js. Deliberately not implied byforwardStdio: CodeLLDB forwards stdio but its exit code is its own.Tests (all watched red first)
terminatedbeforedap_connection_closed;adapter_exitednever overtakes a terminal DAP event and keeps its real code),expectedflag semantics, and adapter-exit exitCode synthesis (code 1 / 0 / signal-kill / non-opted-in policy).expectedpassthrough, exactly-oneexitemission through the full message path.expected-aware mapping table, legacy-path regression guards, late-exit-after-terminated guard.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 lintclean; full unit project 2676/2676.stopped/exitCode 0, raise fixture →stopped/exitCode 1;start_debuggingitself now returns the accurate terminal state for quick scripts.Deferred
Ruby's
isSessionReady(state === PAUSED) still resolves thestart_debuggingready-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