Skip to content

fix(#502): make proxy-worker teardown deterministic — latch terminate onto in-flight shutdown, escalate on real exit evidence, verify the kill - #535

Merged
debugmcpdev merged 3 commits into
mainfrom
fix/502-http-reap-leaves-worker
Aug 27, 2026
Merged

fix(#502): make proxy-worker teardown deterministic — latch terminate onto in-flight shutdown, escalate on real exit evidence, verify the kill#535
debugmcpdev merged 3 commits into
mainfrom
fix/502-http-reap-leaves-worker

Conversation

@debugmcpdev

Copy link
Copy Markdown
Collaborator

Closes #502.

Root cause (sharper than the issue's audit)

The live mechanism is a race the worker loses against itself. On natural debuggee termination, onTerminated/onClose run shutdown() as a floating promise; state sits in SHUTTING_DOWN for ≥1s (two 500ms grace waits + adapter tree-kill). The parent reacts to the same DAP terminated event with stop() → IPC terminate, which lands inside that window (~7ms vs ~800ms): handleTerminate early-returned, and the runner's sole exit scheduler (post-handleCommand, state === TERMINATED exactly) scheduled nothing. The worker finished its own shutdown and sat alive forever in state TERMINATED — reaped only by the 5s SIGKILL, and not even that on the paths where the escalation was neutered (.killed latches on any delivered signal; cleanup() drops the handle before the escalation; the status-driven 'exit' handler never stopped at all; no layer retained the worker pid).

Reproduced deterministically on Linux, first try — worker log:

21:44:15.058  [Worker] DAP event: terminated              ← floating shutdown() begins
21:44:15.065  [Worker] Already shutting down or terminated.   ← parent's terminate, 7ms later
21:44:15.065  [Worker] Completed command terminate … state=shutting_down   ← no exit scheduled
21:44:15.866  [Worker] Shutdown sequence completed.       ← alive forever, absent SIGKILL
21:44:20.064  [ProxyManager] Timeout waiting for proxy exit. Force killing.

Fix

  • Worker (dap-proxy-worker.ts): shutdown() re-entrant via a latched promise; terminate during SHUTTING_DOWN awaits the in-flight shutdown, so handleCommand completes in TERMINATED and the exit is always scheduled. Init-failure rollback no longer clobbers a completed shutdown's terminal state.
  • ProxyManager / process adapter: stop()'s IPC-send, SIGKILL, and early-resolve guards key on exitCode/signalCode (actual death) instead of .killed; ProxyProcessAdapter.kill() likewise (win32 tree-kill preserved); worker pid retained across cleanup() and exposed as IProxyManager.getProxyPid(); isRunning() de-poisoned.
  • SessionManager: terminal handlers record the pid and retain their stop() promise (session.pendingProxyStop); closeSession/stopProxyPreservingSession await it even after the reference was cleared; the status-driven 'exit' (expected !== undefined — worker claims dead, OS process may not be) now stops the proxy; a post-close, unref'd 1.5s liveness probe logs leaked worker (issue #502) with the pid so any recurrence logs itself.
  • HTTP layer: MCP_HTTP_STALE_SWEEP_INTERVAL_MS makes the hard-coded 60s sweep testable; --log-file now reaches the CLI logger (attachSharedFileTransport) so the Reaping stale HTTP session line is visible in the operator's log (the sse path has the same defect — sse: --log-file never reaches the CLI logger, so sse-command lines (incl. stale-session reap warnings) are invisible #533).

Tests

  • Worker: terminate-during-in-flight-shutdown latches and completes in TERMINATED, one teardown, one status (tests/proxy/dap-proxy-worker.test.ts).
  • ProxyManager: escalation fires when killed=true but the process never exited; getProxyPid() survives stop/cleanup; the pinned "resolves immediately when already exited" test now models death with exitCode, not .killed (semantics: Node latches .killed on signal send, not death).
  • SessionManager: new session-manager-worker-reap.test.ts — status-driven exit reaps (stopCalls === 1), real child exit doesn't (pinned behavior preserved), closeSession awaits the in-flight stop, leak probe logs/stays silent by liveness.
  • HTTP: sweep-override honored; invalid values warn + default.
  • New e2e mcp-server-smoke-http-stale-reap.test.ts: real server + StreamableHTTP client (first use in the suite), mock session to natural termination, client abandoned without DELETE, worker pid captured via argv tags, polled to death, log asserted to contain the reap line and not the force-kill line. Red on unfixed main; green in ~7s with the fix (worker exits cleanly ~800ms after termination).

Full unit (4260), integration, and lint green; pre-push gate passed.

Dogfooding

The bug was held open live with mcp-debugger on itself: one debug session frozen at a breakpoint inside the parent's force-kill timer (freezing the timer keeps the zombie alive indefinitely), and a second session attached to the zombie worker via SIGUSR1 to inspect it from the inside (idle event loop, IPC pipe only, argv-tagged to the reaped session). Write-up: docs/case-studies/the-zombie-worker-and-the-frozen-force-kill.md. Dogfooding also filed #533 (sse --log-file gap) and #534 (worker stderr forwarded at error level).

🤖 Generated with Claude Code

https://claude.ai/code/session_018KnLteX47RBLM3ZYF1jGbc

… onto in-flight shutdown, escalate on real exit evidence, verify the kill

Root cause: on natural debuggee termination the worker runs shutdown() as a
floating promise off the DAP event; the parent's IPC terminate lands inside
that ≥1s SHUTTING_DOWN window, handleTerminate early-returned, and the
runner's post-command exit check (state === TERMINATED exactly) scheduled
nothing — the worker completed its own shutdown and sat alive forever, reaped
only by the 5s SIGKILL, and not even that on the paths where the escalation
was neutered.

Worker: shutdown() is re-entrant via a latched promise; terminate during
SHUTTING_DOWN awaits it, so the exit is always scheduled. Init-failure
rollback no longer clobbers a completed shutdown's TERMINATED.

ProxyManager/adapter: stop()'s IPC-send, SIGKILL, and early-resolve guards
key on exitCode/signalCode instead of .killed (which Node latches on any
delivered signal); ProxyProcessAdapter.kill() likewise; worker pid retained
across cleanup() and exposed as IProxyManager.getProxyPid().

SessionManager: terminal handlers retain their stop() promise
(pendingProxyStop) and the worker pid; closeSession/stopProxyPreservingSession
await the pending stop even after the reference is cleared; the status-driven
'exit' (worker claims dead — OS process may not be) now stops the proxy;
a post-close liveness probe logs "leaked worker" with the pid on recurrence.

HTTP: MCP_HTTP_STALE_SWEEP_INTERVAL_MS makes the 60s sweep testable;
--log-file now reaches the CLI logger (attachSharedFileTransport), so the
stale-session reap line is visible in the operator's log (sse: #533).

Tests: worker latch unit test; stop()-escalation and getProxyPid unit tests;
session-manager worker-reap suite; sweep-override unit tests; new e2e
(mcp-server-smoke-http-stale-reap) driving the real server + StreamableHTTP
client through abandon → reap → pid-dead with no force-kill — red on
unfixed main, green in ~7s after.

Case study: docs/case-studies/the-zombie-worker-and-the-frozen-force-kill.md
(double self-attach: one session frozen in the parent's force-kill timer, a
second attached to the zombie worker via SIGUSR1). Dogfooding also filed
#533 and #534.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018KnLteX47RBLM3ZYF1jGbc
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018KnLteX47RBLM3ZYF1jGbc
@codecov

codecov Bot commented Aug 27, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

isRunning() exit-evidence semantics; status-driven exit reap failure warn;
stopProxyPreservingSession pending-stop await + failure paths;
attachSharedFileTransport happy/idempotent/best-effort; handleHttpCommand
--log-file wiring with a non-winston logger.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018KnLteX47RBLM3ZYF1jGbc
@debugmcpdev
debugmcpdev merged commit eabe1f6 into main Aug 27, 2026
10 checks passed
@debugmcpdev
debugmcpdev deleted the fix/502-http-reap-leaves-worker branch August 27, 2026 22:52
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.

HTTP stale-session reaper may leave the session's proxy worker running (single observation)

1 participant