fix(main): exit Windows stdio servers when the spawning client dies (#914) - #1874
fix(main): exit Windows stdio servers when the spawning client dies (#914)#1874LazyXuan wants to merge 2 commits into
Conversation
|
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. What that means for this PR, concretely:
Things that will genuinely speed it up whenever review does happen:
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. |
|
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 Waiting on a handle rather than polling a ppid is the right primitive, for the reason you give. An open The fail-open/fail-closed asymmetry is right and I checked it. No trustworthy parent signal — snapshot failure, reserved PID, parent already gone, 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 Both deviations from the POSIX arm are explained in the script where a future reader will meet them, which is where they belong. Process noteClearance came back Please rebase — 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. |
4529db7 to
c38d191
Compare
…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>
c38d191 to
956489d
Compare
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.exeit 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:
KILL_ON_JOB_CLOSEjob insubprocess.cwraps processes CBM spawns itself (index workers);So the orphan lingers, holding SQLite WAL read locks that block checkpoints (#1083) and surface as
delete_projectpermission errors (the #914 symptom chain).Fix
The Windows branch of the watchdog waits on a handle instead of polling a ppid:
getppid()), the client resolves its parent PID via a Toolhelp snapshot and opens aSYNCHRONIZEhandle to it.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._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.initial_ppid <= 1fail-open choice; a thread-creation failure stays fail-closed.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.shnow runs on MSYS2 instead of skipping. The Windows arm differs from the POSIX arm for measured reasons, each noted in the script:ParentProcessId, which is not the wrapper script process) andTerminateProcesses exactly that one, because MSYSkill -9does 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-formatandcppcheckare clean on the touched region; the full suite is left to CI (local Git Bash has known spawn-isolation flakiness in daemon suites).Notes