Module teardown kills the direct child only — grandchildren survive on Windows
Summary
drain_child_to_state ends in child.start_kill() for the direct child, and on
non-Unix request_graceful_stop is a documented no-op. Nothing takes the
descendant tree. On Windows that means a module's grandchildren outlive the
module, and a restart accumulates orphans.
This is the standalone slice you said you'd welcome from #103 ("the grandchild
kill ... is a real gap in our Windows teardown"). Filing it as an issue first per
the new process.
Evidence
1. The teardown primitive is direct-child only. crates/subc-daemon/src/supervise.rs:
drain_child_to_state (L4978) → on timeout/start_kill() (L5023), which is
Rust Child::kill → Win32 TerminateProcess, scoped to that pid.
request_graceful_stop (L5083) is #[cfg(unix)] and sends SIGTERM. The
#[cfg(not(unix))] arm (L5113) logs "no graceful stop signal exists on this
platform; protocol: none teardown waits, then kills" and does nothing.
- No
taskkill in the teardown path: git grep taskkill origin/master matches
only crates/subc-core/src/setup/runtime.rs (self-update), and only as
/PID <pid> /F — there is no /T anywhere in the tree.
- No job object:
git grep -n "JobObject\|AssignProcessToJobObject\|CREATE_SUSPENDED"
on master returns nothing, so there is no containment primitive underneath
this either.
2. TerminateProcess provably does not reap grandchildren. Minimal
reproducer (termkill_repro.py, ~60 lines, spawns child → grandchild, kills only
the child):
child pid = 1968
grandchild pid = 15760
child killed = 1968 (TerminateProcess)
grandchild alive after direct-child kill: True
3. It is happening in the live fleet, not hypothetically. Current process
tree on this machine:
ck-subc-no-console.exe pid=904 (daemon)
├── ck-aft.exe pid=18996 ppid=904
└── ck-synapse-batch-advice pid=456 ppid=904 (supervised module)
└── ck-synapse-worker-cuda pid=20692 ppid=456 (grandchild, ~2.2 GB VRAM)
The grandchild is the embedding engine and it holds the GPU allocation. The
worker's own loop breaks on pipe EOF (synapse-worker-cuda/src/main.rs:162,
WorkerRequest::Shutdown => break at L265), so it does exit when its parent
closes the pipe gracefully — but that is the parent's cooperation, not the
supervisor reaping a tree. A parent that is TerminateProcess'd cannot close
anything, which is precisely the path above.
Consequence: on the Restarting/Disabled/wedge paths that reach
start_kill(), a module with a helper process leaks that helper, and its GPU or
port allocation with it. Repeat over a day of restarts and the cost compounds.
Proposed fix
Move the tree kill to the kill step, after the drain-and-wait — the ordering you
asked for in #103, which is also the correct one here because a drain that still
needs to deliver GOODBYE must not have its process tree removed first:
// after the drain wait, at the kill site, not ahead of it
#[cfg(windows)]
if let Some(pid) = child.id() {
Command::new("taskkill.exe")
.args(["/PID", &pid.to_string(), "/T", "/F"])
.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null())
.creation_flags(0x0800_0000);
let _ = timeout(Duration::from_secs(10), command.status()).await;
}
// then the existing child.start_kill() / wait as the fallback that still owns
// the outcome if taskkill is unavailable or refuses
/T takes the descendants; the existing start_kill() stays as the fallback
that decides the outcome, so a missing or refusing taskkill cannot change
module state — matching the best-effort posture request_graceful_stop already
documents.
Alternatives, and why I did not pick them:
- Job object with
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE is the more durable
fix — it reaps the tree on any parent death, including a daemon crash, where
taskkill /T only helps when the supervisor is alive to call it. It is also a
larger change: the child must be created into the job at spawn (suspended or
before it can fork), and SupervisedChild's spawn path is shared with the Unix
lanes. Worth considering as the follow-up rather than the first step.
CREATE_NEW_PROCESS_GROUP alone does not help: it changes console control
routing, not termination scope.
Test
job_pool / supervisor tests need a Windows case that spawns a real grandchild,
kills through drain_child_to_state, and asserts the grandchild is gone —
otherwise this is unenforced. On non-Windows the arm compiles out, so the test
is #[cfg(windows)] like the monitor tests in #103; per your note that means the
Windows CI leg is the only evidence and I will read it as such.
Process note
This is the small standalone slice, not the lifecycle PR. If you'd rather have
the job-object shape first, say so and I'll write the issue for that instead of
the /T patch.
Module teardown kills the direct child only — grandchildren survive on Windows
Summary
drain_child_to_stateends inchild.start_kill()for the direct child, and onnon-Unix
request_graceful_stopis a documented no-op. Nothing takes thedescendant tree. On Windows that means a module's grandchildren outlive the
module, and a restart accumulates orphans.
This is the standalone slice you said you'd welcome from #103 ("the grandchild
kill ... is a real gap in our Windows teardown"). Filing it as an issue first per
the new process.
Evidence
1. The teardown primitive is direct-child only.
crates/subc-daemon/src/supervise.rs:drain_child_to_state(L4978) → on timeout/start_kill()(L5023), which isRust
Child::kill→ Win32TerminateProcess, scoped to that pid.request_graceful_stop(L5083) is#[cfg(unix)]and sends SIGTERM. The#[cfg(not(unix))]arm (L5113) logs "no graceful stop signal exists on thisplatform; protocol: none teardown waits, then kills" and does nothing.
taskkillin the teardown path:git grep taskkill origin/mastermatchesonly
crates/subc-core/src/setup/runtime.rs(self-update), and only as/PID <pid> /F— there is no/Tanywhere in the tree.git grep -n "JobObject\|AssignProcessToJobObject\|CREATE_SUSPENDED"on master returns nothing, so there is no containment primitive underneath
this either.
2.
TerminateProcessprovably does not reap grandchildren. Minimalreproducer (
termkill_repro.py, ~60 lines, spawns child → grandchild, kills onlythe child):
3. It is happening in the live fleet, not hypothetically. Current process
tree on this machine:
The grandchild is the embedding engine and it holds the GPU allocation. The
worker's own loop breaks on pipe EOF (
synapse-worker-cuda/src/main.rs:162,WorkerRequest::Shutdown => breakat L265), so it does exit when its parentcloses the pipe gracefully — but that is the parent's cooperation, not the
supervisor reaping a tree. A parent that is
TerminateProcess'd cannot closeanything, which is precisely the path above.
Consequence: on the
Restarting/Disabled/wedge paths that reachstart_kill(), a module with a helper process leaks that helper, and its GPU orport allocation with it. Repeat over a day of restarts and the cost compounds.
Proposed fix
Move the tree kill to the kill step, after the drain-and-wait — the ordering you
asked for in #103, which is also the correct one here because a drain that still
needs to deliver GOODBYE must not have its process tree removed first:
/Ttakes the descendants; the existingstart_kill()stays as the fallbackthat decides the outcome, so a missing or refusing
taskkillcannot changemodule state — matching the best-effort posture
request_graceful_stopalreadydocuments.
Alternatives, and why I did not pick them:
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSEis the more durablefix — it reaps the tree on any parent death, including a daemon crash, where
taskkill /Tonly helps when the supervisor is alive to call it. It is also alarger change: the child must be created into the job at spawn (suspended or
before it can fork), and
SupervisedChild's spawn path is shared with the Unixlanes. Worth considering as the follow-up rather than the first step.
CREATE_NEW_PROCESS_GROUPalone does not help: it changes console controlrouting, not termination scope.
Test
job_pool/ supervisor tests need a Windows case that spawns a real grandchild,kills through
drain_child_to_state, and asserts the grandchild is gone —otherwise this is unenforced. On non-Windows the arm compiles out, so the test
is
#[cfg(windows)]like the monitor tests in #103; per your note that means theWindows CI leg is the only evidence and I will read it as such.
Process note
This is the small standalone slice, not the lifecycle PR. If you'd rather have
the job-object shape first, say so and I'll write the issue for that instead of
the
/Tpatch.