Skip to content

Module teardown kills the direct child only — grandchildren survive on Windows #109

Description

@Qiiks

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> /Fthere 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions