Skip to content

Kernels exit when their owner dies - #1559

Merged
snimu merged 16 commits into
mainfrom
eng-5310
Aug 20, 2026
Merged

Kernels exit when their owner dies#1559
snimu merged 16 commits into
mainfrom
eng-5310

Conversation

@snimu

@snimu snimu commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Hundreds of leaked ipykernel processes (and ~1,900 stale prime-agent-kernel-* temp dirs) were draining batteries: every graceful shutdown path is clean, but when the owning process hard-dies (SIGKILL, crash, OOM — including root TUI sessions and killed test runners) the kernel's dispose never runs and it lives forever.

What changes:

  • Kernels get JPY_PARENT_PID in their environment, arming ipykernel's built-in parent poller: direct spawns watch the Node owner; forked kernels watch their real parent, the forkserver (ipykernel's Unix poller distrusts a parent_handle that differs from getppid() and would fall back to a pid-1 reparent watch that subreapers never trigger — so the forked child passes the forkserver pid explicitly after the per-kernel env is applied, the trait default being frozen at template import).
  • The forkserver script gains a daemon watchdog thread that polls os.getppid() and exits when its parent (the worker) disappears. A dead forkserver reparents its kernels, which trips their pollers — closing the chain on every platform, subreaper or not.
  • Kernel and forkserver pids are registered in the orphan process journal on start and deregistered on confirmed teardown, so the supervisor's existing recovery sweep kills any survivors with verified process identity.

Follow-up (review feedback on pid-reuse): forked kernels are no longer signaled by bare pid from Node — after pid reuse that could kill an innocent process and write a wrong inactive journal record masking a sibling manager's active one. The forkserver (their parent, which waitpid-reaps them) now owns kill and liveness through new additive control-protocol messages, keyed by the fork request id rather than the pid: ids are unique and never reused, so a kill/alive can only ever act on the caller's own child incarnation, even if that child's pid was recycled by a later fork. The forkserver keeps a bounded id → (pid, alive) registry (FIFO, 4096 entries; evicted ids fail closed as unknown-pid), reaps children under a SIGCHLD discipline that blocks the signal in every thread but the main one and across both fork bookkeeping and the check+kill section (a parent signaling its own un-reaped child is POSIX-race-free; the forked child unblocks the inherited mask before running the kernel). The inactive orphan-journal write is restricted to the signaled outcome — the only one proving the pid still named our child at kill time; already-exited, unknown-pid, and errors leave the active record stale for the identity-verifying supervisor reaper, since the journal itself is pid-keyed and a wrong inactive write could mask a sibling manager's record.

Verified:

  • Unit + protocol tests (macOS + Linux): direct/fork env carries the right parent pid; protocol kill round-trip, already-exited, unknown-fork-id never signals, liveness from the registry, kill outcomes truthful under concurrent child churn/reaping, a fast-exiting child never lands alive in the registry, evicting a still-alive entry stays fail-closed and reap-safe, dead-forkserver fallback never signals, journal gating (signaled → inactive; everything else → stale-active).
  • Linux sandbox E2E (Ubuntu 24.04 VM, real prime-agent instance): forkserver path confirmed as Linux default; kill -9 of a real daemon worker took forkserver and kernel down in 2s; full feature smoke (persistent kernel state, %%bash, rlm subagent + agent messaging, direct-spawn path) clean with zero stray processes after daemon shutdown.
  • Pid-reuse stress on that sandbox (kernel.pid_max=400, canary process recycled onto the dead kernel's pid before dispose): old code wrongly killed the canary 8/8 iterations; fixed code 0/8 with 8/8 recycles — the race reproduces deterministically and the fix eliminates it.
  • Full suite on Linux (CI shape): failing-file set identical to the branch-point baseline (7 pre-existing env-specific files, 59 tests); branch adds 10 passing tests, zero regressions. npm run check clean.

Out of scope (ENG-5311, parallel PR): reaping pre-existing orphans and sweeping stale temp dirs via doctor.

LOC: +866 / -74 total (src +271/-74, tests +593/-0, changelog +2).

Fixes ENG-5310 (https://linear.app/primeintellect/issue/ENG-5310/kernels-must-not-outlive-their-owner-parent-death-watchdog-orphan)

Note

Make IPython kernels and forkserver exit when their owner dies

  • Direct ipykernel spawns now set JPY_PARENT_PID so the kernel exits if the owning process dies. Forked kernels delegate kill and liveness queries through the forkserver, which also watches its own parent and terminates on owner death.
  • ForkServer refactored to a request/reply protocol returning ForkedKernelHandle (with kill() and isAlive()) instead of a raw pid. This avoids pid-reuse races by routing all signaling and liveness through the forkserver with explicit outcomes.
  • KernelManager adds generation-based guards (startGeneration, startStale()) to fence off stale concurrent start/shutdown interactions, and bounds liveness probes by startup budgets so hangs don't extend timeouts.
  • Orphan-process journal records are written for child and forkserver pids on spawn, and marked inactive only on confirmed kills or observed exits. See index.ts and fork-server.ts.
  • Also updates pricing constants for deepseek/deepseek-v4-flash and moonshotai/kimi-k2.6 in models.generated.ts.
  • Behavioral Change: forkKernel now returns ForkedKernelHandle instead of a numeric pid; dispose() writes orphan journal inactive records only on confirmed kill or observed exit, leaving records active otherwise.

Changes since #1559 opened

  • Changed the eviction policy in the _serve server loop to retain live child kernels beyond the historyBound by selectively evicting only entries with alive flag set to false, iterating through tracked children and removing exited entries until within the bound, eliminating removal of live entries and their pid-to-id routing mappings [967b2e9]
  • Replaced test expecting eviction of alive entries with test asserting live entries are retained past historyBound and only exited entries are evicted [967b2e9]
  • Updated pricing metadata for deepseek/deepseek-v4-flash model [14288cf]
  • Updated pricing metadata for moonshotai/kimi-k2.6 model [14288cf]
  • Modified liveness probe handling for forked kernels to treat timed-out fork server requests as unknown state rather than kernel death, and added serialization to prevent concurrent liveness probes [10aef5c]
  • Added timeout indication capability to ForkServerUnavailable error class and configured fork server request timeouts to use it [10aef5c]
  • Changed direct-spawn kernel cleanup to mark orphan process state as inactive only when a kill signal was successfully delivered [10aef5c]
  • Added and updated tests to validate timeout handling in liveness probes, concurrent probe prevention, and direct-spawn cleanup behavior [10aef5c]
  • Modified KernelManager.shutdown method to track cleanup execution with a boolean flag and determine ownership before performing cleanup operations [940bc35]
  • Updated test assertions in kernel-parent-watchdog.test.ts and kernel-shutdown.test.ts to verify the new shutdown return value contract [940bc35]

Macroscope summarized 3393591.


Note

High Risk
Changes how IPython kernels and the forkserver are signaled, reaped, and journaled for supervisor recovery. A bug here can leak processes or, on pid reuse, kill an unrelated process.

Overview
Stops leaked IPython kernels after a hard crash (SIGKILL/OOM) and closes a pid-reuse race on forked kernels.

Direct spawns set JPY_PARENT_PID so ipykernel’s parent poller exits with Node. The forkserver polls getppid() and dies with the worker; forked children watch the forkserver via parent_handle. Kernel and forkserver pids are journaled as orphans on spawn; inactive is written only on a confirmed kill or observed exit so a reused pid cannot mask a sibling’s record.

forkKernel now returns a ForkedKernelHandle. Kill and liveness are keyed by never-reused fork request ids (not raw process.kill), with SIGCHLD-blocked check+kill and a bounded registry that evicts only exited entries. KernelManager uses startGeneration so concurrent start/shutdown cannot tear down a successor kernel.

Reviewed by Cursor Bugbot for commit 10aef5c. Bugbot is set up for automated code reviews on this repo. Configure here.

Set JPY_PARENT_PID so ipykernel's parent poller exits kernels when the
owning process hard-dies (SIGKILL/crash/OOM), add a parent-death
watchdog thread to the forkserver script, and register kernel and
forkserver pids in the orphan process journal so supervisor recovery
can reap them.

fixes ENG-5310
Comment thread packages/coding-agent/src/core/kernel/fork-server-script.ts
ipykernel's Unix poller distrusts a parent_handle that differs from
getppid() at startup and falls back to watching for pid-1 reparenting,
which subreapers (systemd --user) never trigger. Forked children now pass
their real parent (the forkserver) whose own watchdog ties it to the
worker, so the death chain holds on every platform. The fork-request env
no longer carries an intentionally-ignored JPY_PARENT_PID.
Comment thread packages/coding-agent/src/core/kernel/index.ts Outdated
…rotocol

Forked kernels were signaled by bare pid from Node (process.kill), which
can hit a reused pid and write a wrong inactive journal record that masks
a sibling manager's active one. The forkserver is the kernels' parent and
waitpid-reaps them, so it now owns kill and liveness: new id-keyed protocol
messages let KernelManager kill/poll through a ForkedKernelHandle, the
Python side only signals a pid found in its un-reaped-children table while
SIGCHLD delivery is excluded (blocked in all threads, handled only by the
main thread outside the check+kill section), and the inactive journal write
is gated on a confirmed outcome — uncertainty leaves the active record for
the supervisor reaper, which verifies process identity before acting.

fixes ENG-5310
Comment thread packages/coding-agent/src/core/kernel/fork-server-script.ts Outdated
Comment thread packages/coding-agent/src/core/kernel/fork-server-script.ts
Comment thread packages/coding-agent/src/core/kernel/fork-server-script.ts Outdated
Comment thread packages/coding-agent/src/core/kernel/index.ts Outdated
snimu added 2 commits August 19, 2026 20:37
Raw-pid keying could alias across forkserver children: a reaped pid
reused by a later fork made kill/alive act on a sibling manager's
kernel. Fork request ids are unique and never reused, so the forkserver
now keeps a bounded id -> (pid, alive) registry (FIFO, 4096) and
kill/alive by id can only ever act on the caller's own incarnation;
evicted ids fail closed. Fork bookkeeping now runs with SIGCHLD blocked
so a fast-exiting child can't be reaped before registration (the forked
child unblocks the inherited mask before running the kernel), and the
inactive orphan-journal write is restricted to the 'signaled' outcome —
the only one that proves the pid still named our child at kill time.

fixes ENG-5310
Comment thread packages/coding-agent/src/core/kernel/index.ts Outdated
Comment thread packages/coding-agent/src/core/kernel/index.ts Outdated
Comment thread packages/coding-agent/src/core/kernel/fork-server.ts Outdated
…ound journal writes

A stale in-flight doStart (superseded by a public restart) could resume
and tear down or corrupt the successor kernel; a hung forkserver could
stretch startup failure past the 5s budgets via the 10s protocol
timeout; and the forkserver's inactive journal write was unconditional.
Starts now own a generation token bumped by every teardown: stale
resumes and stale failure catches bail without side effects, shutdown
and dispose skip cleanup when superseded mid-await, and liveness probes
during startup are bounded by the remaining budget (timeout counts as
alive so the loop deadline owns failure). The forkserver journal write
now requires an observed exit or confirmed handle-based delivery.

fixes ENG-5310
Comment thread packages/coding-agent/src/core/kernel/index.ts
Comment thread packages/coding-agent/src/core/kernel/index.ts
snimu added 2 commits August 19, 2026 23:59
shutdown() now reports whether it performed the cleanup; start recovery
resurrects to idle only as the owning cleanup, so a kill() racing the
recovery can no longer be undone. Replaces the generation+1 idiom and the
remaining inline staleness comparisons with the one startStale predicate.
# Conflicts:
#	packages/coding-agent/CHANGELOG.md
@snimu
snimu requested a review from alexzhang13 August 20, 2026 09:27
# Conflicts:
#	packages/coding-agent/CHANGELOG.md
Comment thread packages/coding-agent/src/core/kernel/fork-server-script.ts
Comment thread packages/coding-agent/src/core/kernel/fork-server.ts
snimu added 3 commits August 20, 2026 15:21
# Conflicts:
#	packages/coding-agent/CHANGELOG.md
#	packages/coding-agent/src/core/kernel/index.ts
FIFO eviction bounded total forks, not dead entries, so the 4097th fork on
one forkserver dropped the oldest still-running kernel: its liveness read
false (tearing down a healthy kernel) and its kill was unroutable — the
exact orphan leak this change prevents. Eviction now sweeps exited entries
only; live entries are bounded by real concurrent kernels.
# Conflicts:
#	packages/coding-agent/CHANGELOG.md
Comment thread packages/ai/src/models.generated.ts Outdated
An earlier merge-with-main resolved the generated model catalog as ours,
reverting newer pricing/context data; the watchdog PR must not touch it.
alexzhang13
alexzhang13 previously approved these changes Aug 20, 2026

@cursor cursor 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.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 3393591. Configure here.

Comment thread packages/coding-agent/src/core/kernel/index.ts
Comment thread packages/coding-agent/src/core/kernel/index.ts Outdated
…e journal writes on a delivered signal

A forkserver stalled in a slow fork rejects isAlive with a request
timeout; the liveness monitor took any rejection as death and tore down
healthy kernels. ForkServerUnavailable now carries a timedOut flag and
the monitor treats a timed-out probe as unknown (alive), with an
in-flight latch so 1s polls cannot pile up behind a stalled probe.
Proven unavailability (socket death) still counts as dead.

Direct-spawn cleanup wrote an inactive journal record even for a child
that had long exited, which can mask a sibling manager's active record
for a reused pid; it now writes inactive only when kill() delivered a
signal, matching the forked branch's rule. Also retargets the hung-probe
startup test to the probe budget itself (main's cold-boot change raised
the ports budget to 30s, past the test's wall-clock bound).
Comment thread packages/coding-agent/src/core/kernel/index.ts
…cleanup generation check

cleanupResources bumps startGeneration, so the final startStale check read
every non-superseded shutdown as superseded and returned false; startup-failure
recovery then never resurrected the manager to idle, leaving it bricked in
shutdown after a failed ports resolve. Ownership is now captured where the
cleanup decision is made. The superseded-shutdown watchdog test was passing
because of this bug (its parked send short-circuited via waitForKernelExit on
a missing kernel handle); it now parks genuinely and still pins false.
@alexzhang13
alexzhang13 self-requested a review August 20, 2026 18:24
@snimu
snimu merged commit addfc23 into main Aug 20, 2026
22 checks passed
@snimu
snimu deleted the eng-5310 branch August 20, 2026 18:27
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