fix(runtime-host): ephemeral candidate never exits after retainUntilProcessExit disables the initial connection timeout
#2,943 opened on Aug 13, 2026
Repository metrics
- Stars
- (1 star)
- PR merge metrics
- (PR metrics pending)
Description
Summary
Two execution-candidate-main.js processes survived for 26 hours after their parent exited, each burning 70–82% CPU the entire time, despite being started in ephemeral mode with --initial-connection-timeout-ms set to 1000 and 10000 respectively.
Code reading points at retainUntilProcessExit() permanently disabling the initial-connection timeout, with no fallback deadline to force the exit. See the hypothesis below — it is not runtime-verified.
Observed
Captured with ps -Ao pid,ppid,pcpu,etime,command -r on macOS 15 (Darwin 25.5.0), Node 24.18.1:
PID PPID %CPU ELAPSED COMMAND
89292 1 69.7 01-01:58:38 node .../runtime-host/dist/execution-candidate-main.js \
--root /private/tmp/maka-review-connect-lock.sfKdJq \
--expected-root-id 70ba08d6... \
--initial-connection-timeout-ms 1000
89559 1 66.9 01-01:58:24 node .../runtime-host/dist/execution-candidate-main.js \
--root /private/tmp/maka-review-connect-abort.aPwNIo \
--expected-root-id ce951ea2... \
--initial-connection-timeout-ms 10000
Facts:
PPID = 1— the launching process was long gone; both had been reparented to launchd.- Started 14 seconds apart, so they came from one batch of runs.
- No client ever connected (the roots were throwaway temp dirs).
- Both survived roughly 94,000× their configured initial connection timeout.
- A third root from the same batch (
maka-review-connect-readonly.*) had no surviving process, so the leak is not universal across that batch.
Expected: with lifecycleMode: 'ephemeral' and no connection ever accepted, the candidate should shut down after initialConnectionTimeoutMs.
Hypothesis (code reading only — not verified at runtime)
In packages/runtime-host/src/server/host-kernel.ts:
#retainUntilProcessExit(): void {
if (this.#retainedUntilProcessExit) return;
this.#retainedUntilProcessExit = true;
this.#residencies.acquire('process-retention'); // never released
this.#cancelIdle();
}
The process-retention residency is acquired and never released. #isTrueIdle() gates on total residency count:
#isTrueIdle(): boolean {
return (
this.#state === 'ready' &&
this.#acceptedTransports.size === 0 &&
this.#activeOperations === 0 &&
this.#residencies.activeCount === 0 // <-- process-retention keeps this non-zero forever
);
}
So once #retainUntilProcessExit() runs, #scheduleIdleIfNeeded() can never arm the timer again and initialConnectionTimeoutMs becomes permanently inert.
This looks like an oversight rather than intent, because the neighbouring #hasNonRetentionWork() deliberately excludes exactly this label:
return this.#residencies.snapshot().some(({ label }) => label !== 'process-retention');
Two treatments of the same label, one filtering it out and one not.
The only callers are the onPoison handlers in packages/runtime-host/src/server/execution-composition.ts (lines 576 and 954), both of which pair retention with a drain request:
onPoison: (error) => {
if (poisonFailure) return;
poisonFailure = error;
context.retainUntilProcessExit();
beginDrain();
context.requestDrain();
},
But #commitRequestedShutdownIfQuiescent() returns early unless activeCommandOperations === 0, and #armShutdownDeadline() is only called from inside #commitShutdown(). If poisoning happens while a command operation is still in flight, the process never becomes quiescent, commitShutdown never runs, the shutdown deadline is therefore never armed, and nothing else can terminate the process.
What this does not explain
The hypothesis accounts for never exiting. It does not account for the sustained 70–82% CPU. An idle process waiting on a residency should sit at ~0%. Something is spinning, and I did not find it. Unfortunately I killed both processes before capturing a stack sample, so I have no profiler evidence to offer.
Reproduction
I do not have a reliable repro. The temp-dir prefix maka-review-connect-* does not appear anywhere in the repository, so these were launched by an external script calling execution-candidate-main.js directly, not by an in-repo test. Anyone reproducing this would need to drive an ephemeral candidate into the onPoison path while a command operation is in flight, then confirm the process outlives its initial connection timeout.
Suggested directions
- Exclude
process-retentionfrom#isTrueIdle(), consistent with#hasNonRetentionWork(), so the initial-connection timeout keeps working after retention. - Arm the shutdown deadline when shutdown is requested, not only when it is committed, so a non-quiescent host still has a hard bound on its lifetime.
- If a candidate really should be retained for post-mortem after poisoning, cap that retention with an explicit timeout rather than making it unbounded.
Happy to test a patch against the same setup.
Environment: macOS 15 (Darwin 25.5.0, arm64), Node 24.18.1 (homebrew node@24), built from worktree at packages/runtime-host/dist/.