Skip to content

fix(watch): stop nub watch leaking an immortal node --watch supervisor - #620

Merged
colinhacks merged 1 commit into
mainfrom
fix-watch-orphans
Jul 29, 2026
Merged

fix(watch): stop nub watch leaking an immortal node --watch supervisor#620
colinhacks merged 1 commit into
mainfrom
fix-watch-orphans

Conversation

@colinhacks

Copy link
Copy Markdown
Contributor

run_watch was the only long-lived spawn in nub that called Command::status() directly. Every other one — spawn.rs:672, spawn.rs:1182, cli.rs:4647 — goes through a process-group + reaper path.

That single gap is why orphan sweeps on the dev host kept finding stranded processes:

rustc:  13 total,   0 orphaned
cargo:   3 total,   0 orphaned
node:  212 total,  99 running --watch, ALL with PPID=1   (oldest: 17 hours)

Exclusively node --watch. Never rustc, never cargo — because those paths are already guarded.

Why this path matters most

node --watch is a supervisor that by design never exits, and it spawns a watched grandchild of its own. With no process group and no reaper, the death of the nub leader — a killed agent session, a test harness that gives up, a closed terminal — reparents both to launchd, where they run forever holding an fsevents watch on a source tree. They accumulate monotonically, so the only remedy was clearing them by hand once the machine slowed to a crawl.

The fix

Both spawn sites (cli.rs:4832 NODE_COMPAT, cli.rs:~5084 augmented) now use status_forwarding_signals() — the existing helper documented as "the signal-faithful, subtree-reaching equivalent of cmd.status()": own process group, the #480 SIGKILL-on-leader reaper held across the wait, and terminating signals forwarded to the whole subtree.

No new machinery. This is #480's reaper applied to the one path that was missing it.

The regression test kills the LEADER, not the group — and that is the test

A first version killed the process group and passed against unfixed code. On the unfixed path the watched node inherits nub's group, so a group signal reaches it directly and no reaper is exercised. It would have shipped green forever while protecting nothing.

Killing the leader alone is also the shape of the real failure: a session dies, nub goes with it, node --watch does not.

Verified both directions:

Result
Unfixed + leader-kill test FAILEDwatched node (pid 19633) survived
Fixed + leader-kill test 5 passed, 0 failed

Gates

cargo check -p nub-cli · cargo fmt --check · cargo clippy --all-targets --all-features -- -D warnings · cargo test -p nub-cli --test pdeath_watch — all clean locally.

Scope

This stops new orphans. It does not address the separate contention on that host (~20 concurrent builds on 10 cores, 74 live worktrees / 262 GB of target dirs) — those are structural and tracked separately.

Refs #480

run_watch was the only long-lived spawn in nub that called Command::status()
directly. Every other one — spawn.rs:672, spawn.rs:1182, cli.rs:4647 — goes
through a process-group + reaper path. That single gap is why orphan sweeps on
the dev host found 99 stranded 'node --watch' processes, all PPID=1, the oldest
17 hours, and zero stranded rustc or cargo.

It matters most precisely here: node --watch is a supervisor that by design never
exits, and it spawns a watched grandchild of its own. With no group and no reaper,
the death of the nub leader — a killed agent session, a test harness that gives
up, a closed terminal — reparents both to launchd, where they run forever holding
an fsevents watch on a source tree. They accumulate monotonically, which is why
they had to be cleared by hand.

Both spawn sites now use status_forwarding_signals(), the existing helper
documented as the signal-faithful, subtree-reaching equivalent of status():
own process group, the #480 SIGKILL-on-leader reaper held across the wait, and
terminating signals forwarded to the whole subtree.

The regression test kills the nub LEADER, not the group, and that distinction is
the test. A first version killed the group and passed against unfixed code — on
the unfixed path the watched node inherits nub's group, so a group signal reaches
it directly and no reaper is exercised. Verified both directions: leader-kill
fails without the fix, passes with it.
Copilot AI review requested due to automatic review settings July 29, 2026 18:09
@vercel

vercel Bot commented Jul 29, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
nub Ready Ready Preview, Comment Jul 29, 2026 6:12pm

Request Review

@pullfrog

pullfrog Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Pullfrog  | View workflow run | via Pullfrog | Using Claude Opus𝕏

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.

@colinhacks
colinhacks merged commit ac40929 into main Jul 29, 2026
53 checks passed
colinhacks added a commit that referenced this pull request Jul 29, 2026
Two conflicts, both in vendor/aube/crates/aube-linker/, both the same
collision: this branch retyped `disk_materialize` from HashSet<String>
to PackageNameMatcher so `install.linker.eject` can take glob patterns,
while #616 added a sibling `reusable_hoisted: HashSet<String>` to let
the hoisted pass skip unchanged packages. Kept both — the matcher type
and main's new field with its rationale comment.

Verified rather than inspected, since a clean auto-merge into
non-compiling code has bitten this branch twice: cargo check across the
workspace with --all-targets --all-features passes, and main's watch-
supervisor fix (#620) survives at both status_forwarding_signals call
sites in cli.rs with no bare status() left in run_watch.
@colinhacks

Copy link
Copy Markdown
Contributor Author

Shipped in v0.7.0: https://github.com/nubjs/nub/releases/tag/v0.7.0

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