Repository navigation
fix(runtime): helper-lifecycle follow-ups (#666, #667, #668) and a detached-reap regression #669
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 1 commit
Commits
Show all changes
18 commits
Select commit
Hold shift + click to select a range
52d92c1
fix(codex): stop runtime helpers from leaking past their idle timeout
possibilities f5bf873
fix(codex): address CodeRabbit round 1 on the helper-leak fix
possibilities 1590cd1
test(codex): pin the sweep-retry test to the four-attempt budget, ord…
possibilities 6bb2049
fix(codex): reap app helpers stranded by the detach grace
possibilities c18a5df
fix(codex): degrade an unreadable connection count to zero
possibilities c03af14
fix(codex): correct the connection-count comment and cover the reap o…
possibilities e57da41
fix(codex): accept only a positive socket count as evidence of a cons…
possibilities 16a335f
test: add owned-PID probes for helper-lifecycle fixtures (#668)
ndycode bf0a749
fix(codex): reap only helpers that were never handed to a consumer
ndycode 040e0c1
fix(runtime): stop unbind from orphaning helper owner files (#666)
ndycode 7c10722
refactor(runtime): one identity-checked helper selector for both read…
ndycode 109db04
docs: correct the runtime app helper status path in the README
ndycode 51c5ca4
refactor(runtime): narrow the helper PID instead of casting it
ndycode c42bf54
fix(runtime): address the review on the helper-lifecycle follow-ups
ndycode e087c43
test: stress the helper lifecycle at the scale the leak report described
ndycode 8f0b578
test: fix the review round on the stress suite
ndycode 6b9f327
test: make the spawn-failure coverage exercise the helpers, not node
ndycode 02e0c9b
test: cover the failed-spawn branch in withLivePid
ndycode File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
test: fix the review round on the stress suite
All five findings are against the stress tests added in the previous commit, and three of them are defects in the tests rather than nits. The serious one: the launch-cycle test passed if no helper was ever created. Every assertion in it was an upper bound, so a launch path that silently stopped publishing metadata — a wrong env gate, a proxy fixture that never engaged, `app .` short-circuiting on the fake bin — left every count at zero and the test green, while proving nothing about the accumulation it exists to guard. That is the same defect class as the unexercised selector branch this PR fixes for #668, in the test written to guard against it. It now probes first: one launch with a long idle window, asserting helper metadata actually appears, and that probe helper is torn down before the measurement loop starts. The loop additionally records whether it ever observed a helper, and asserts it did. Verified by mutation: stubbing out both metadata writers in the wrapper now fails the test instead of passing it. The rest: - `waitForExit` waited only on `exit`. A child that never spawns emits `error` and never `exit`, so the promise stayed pending forever — and since the batched helpers await a whole batch concurrently, one failed spawn stalled every sibling and hung the run rather than failing it. It now settles on either event, and the batch is reaped before the PIDs are validated so a throw cannot leak the siblings that did start. - `countHelperMetadata` swallowed every readdir error and reported zero. Only ENOENT is a legitimate zero; anything else — a permissions change, a path that is not a directory — was being reported as a clean sweep, which the upper-bound assertions accept happily. Narrowed to ENOENT, rethrowing the rest. - The reap matrix indexed `running[index]?.ready.pid ?? 0` and passed the fallback to `isProcessAlive`. On POSIX `kill(0, 0)` probes the caller's own process group and succeeds, so a missing helper would have read as alive — and four of the five cases expect alive. Zipped off `running` instead, with the length pinned. - The same test hardcoded `running[1]` as the reaped case and guarded the assertion with `if (reaped)`, so reordering `cases` would have asserted `owner-gone` against a helper meant to survive, and a missing entry would have skipped the only assertion proving *why* the helper died. Looked up by label and asserted unconditionally. The header comment said "four configurations" over a list of five. test/owned-pids-helper.test.ts covers the helpers themselves, including a direct assertion that a child which fails to spawn signals via `error` — the premise the hang fix rests on — with a timeout well inside vitest's so a regression reads as a failure rather than a stuck suite. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015b4Lew3oHNEYmTZtg7zEWz
- Loading branch information
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,108 @@ | ||
| import { spawn } from "node:child_process"; | ||
| import process from "node:process"; | ||
| import { describe, expect, it } from "vitest"; | ||
| import { withDeadPid, withDeadPids, withLivePid, withLivePids } from "./helpers/owned-pids.js"; | ||
|
|
||
| // The lifecycle fixtures depend on these helpers being facts rather than | ||
| // approximations, so the helpers themselves need coverage. The hang is the | ||
| // dangerous one: a helper that never resolves turns a test failure into a | ||
| // suite that sits there until the runner's timeout, with no useful output. | ||
|
|
||
| function isAlive(pid: number): boolean { | ||
| try { | ||
| process.kill(pid, 0); | ||
| return true; | ||
| } catch (error) { | ||
| const code = | ||
| error && typeof error === "object" && "code" in error ? error.code : null; | ||
| return code === "EPERM"; | ||
| } | ||
| } | ||
|
|
||
| describe("owned-pids", () => { | ||
| it("hands out a PID that is genuinely dead", async () => { | ||
| await withDeadPid((pid) => { | ||
| expect(Number.isInteger(pid)).toBe(true); | ||
| expect(pid).toBeGreaterThan(0); | ||
| expect(isAlive(pid)).toBe(false); | ||
| }); | ||
| }); | ||
|
|
||
| it("hands out a PID that is genuinely alive, and reaps it afterwards", async () => { | ||
| let captured = 0; | ||
| await withLivePid((pid) => { | ||
| captured = pid; | ||
| expect(isAlive(pid)).toBe(true); | ||
| }); | ||
| // Killed on the way out rather than left for the OS. | ||
| expect(isAlive(captured)).toBe(false); | ||
| }); | ||
|
|
||
| it("kills the live PID even when the body throws", async () => { | ||
| let captured = 0; | ||
| await expect( | ||
| withLivePid((pid) => { | ||
| captured = pid; | ||
| throw new Error("boom"); | ||
| }), | ||
| ).rejects.toThrow("boom"); | ||
| expect(isAlive(captured)).toBe(false); | ||
| }); | ||
|
|
||
| it("produces distinct PIDs in batches larger than one spawn round", async () => { | ||
| // The batch size is an implementation detail; asking for more than one | ||
| // batch is what proves the loop stitches them together rather than | ||
| // returning only the last batch. | ||
| const count = 40; | ||
| await withDeadPids(count, (pids) => { | ||
| expect(pids).toHaveLength(count); | ||
| expect(new Set(pids).size).toBe(count); | ||
| for (const pid of pids) { | ||
| expect(isAlive(pid)).toBe(false); | ||
| } | ||
| }); | ||
| }, 60_000); | ||
|
|
||
| it("keeps every PID in a live batch alive for the body and reaps them after", async () => { | ||
| let captured: number[] = []; | ||
| await withLivePids(5, (pids) => { | ||
| captured = [...pids]; | ||
| expect(new Set(pids).size).toBe(pids.length); | ||
| for (const pid of pids) { | ||
| expect(isAlive(pid)).toBe(true); | ||
| } | ||
| }); | ||
| for (const pid of captured) { | ||
| expect(isAlive(pid)).toBe(false); | ||
| } | ||
| }, 60_000); | ||
|
|
||
| it("settles rather than hanging when a child never spawns", async () => { | ||
| // A child that fails to spawn emits `error` and never `exit`. The batched | ||
| // helpers await a whole batch concurrently, so waiting on `exit` alone | ||
| // meant one failed spawn stalled every sibling and hung the run instead of | ||
| // failing it. This asserts the settle, with a timeout well under vitest's | ||
| // so a regression reads as a failure here rather than as a stuck suite. | ||
| const child = spawn( | ||
| "definitely-not-a-real-binary-2f8c1d", | ||
| ["--nope"], | ||
| { stdio: ["pipe", "ignore", "ignore"] }, | ||
| ); | ||
| const settled = await Promise.race([ | ||
| new Promise<string>((resolve) => { | ||
| let done = false; | ||
| const finish = (label: string) => () => { | ||
| if (done) return; | ||
| done = true; | ||
| resolve(label); | ||
| }; | ||
| child.once("exit", finish("exit")); | ||
| child.once("error", finish("error")); | ||
| }), | ||
| new Promise<string>((resolve) => setTimeout(() => resolve("timeout"), 5_000)), | ||
| ]); | ||
| // The premise of the fix: this child signals via `error`, not `exit`. | ||
| expect(settled).toBe("error"); | ||
| child.stdin?.destroy(); | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
| }, 30_000); | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
| }); | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.