Skip to content

fix(windows): report the end of a Rust/C++/COBOL program when it happens, not 2 s later (#856) - #859

Merged
debugmcpdev merged 3 commits into
mainfrom
fix/856-codelldb-exit-drain
Oct 5, 2026
Merged

debugmcpdev merged 3 commits into
mainfrom
fix/856-codelldb-exit-drain

Conversation

@debugmcpdev

@debugmcpdev debugmcpdev commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #856.

What was wrong

On Windows, every Rust, C/C++ and COBOL session reported the end of its program 2 s after the program ended. The proxy holds exited/terminated until the output the program printed on its way out has been forwarded (#222), and took "the adapter's stdio pipes closed" as the sign that it had. That is right for rdbg -c, which exits with its debuggee. CodeLLDB outlives its debuggee and keeps the pipes open until the session is torn down, so on Windows — where the debuggee writes straight into those pipes (#223) — the wait ran to its 2 s backstop at the end of every program.

It predates #857, but #857 made it visible: with a 1 s launch hold, a short Rust/C++/COBOL program on Windows answered running + pending: true although it had already ended.

The fix

A policy can now say its adapter outlives the debuggee (forwardStdio.adapterOutlivesDebuggee; buildLldbSpawnConfig sets it on win32, so Rust, C/C++ and COBOL get it). For such an adapter the worker also takes the pipes going quiet as "drained": when the adapter reports the exit the process is gone and everything it wrote is already in the pipe, so the proxy only has to read what is buffered.

  • Quiet means 100 ms without a chunk on stdout or stderr, counted from the later of the last chunk and the first terminal signal — so terminated queued behind exited does not wait a second window.
  • The clock alone is not believed: once the window has passed, the worker takes one full turn of the event loop (setImmediate runs after the poll phase that reads pending I/O) and starts the window over if that turn brought a chunk. A worker that was descheduled cannot mistake unread output for silence.
  • Output that is still arriving keeps the wait open; the 2 s backstop still bounds it, exactly as before.
  • rdbg is untouched: its pipes close with the program, and silence there is not the end (an at_exit hook can still print).

Sizing the window

Measured on CodeLLDB/Windows with the proxy logging every line at debug level, as the gap between consecutive chunks of output that arrived after exited:

program idle, 3 runs all 16 cores busy, 3 runs
hello world no output after exited no output after exited
9 MB streamed (200,000 lines), then exit longest gap 8–9 ms; last line 1.2 s after exited longest gap 25–39 ms; last line 1.6–2.1 s after
6 MB held in one buffer and flushed at exit longest gap 7–14 ms; last line 0.7 s after longest gap 20–29 ms; last line 1.0–1.2 s after

100 ms is 7× the longest idle gap and 2.5× the longest under load. (The same table shows why "wait a fixed short time after exited" would not do: output can keep arriving for a second or two. The window is on silence, not on elapsed time.)

Result

End reported after launch, hello-world example, three runs each (same method as the table in #857):

before after
Rust 2,009–2,020 ms 110–120 ms
C/C++ 2,009–2,024 ms 111–117 ms
COBOL 2,023–2,030 ms 124–134 ms
Ruby (drain by close, unchanged) 26–31 ms 27–29 ms

start_debugging on all three now answers stopped with the exit code in the one call. The 9 MB and 6 MB programs were re-run on the fixed build: the session's output ends with their last line (line 199999 …, line 119999 …) in every run.

Tests

  • tests/proxy/dap-proxy-worker.test.ts, new block: exited is forwarded once the pipes have been quiet, not at the backstop; late output keeps the wait open and is forwarded first; output that lands in the same instant the window closes restarts it (this one was mutation-checked: it fails when the event-loop turn stops re-checking); pipes that never go quiet end at the backstop; terminated does not wait a second window; a close still settles at once; an adapter without the flag still waits for the close.
  • Policy pins for the flag (win32 only) in the C/C++ and Rust policy tests.
  • tests/e2e/mcp-server-smoke-cpp.test.ts: a short program's launch answers stopped, exit code 0, with both of its output lines. Fails on Windows before the fix (running, "nothing is armed"), passes after.
  • Run locally on Windows on the final build: the Rust, C/C++, COBOL, Ruby, logpoint, restart, exception and comprehensive e2e suites (13 files, 187 tests), plus unit (6,663 tests), lint and typecheck:all.

🤖 Generated with Claude Code

…ens, not 2 s later

The worker holds exited/terminated until the adapter's forwarded stdio
has drained, and took "the pipes closed" as the sign. CodeLLDB outlives
its debuggee and keeps the pipes open until teardown, so on Windows
(where the debuggee writes straight into them) the wait ran to its 2 s
backstop at the end of every program.

- AdapterSpawnConfig.forwardStdio gains adapterOutlivesDebuggee;
  buildLldbSpawnConfig sets it on win32 (rust, cpp, cobol).
- For such an adapter the drain also settles when the pipes have been
  quiet for 100 ms, counted from the later of the last chunk and the first
  terminal signal, and re-checked after one full turn of the event loop so
  a descheduled worker cannot take unread output for silence. Output still
  arriving keeps the wait open; the 2 s backstop is unchanged.
- rdbg, whose pipes close with the program, still waits for the close.

Measured: end reported 110-134 ms after launch (was 2009-2030 ms); the
longest gap inside 9 MB of exit-time output was 14 ms idle, 39 ms with
every core busy.

Closes #856.

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

codecov Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

… endings

Review follow-up on #859. The comment on trackStdioActivity said a counted
chunk's lines have been forwarded; that holds for complete lines only (a
trailing fragment waits in the line buffer for the pipe to close, #860).
The drain's doc stated the output-before-exit order as a guarantee; it is
one when the wait ends on the close, a measured expectation when it ends
on silence, and neither at the backstop.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@debugmcpdev

Copy link
Copy Markdown
Collaborator Author

Code review

Five reviewers (CLAUDE.md compliance, bug scan, git history of the exit path, earlier PR feedback, code comments) went over cb1c9b1. No bugs found. The history reviewer traced the earlier exit-ordering fixes (#222, #247, #258, #366, #720, #746, #753, #763) against the shorter wait and found none of them relied on the 2 s gap for CodeLLDB.

One reviewer found two comments that claimed more than the code guarantees; both are corrected in 75a33ff (comments only):

  1. The comment on trackStdioActivity said a counted chunk's lines have been forwarded. That holds for complete lines only: a trailing fragment with no newline waits in the adapter manager's line buffer for the pipe to close.

/**
* Count and time every chunk the adapter's stdio delivers (issue #856).
* Registered after the adapter manager's own 'data' listeners, so by the
* time a chunk is counted the complete lines in it have been forwarded. A
* trailing fragment with no newline is another matter: it stays in the
* manager's line buffer until the pipe closes, which for an adapter that
* outlives its debuggee is teardown — too late, now as before (issue #860).
*/
private trackStdioActivity(adapterProcess: ChildProcess): { chunks: number; lastChunkAt: number } {

  1. The drain's doc stated the output-before-exit order as a guarantee. It is one when the wait ends on the pipes closing, a measured expectation when it ends on silence, and neither at the backstop.

/**
* Hold exited/terminated forwarding until adapter stdio has drained: a
* debuggee printing to a block-buffered pipe flushes everything at exit,
* milliseconds after the adapter's terminated event, and the SessionManager
* stops the proxy on terminated — dropping late messages. When the wait
* ends on the pipes closing, the order is guaranteed: stream 'data' fires
* before 'close' and IPC is FIFO, so the forwarded output reaches the
* session buffer first. When it ends on silence the order rests on a
* measurement instead (ADAPTER_STDIO_QUIET_MS), and at the backstop on
* nothing.
*
* Drained means the pipes closed — or, for an adapter that outlives its
* debuggee and so never closes them when the program ends, that they went
* quiet (issue #856: CodeLLDB on Windows used to sit out the backstop at
* the end of every program, reporting each exit 2 s late). The 2 s
* backstop still bounds both: a pipe that neither closes nor falls silent
* (something the program started is still writing to it) is not waited
* for longer than before. No-op when forwarding is off.
*/
private async waitForAdapterStdioDrain(): Promise<void> {
if (!this.adapterStdioDrained) {
return;

Following up on the first point: a program's last output is lost on Windows for the CodeLLDB languages when it does not end in a newline. Checked live — it is lost on main as well, so it is not this PR's doing, and it is filed as #860 rather than folded in here.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

…ad cancel flag goes

Output that lands in the same instant the quiet window closes must restart
the window: a test now fails if the turn after the timer stops checking for
it (verified by removing the check). The cancelled flag in
waitForAdapterStdioQuiet could never be read — at most one of the timer and
the immediate is pending, and cancel clears both — so it is removed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@debugmcpdev

Copy link
Copy Markdown
Collaborator Author

One more commit after the review above, 86d4697: the quiet window's event-loop turn now has a test of its own (output arriving in the same instant the window closes must restart it — verified by removing the re-check and watching the test fail), and the cancelled flag in waitForAdapterStdioQuiet, which could never be read, is gone. Unit (6,664 tests), lint, typecheck:all and the Rust/C++/COBOL/logpoint e2e suites pass locally on this head.

CI on this head has not run: GitHub Actions is in an outage and the hosted runners never picked the jobs up ("The job was not acquired by Runner of type hosted even after multiple attempts"). The first head, cb1c9b1, passed Build and Test on ubuntu and Windows, Lint and Container Tests.

@debugmcpdev
debugmcpdev merged commit d64bd02 into main Oct 5, 2026
14 of 25 checks passed
@debugmcpdev
debugmcpdev deleted the fix/856-codelldb-exit-drain branch October 5, 2026 23:18
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.

Windows: the end of a Rust/C++/COBOL program is reported 2 s late — the stdio drain waits for a pipe close CodeLLDB never produces

2 participants