Skip to content

fix(main): exit Windows stdio servers when the spawning client dies (#914) - #1874

Open
LazyXuan wants to merge 2 commits into
DeusData:mainfrom
LazyXuan:fix/windows-parent-watchdog
Open

fix(main): exit Windows stdio servers when the spawning client dies (#914)#1874
LazyXuan wants to merge 2 commits into
DeusData:mainfrom
LazyXuan:fix/windows-parent-watchdog

Conversation

@LazyXuan

Copy link
Copy Markdown
Contributor

Fixes #914 (also the lingering-server half of the #185 report; eases the WAL bloat in #1083).

Problem

When an MCP stdio client on Windows (ZCode, Codex, OpenCode, VS Code, ...) is force-killed, the codebase-memory-mcp.exe it spawned survives as an orphan, blocked on stdin forever.

The parent-death watchdog from #407 was built POSIX-only, with the comment "Windows is unaffected (job objects handle this)". That assumption only covers half the process tree:

  • the KILL_ON_JOB_CLOSE job in subprocess.c wraps processes CBM spawns itself (index workers);
  • an MCP stdio server is spawned BY the client, as the client's child — and Windows, unlike POSIX, never propagates a parent's termination to its children.

So the orphan lingers, holding SQLite WAL read locks that block checkpoints (#1083) and surface as delete_project permission errors (the #914 symptom chain).

Fix

The Windows branch of the watchdog waits on a handle instead of polling a ppid:

  • At startup (same point POSIX captures getppid()), the client resolves its parent PID via a Toolhelp snapshot and opens a SYNCHRONIZE handle to it.
  • The watchdog thread waits on that handle with a 500 ms timeout (the timeout only re-checks g_shutdown, mirroring the POSIX poll cadence). The kernel signals a process handle exactly once, on termination, and the held handle pins the process object — so PID reuse cannot fool the wait, unlike re-reading a ppid.
  • A signaled parent takes the same deliberate _exit(0) as POSIX: with the owning client gone, kernel handle reclamation is the only trustworthy release for the daemon connection, file locks, and the WAL read lock an orphan would otherwise pin.
  • No trustworthy parent signal at startup (snapshot failed, reserved PID, parent already gone, handle unopenable) → keep running and lean on the stdin EOF path, exactly the POSIX initial_ppid <= 1 fail-open choice; a thread-creation failure stays fail-closed.
  • Workers are untouched: they run inside CBM's own kill-on-close job, which is the containment the old comment assumed everyone had.

This only affects CBM's own lifecycle: the watchdog terminates this process when its own parent dies. It never touches, signals, or enumerates any other process beyond reading the parent PID once.

Test

tests/test_parent_watchdog.sh now runs on MSYS2 instead of skipping. The Windows arm differs from the POSIX arm for measured reasons, each noted in the script:

  • stdin is an anonymous pipe (an MSYS FIFO is not readable by native binaries — the server blocks forever on it), with a writer helper that holds the write end open, so the observed exit can only come from the watchdog, never an EOF;
  • the kill resolves the child's Windows-physical parent (the pipeline subshell — the watchdog watches the Toolhelp ParentProcessId, which is not the wrapper script process) and TerminateProcesses exactly that one, because MSYS kill -9 does not reliably terminate the Windows process behind an MSYS pid.

Discrimination proof (local Windows run of the same test on the same tree): the pre-fix binary leaves the server running after the parent dies; this build exits within one watchdog tick. clang-format and cppcheck are clean on the touched region; the full suite is left to CI (local Git Bash has known spawn-isolation flakiness in daemon suites).

Notes

  • Scope deliberately mirrors POSIX semantics, including the "parent already dead at startup" fail-open (POSIX cannot distinguish that from ppid==1 either); the stdin EOF path covers it.
  • A shell-launched server still watches the shell, not the ultimate client — the same limitation the POSIX ppid poll has.

@LazyXuan
LazyXuan requested a review from DeusData as a code owner August 28, 2026 12:33
@github-actions

Copy link
Copy Markdown

Thanks for opening this — it has been seen, and it is queued.

This note is automated, but it is not a brush-off: it exists so you know where your PR stands instead of having to guess from silence.

Current review status: working through a backlog. 0.9.1-rc.1 is out, so the release freeze that held reviews is over — but it left a large queue of open pull requests behind it, and we are reading through them oldest-first. The background is in discussion #1144.

What that means for this PR, concretely:

  • It will not be closed for inactivity. No stale bot touches pull requests here.
  • It may still sit a while before a human reads it. That is on us, not on you.
  • Older PRs are read first, so a recent one is not being skipped — it is behind a queue.

Things that will genuinely speed it up whenever review does happen:

  • Keep it rebased on main — the tree is moving quickly right now, and a conflicting branch cannot be reviewed as the diff you intended.
  • Get CI green, or say which failures you believe are pre-existing.
  • Keep the change to one claim. Bundled features and refactors get split before they get merged, which costs you a round trip.
  • Every commit needs a sign-off (git commit -s) — CI enforces DCO.

If this fixes a bug, a reproduction we can run is worth more than a description of the symptom.

Thanks for contributing, and sorry in advance for the wait.

@DeusData

DeusData commented Sep 1, 2026

Copy link
Copy Markdown
Owner

Approved. The diagnosis corrects a documented assumption, and the mechanism you chose is better than the one it mirrors.

The half-truth in the #407 comment is the crux. "Windows is unaffected (job objects handle this)" is true for processes CBM spawns — those sit in its own KILL_ON_JOB_CLOSE job. It is simply not true for an MCP stdio server, which is spawned by the client, as the client's child, and Windows does not propagate a parent's termination downward. Splitting the process tree into those two halves is what makes this obviously a real gap rather than a theoretical one, and the symptom chain you trace — orphan holds a SQLite WAL read lock, checkpoints blocked (#1083), delete_project surfaces a permission error (#914) — explains why it looked like three unrelated bugs.

Waiting on a handle rather than polling a ppid is the right primitive, for the reason you give. An open SYNCHRONIZE handle pins the process object, so the PID cannot be recycled underneath you; the kernel signals it exactly once, on termination. A ppid poll can be fooled by reuse and has to re-read state it does not own. That is the same category of improvement as using a Job Object for tree membership — picking the primitive that makes the race impossible instead of narrowing the window.

The fail-open/fail-closed asymmetry is right and I checked it. No trustworthy parent signal — snapshot failure, reserved PID, parent already gone, OpenProcess refusing — returns success and leaves the process running on the stdin EOF path, which is exactly POSIX's initial_ppid <= 1 choice. A thread-creation failure returns false. Those are the correct directions: an unknowable parent should not kill a working server, but a watchdog that cannot start should not be reported as started.

And the test earns its result. The writer holding the pipe's write end open means stdin never sees EOF, so the observed exit can only come from the watchdog. Without that, a passing test would be equally consistent with an EOF exit and would prove nothing. Resolving the child's Windows-physical parent and calling TerminateProcess on exactly that one — rather than trusting MSYS kill -9 — is the same discipline applied to the kill side.

Both deviations from the POSIX arm are explained in the script where a future reader will meet them, which is where they belong.

Process note

Clearance came back REVIEW(3), all three in tests/test_parent_watchdog.sh: exec sleep 3600, and two chmod +x. I read them — they are the wrapper helper the test writes into its own temp directory from a quoted heredoc, then marks executable. No exemption, no network, nothing touched outside the tmpdir. The scanner is flagging ordinary scaffolding in a shell file, which is what it is supposed to do; I will get the marker written before merge.

Please rebasemain moved three times today (broken by a duplicate-symbol merge, repaired by #1993, then #1703 landed).

Being explicit that a shell-launched server still watches the shell rather than the ultimate client — the same limitation the POSIX poll has — is the right way to leave a known boundary. Thank you.

@DeusData DeusData added bug Something isn't working stability/performance Server crashes, OOM, hangs, high CPU/memory editor/integration Editor compatibility and CLI integration windows Windows-specific issues priority/high Needs near-term maintainer attention; high-impact bug, regression, safety issue, or release blocker. labels Sep 1, 2026
@LazyXuan
LazyXuan force-pushed the fix/windows-parent-watchdog branch 3 times, most recently from 4529db7 to c38d191 Compare September 3, 2026 08:00
…eusData#914)

The POSIX parent-death watchdog (DeusData#407) was excluded on Windows with the
comment "job objects handle this". They do not: the KILL_ON_JOB_CLOSE
job in subprocess.c only wraps processes CBM spawns itself. An MCP stdio
server is spawned BY the client as its child, and Windows never
propagates a parent's termination to its children, so a force-killed
client (editor crash, task manager, CI timeout) leaves the server
lingering forever blocked on stdin. The orphan pins SQLite WAL read
locks, blocking checkpoints (DeusData#1083) and feeding the delete_project
permission-denied chain from DeusData#914.

Windows has no reparenting to poll for, so instead of getppid polling
the watchdog opens a SYNCHRONIZE handle to the parent at startup and
waits on it: the kernel signals a process handle exactly once on
termination, and the held handle pins the process object, so PID reuse
cannot fool the wait. The 500 ms loop timeout exists only to re-check
g_shutdown, mirroring the POSIX poll cadence. On a signaled parent the
thread takes the same deliberate _exit(0) as POSIX: after the owning
client is gone, kernel handle reclamation is the only trustworthy
release for the daemon connection, file locks and the WAL read lock.

When no trustworthy parent signal exists at startup (snapshot failed,
reserved PID, parent already exited, or the handle cannot be opened),
the client keeps running and leans on the stdin EOF path - the same
fail-open choice as the POSIX initial_ppid <= 1 bail-out - while a
watchdog thread creation failure stays fail-closed. Workers are
unchanged: they are spawned inside CBM's own kill-on-close job, which
is the containment the old comment assumed everyone had.

The parent-watchdog shell test now runs on MSYS2 instead of skipping:
an MSYS bash wrapper launches the native server over an anonymous pipe
(an MSYS FIFO is not readable by native binaries), a writer helper
holds the write end open so the exit can only come from the watchdog,
and the kill resolves the child's Windows-physical parent (the pipeline
subshell, not the wrapper script process - the watchdog watches the
Toolhelp ParentProcessId) and TerminateProcesses exactly that one,
because MSYS kill -9 does not reliably terminate the Windows process
behind an MSYS pid.

Verified against the pre-fix binary: same test, same tree, the old
build leaves the server running after the parent dies; this build exits
within one watchdog tick.

Signed-off-by: 周文瑄 <zhouwx1997@126.com>
CI caught the macOS run exiting 1 AFTER the watchdog assertion passed
('ok: child exited after parent death' followed by exit code 1): the
kill_hard helper resolved the Windows pid with 'ps -W', an MSYS-only
flag. Under 'set -euo pipefail' the failing ps aborts the helper, the
'[[ -n ]] && kill_hard' statement inherits that status, and the EXIT
trap turns a passing test into a red job. Linux ps would fail the same
way. Guard the pipeline (empty winpid on POSIX, where the flag does not
exist) and restore the bare '|| true' tail on every cleanup kill, which
the original script had and my refactor dropped.

Signed-off-by: 周文瑄 <zhouwx1997@126.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working editor/integration Editor compatibility and CLI integration priority/high Needs near-term maintainer attention; high-impact bug, regression, safety issue, or release blocker. stability/performance Server crashes, OOM, hangs, high CPU/memory windows Windows-specific issues

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Windows v0.8.1: stale graph + Permission denied on delete_project after orphan cbm process (reconfirm #277)

2 participants