Skip to content

subc-daemon: job-object containment for module grandchildren (#109) - #111

Closed
Qiiks wants to merge 2 commits into
cortexkit:masterfrom
Qiiks:fix/windows-job-object
Closed

Qiiks wants to merge 2 commits into
cortexkit:masterfrom
Qiiks:fix/windows-job-object

Conversation

@Qiiks

@Qiiks Qiiks commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Linked issue

Approved issue: #109

The agreed scope on that issue is the grandchild kill only. The session/host ownership model (maintainer-owned) is untouched.

What this does

Contains a supervised module's whole process tree in a Windows job object, so teardown reaches helpers a direct-child kill cannot.

A supervised module may spawn helpers of its own — the Synapse embedding module spawns a CUDA worker that holds the GPU allocation. Nothing in teardown took them: Child::kill is TerminateProcess scoped to one pid, and Windows has no process group to signal, so a module terminated rather than asked could not close its own pipes and its grandchildren outlived it. A day of restarts accumulated orphans.

This is the Windows arm only. Unix containment is the process group plus child_roster (already on master); the two are platform-disjoint and compose at the spawn site.

Why a job object rather than a tree walk

A job contains by membership, not ancestry, which covers the two cases a taskkill /T walk cannot:

  • a grandchild spawned between the walk and the kill;
  • a grandchild whose parent has already exited and been reparented, so it is no longer in the tree to be found. Those are exactly the orphans that accumulate.

Two properties are load-bearing:

  • JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE — dropping the last handle kills every member. That is what makes containment survive a daemon crash, where no code runs to call anything.
  • Breakaway is deliberately not permitted. Leaving JOB_OBJECT_LIMIT_BREAKAWAY_OK unset is what stops a child from escaping with CreateProcess/CREATE_BREAKAWAY_FROM_JOB. A module that could leave the job could leave its grandchildren behind, which is the defect.

The suspend → assign → resume contract

Assignment must happen before the child can run a single instruction, or it could spawn a grandchild that escapes — the defect in smaller form. CreateProcess has no way to place a process in a job atomically short of building the process by hand with PROC_THREAD_ATTRIBUTE_JOB_LIST, so the child is created suspended, assigned, and then resumed.

There is exactly one place that order lives (contain_spawned_child), and the crate root documents it. resume_main_thread reports failure rather than logging it: a suspended process holding a pid with no way to start is a leak, so the caller kills it and fails the spawn.

An assignment failure is not fatal — an uncontained module behaves exactly as it did before, whereas refusing to start one would be a new outage. It is logged at warn because it means a helper could leak.

Ordering at teardown

start_kill terminates the job tree, and it runs at the existing kill site — after the drain budget and after the reaped child, never before. Containment never changes whether a module is reported as stopped: a job failure is logged and the direct-child kill still decides the outcome.

New crate

subc-jobobject follows the subc-cgroup model: a leaf crate, because subc-daemon forbids unsafe code and the Win32 calls need it. Every FFI call and its safety argument is in one file (sys.rs), and the manual Send for the job handle sits on that side of the boundary rather than on the public API. Sync is deliberately not implemented — concurrent TerminateJobObject and Drop on one handle would be a double-close.

Tests

Three, run against the supervisor rather than the crate, because the claim is about teardown: a crate-level test proves a job can reap a tree, not that the daemon's drain path reaches it.

test what it defends
teardown_reaps_the_grandchild the fix: the grandchild is gone after the supervisor's own drain
an_uncontained_grandchild_survives_a_direct_child_kill the defect reproduction from #109
dropping_containment_reaps_the_grandchild crash durability: the tree dies with no teardown code running

The mutation control was run: with assignment disabled, teardown_reaps_the_grandchild reddens by name —

grandchild 24616 outlived module teardown: the tree was not contained

The fixture is the sanctioned fake-aft-stub, extended with a grandchild mode rather than a new binary. subc-core and subc-daemon are version-bumped for the wire check.

Verification

  • cargo test -p subc-daemon --lib — 299 passed, 0 failed
  • cargo test -p subc-jobobject — 4 passed, 0 failed (+ doc test)
  • cargo clippy --all-targets -- -D warnings clean on both crates
  • cargo fmt --all -- --check clean
  • Rebased onto current master (f193a178)

Limits

  • Windows only. The #[cfg(windows)] arm compiles out elsewhere; the cgroup lane is a separate containment path with its own tests.
  • The direct-child kill is unchanged. Containment is additive; start_kill still calls it, so nothing about existing stop reporting moves.
  • The grandchild fixture is a parked process. It proves containment reaches a real grandchild; it does not exercise a module's own spawn logic.

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.


Summary by cubic

Contains a supervised module's whole Windows process tree in a job object, so teardown reaches grandchildren a direct-child kill cannot. A module's CUDA worker held the GPU allocation and outlived restarts, accumulating orphans.

  • New subc-jobobject crate; the child is created suspended, assigned, then resumed so nothing runs before it is contained.
  • The job kills its members when the last handle closes (daemon-crash durability) and forbids breakaway, so a child cannot escape containment.
  • KILL_ON_JOB_CLOSE also fires on every ordinary Windows stop (taskkill or scheduler /End, no SIGTERM handler), killing all modules at once instead of letting them run teardown — accepted for now; a real drain-before-exit stop path is the planned fix.
  • A job-assignment failure is not fatal: the module runs uncontained as before, logged at warn; a resume failure kills the child and fails the spawn.
  • Teardown terminates the job at the existing kill site, and the direct-child kill still decides whether the module is reported stopped.
  • Tests run against the supervisor: teardown reaps the grandchild, an uncontained grandchild survives the same kill (issue Module teardown kills the direct child only — grandchildren survive on Windows #109 reproduction), and closing the job handle reaps the tree with no teardown code running.
  • Windows only; subc-core and subc-daemon are version-bumped.

Written for commit a4720c9. Summary will update on new commits.

Review in cubic

Copilot AI lite review requested due to automatic review settings September 21, 2026 07:39

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@subc-alfonso subc-alfonso Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the shape #109 asked for, and it gets the hard part right.

What I checked. Each child is created suspended, assigned to its job, then resumed, so it can't run a single instruction outside containment. That spawn-to-assign window is the one that decides whether a job object contains anything at all. Breakaway is refused, so a module can't opt out with CREATE_BREAKAWAY_FROM_JOB. The unsafe lives in its own leaf crate behind a safe API, so subc-daemon keeps its lint. The supervisor tests go through the drain path instead of stopping at the crate, which is the claim that matters: a crate test proves a job can reap a tree, not that the daemon's teardown reaches it. Gate on this branch: fmt clean, clippy clean on host and on x86_64-pc-windows-gnu in both profiles, a Linux cross-check clean, and the workspace at 1454 passed / 0 failed. The Windows-only tests themselves run only in CI, since I have no Windows host in this gate.

One tradeoff to write down before merge. JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE is what makes containment survive a daemon crash, and that's the right reason to set it. It also fires on every ordinary daemon stop, because on Windows the daemon has no stop notice: the SIGTERM handler is #[cfg(unix)], and a Windows stop is taskkill or the scheduler's /End. When the process ends, the kernel closes the job handle and every module is TerminateProcess'd at once.

Before this PR the modules survived that, saw EOF on their control socket, and ran their own teardown. On Unix that's deliberate: the daemon exits with process::exit(0) precisely so a module can seal a WAL or close a capture rather than being killed mid-write. So on Windows this trades graceful module teardown on every daemon stop for containment on a crash.

I think that's the right trade for now. The orphaned GPU workers are a reported, recurring problem; mid-write kills on a Windows daemon stop are a smaller one, since the modules that write most heavily don't run on Windows today. But it should be explicit, not incidental. Could you add a short paragraph to the job field's doc saying so, and naming the fix, so the next reader doesn't take the crash rationale as the whole story?

The fix is mine, not yours. Give the Windows daemon a real stop path that drains before it exits, the Windows twin of the Unix SIGTERM handler. Once that exists, KILL_ON_JOB_CLOSE only reaches whatever is left after the drain, which is exactly what it should reach. That's the daemon's stop contract, so I'll take it as a follow-up and link it here.

With the doc paragraph added, I'm happy to merge.

Qiiks added a commit to Qiiks/subconscious that referenced this pull request Sep 23, 2026
The job field's doc explained the limit only as crash containment. It also
fires on every ordinary Windows daemon stop, because the daemon has no stop
notice there: the SIGTERM handler is #[cfg(unix)], so a stop is taskkill or
the scheduler's /End, the kernel closes the job handle, and every module is
TerminateProcess'd at once rather than running its own teardown.

Stating the trade and naming the fix, so the next reader does not take the
crash rationale as the whole story. Requested in review of cortexkit#111.
@cortexkit-ci

cortexkit-ci Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

#109 has the design-approved label; this PR can be reviewed.

…ortexkit#109)

A supervised module may spawn helpers of its own -- the Synapse embedding
module spawns a CUDA worker that holds the GPU allocation -- and nothing in
teardown took them. Child::kill is TerminateProcess scoped to one pid, and
Windows has no process group to signal, so a module terminated rather than
asked could not close its own pipes and its grandchildren outlived it. A day
of restarts accumulated orphans.

New leaf crate subc-jobobject follows the subc-cgroup model: this crate
forbids unsafe code and the Win32 calls need it. The job carries
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE and breakaway is deliberately not
permitted, so a daemon crash reaps the tree that no supervisor code is left
alive to kill, and a child cannot escape by CreateProcess with
CREATE_BREAKAWAY_FROM_JOB.

Spawn is suspend -> assign -> resume, in contain_spawned_child: assignment
must happen before the child can run a single instruction, or it could spawn
a grandchild that escapes. An assignment failure is NOT fatal -- an
uncontained module behaves exactly as before -- and is logged at warn.

start_kill terminates the job tree after the drain wait (the maintainer's
requirement): the drain and the reaped child run first, the tree kill after.
Containment never changes whether a module is reported as stopped.

Tests run against the supervisor: teardown reaps the grandchild, an
uncontained grandchild survives a direct child kill (the defect reproduction
from cortexkit#109), and dropping containment reaps the tree with no teardown code
running (crash durability). The regression test was verified to redden by
name under a containment-disabled mutation.

The job object is the Windows arm only. Unix containment is the process group
plus the child roster, which is already on master; the two compose and use
independent process attributes on Linux (setpgid in the child, cgroup.procs
via pre_exec).

Cargo.lock gains subc-jobobject, subc-daemon takes it as a target-cfg windows
dependency, and subc-core's fake-aft-stub gains the grandchild mode the
containment tests drive.
The job field's doc explained the limit only as crash containment. It also
fires on every ordinary Windows daemon stop, because the daemon has no stop
notice there: the SIGTERM handler is #[cfg(unix)], so a stop is taskkill or
the scheduler's /End, the kernel closes the job handle, and every module is
TerminateProcess'd at once rather than running its own teardown.

The Windows stop path is still outstanding; the Unix arm now has one
(process groups plus the child roster). Stating the trade and naming the fix,
so the next reader does not take the crash rationale as the whole story.
Requested in review of cortexkit#111.
@Qiiks
Qiiks force-pushed the fix/windows-job-object branch from c6e7dee to a4720c9 Compare September 23, 2026 23:01
@Qiiks

Qiiks commented Sep 23, 2026

Copy link
Copy Markdown
Contributor Author

Doc paragraph added, on the job field in SupervisedChild:

That limit is not crash-only, and the difference is worth knowing: a Windows daemon stop is taskkill or the scheduler's /End — the SIGTERM handler is #[cfg(unix)] — so the daemon dies with no stop notice and the kernel closes the job handle, TerminateProcessing every module at once. Before this change they survived that, saw EOF on the control socket, and ran their own teardown; Unix keeps that path deliberately, so a module can seal a WAL or close a capture rather than be killed mid-write. So this trades graceful teardown on every Windows daemon stop for containment on a crash, which is the right way round today: orphaned GPU workers are a reported, recurring problem, and the modules that write most heavily do not run on Windows.

The fix is a real Windows stop path — the daemon draining before it exits, the twin of the Unix SIGTERM handler. Once it exists, this limit only reaches what the drain left behind, which is what it should reach.

The Windows tests, which your gate could not run. Ran on a real Windows host:

$ cargo test -p subc-daemon --lib job_containment
test supervise::job_containment_tests::dropping_containment_reaps_the_grandchild ... ok
test supervise::job_containment_tests::an_uncontained_grandchild_survives_a_direct_child_kill ... ok
test supervise::job_containment_tests::teardown_reaps_the_grandchild ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 345 filtered out

The daemon lib suite runs 348 passed / 0 failed alongside them, with cargo fmt --check and cargo clippy -p subc-daemon --all-targets -- -D warnings both clean.

One thing worth knowing about running them by hand, since it fails in a way that reads like a containment defect: the fixture needs cargo build -p subc-core --bins first. cargo test -p subc-core --lib does not build [[bin]] targets, so fake-aft-stub.exe is absent and all three tests fail on the missing stub, not on the job object.

Rebased onto master. The Unix arm landed while this was in review (4bc1d17e — process groups plus child_roster), so your note that Unix keeps the graceful path is now the implemented behaviour rather than a deliberate omission on this side. The two arms are platform-disjoint and compose at the spawn site: #[cfg(unix)] command.process_group(0) next to the Windows suspend → assign → resume, with the job object's doc pointing at the Unix arm so the next reader does not take the Windows mechanism for the whole containment story.

The rebase forced one integration change: ModuleSpec gained overlap for blue/green swaps, so the containment fixture's spec literal needed overlap: Default::default(). That is in the first commit, which compiles and tests standalone.

The Windows stop path is the half I have not touched, as you said it is yours.

@Qiiks

Qiiks commented Sep 23, 2026

Copy link
Copy Markdown
Contributor Author

Doc paragraph and rebase are pushed; the design gate is now the only blocker and it needs your label.

The PR description gained the template's ## Linked issue line (Approved issue: #109), so the gate moved from No linked issue to:

design-gate: #111 -> failure (Waiting for `design-approved` on #109)

#109 carries no labels yet. The design conversation did happen there — the job-object ruling, and the scope narrowed to the grandchild kill — so labelling it is the last step on my side of this.

Two notes on the rebased branch, since they are the parts a reviewer would otherwise have to reconstruct:

  • The fixture needs cargo build -p subc-core --bins before cargo test -p subc-daemon --lib. --lib does not build [[bin]] targets, so fake-aft-stub.exe is absent and all three containment tests fail on the missing stub rather than on the job object — which reads like a containment regression if you hit it cold.
  • ModuleSpec grew overlap while this was in review, so the containment fixture's spec literal needed it. That is in the first commit, which compiles and tests standalone.

ualtinok pushed a commit that referenced this pull request Sep 24, 2026
The job field's doc explained the limit only as crash containment. It also
fires on every ordinary Windows daemon stop, because the daemon has no stop
notice there: the SIGTERM handler is #[cfg(unix)], so a stop is taskkill or
the scheduler's /End, the kernel closes the job handle, and every module is
TerminateProcess'd at once rather than running its own teardown.

The Windows stop path is still outstanding; the Unix arm now has one
(process groups plus the child roster). Stating the trade and naming the fix,
so the next reader does not take the crash rationale as the whole story.
Requested in review of #111.
ualtinok added a commit that referenced this pull request Sep 24, 2026
…n test

drain_child_to_state gained a StopNotice argument on master after #111 was
written; the test only compiles on Windows, so it was the one call site left.
ualtinok pushed a commit that referenced this pull request Sep 24, 2026
The job field's doc explained the limit only as crash containment. It also
fires on every ordinary Windows daemon stop, because the daemon has no stop
notice there: the SIGTERM handler is #[cfg(unix)], so a stop is taskkill or
the scheduler's /End, the kernel closes the job handle, and every module is
TerminateProcess'd at once rather than running its own teardown.

The Windows stop path is still outstanding; the Unix arm now has one
(process groups plus the child roster). Stating the trade and naming the fix,
so the next reader does not take the crash rationale as the whole story.
Requested in review of #111.
ualtinok added a commit that referenced this pull request Sep 24, 2026
…n test

drain_child_to_state gained a StopNotice argument on master after #111 was
written; the test only compiles on Windows, so it was the one call site left.
@subc-alfonso

subc-alfonso Bot commented Sep 24, 2026

Copy link
Copy Markdown

Thanks. This is merged to master as your two commits, with four small commits of mine on top, so I'm closing the PR here rather than asking you to rebase again. Master had moved since your last push, and a few things only showed up once the branch sat on the current code:

  • Publishing. subc-daemon is now published to crates.io, and crates.io refuses a published crate with an unpublishable dependency. So subc-jobobject is now publish = true, version-pinned from the daemon, and part of the publish-on-bump run.
  • Windows: every supervised module gets a console window; closing it kills the module (0xC000013A) #131. I folded your console-window fix in here, since it lives in the same flags: CONTAINMENT_CREATION_FLAGS = CREATE_SUSPENDED | CREATE_NO_WINDOW, set in one creation_flags call for exactly the reason you gave. A new Windows test spawns a contained child and asserts it has no console window at all.
  • A Windows-only test call site. drain_child_to_state gained a stop-notice argument on master after your branch was cut, and the Windows containment test was the one caller that macOS and Linux never compile.
  • One test is now Unix-only. a_held_stderr_pipe_marks_the_tail_incomplete_and_its_late_output_stays_with_its_process has a module exit while a descendant still holds its stderr pipe. With your change that can't happen on Windows, because the job closes and takes the descendant with it, so the pipe closes at once. That's the containment Module teardown kills the direct child only — grandchildren survive on Windows #109 asked for, and the comment at the test says so.

CI is green on every leg, including Windows. The doc paragraph on KILL_ON_JOB_CLOSE is in as you wrote it. The real Windows stop path (drain before exit) is still the follow-up it describes.

@subc-alfonso subc-alfonso Bot closed this Sep 24, 2026
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.

2 participants