Skip to content

fix(temp): managed temp-directory lifecycle with signal-safe reaping - #30

Merged
frankekn merged 4 commits into
mainfrom
fix/temp-dir-lifecycle
Jul 18, 2026
Merged

fix(temp): managed temp-directory lifecycle with signal-safe reaping#30
frankekn merged 4 commits into
mainfrom
fix/temp-dir-lifecycle

Conversation

@frankekn

Copy link
Copy Markdown
Owner

Why

Needlefish leaked its invocation temp directories on this machine until /tmp (a 4G tmpfs, so RAM) hit 86%. Root cause, proven and reproduced end to end: runCodex cleaned up in a finally, and Node does not run finally on SIGTERM/SIGINT at default disposition. Every signalled invocation stranded its whole sandbox.

The leak surfaced here because needlefish protects its own runner (.env TMPDIR + a review.yml preflight) but is invoked by other repos' runners, which had neither.

What changed

  • createManagedTempDirectory / disposeManagedTempDirectory replace the ad-hoc mkdtempSync + finally in codex.ts.
  • A process-level termination coordinator: blocks new scheduling, signals runner groups, bounded grace, force-kill, then exit — instead of relying on a finally that never runs.
  • Ownership is kernel-authoritative. A directory is staged under an excluded prefix, flocked, stamped with pid + /proc/<pid>/stat start time + boot ID, then atomically renamed into the managed namespace. A sweeper that can take the lock knows the owner is gone. PID reuse cannot fool it.
  • Startup sweep reaps orphans left by previous crashes, serialised by its own lock.
  • Portable: the coordinator and both registries work on macOS and Windows; only the flock/proc sweep is Linux-gated. Windows taskkill sends a graceful close before escalating to /F.

Review history

This branch was reviewed adversarially rather than diff-read. That mattered:

  • Round 1 shipped a change that broke the existing credential-leak regression test. Caught by running the suite, not by reading.
  • Round 2 (portability) passed review and mutation testing.
  • Round 3 — an independent adversarial pass found nine defects while the suite was green at 504/504, including two that could destroy data: shutdown could SIGKILL a recycled, unrelated process group, and the sweep could recursively delete any needlefish-shaped directory with no ownership marker required.
  • Round 4 fixed all nine. One fix did not survive verification — the orphan-lock reaper aborted silently whenever any unattributable directory was present, which is exactly the crash-leftover state it exists to handle — and was sent back and fixed.

The single most important fix is the last one on the list below: the credential-leak security test could pass vacuously. It redirected TMPDIR, but production resolves NEEDLEFISH_TMPDIR first. On every runner that exports that variable, the test inspected an empty directory. A real credential leak would have shipped green.

Verification

pnpm test 516/516, pnpm check, pnpm lint clean.

Every fix was mutation-tested independently of the implementer — each defect was reintroduced and the suite confirmed to go red:

reintroduced defect result
unregister refuses during termination 3 tests red
legacy reaping unconditional again 1 test red
production ignores NEEDLEFISH_TMPDIR (test/prod divergence) credential tests red
real credential leak, under adversarial ambient env 6 tests red
orphan-lock reaper aborts on unattributable dir 1 test red
non-Linux registration no-op restored 3 tests red
portable shutdown deletes unconfirmed trees 1 test red

Deploy note

Merging is not sufficient — the runners currently run release 53e4ed7b. This needs a redeploy to take effect. Machine-side config (per-runner TMPDIR / NEEDLEFISH_TMPDIR on disk-backed storage, plus a workflow preflight in the calling repo) is already in place and is what is holding /tmp at 14%.

🤖 Generated with Claude Code

frankekn and others added 4 commits July 18, 2026 21:06
…sted]

Node does not unwind the stack on SIGTERM/SIGINT/SIGKILL at default
disposition, so the finally in runCodexOnce never ran on a killed
process and stranded a full runner-repo clone (105-155MB each).

Add a termination coordinator plus a flock-backed ownership registry:
directories are created under an excluded staging name, locked, stamped
with PID / /proc start time / boot ID, then atomically renamed into the
managed namespace. A startup sweeper takes a nonblocking lock to
distinguish live from orphaned and quarantines before deleting.

On signal, disposal is deliberately skipped and the ownership lock is
held until process death, so a live runner tree is never deleted out
from under a detached process group; the next startup reaps it.

Resolve the temp root from NEEDLEFISH_TMPDIR in code rather than relying
on ambient TMPDIR, which is why CI runners lacking it wrote into a 4GB
tmpfs.

Linux-gated; macOS/Windows fall back to the previous behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The coordinator, runner-group registry and temp-directory registry were
gated behind isLinux(), so on macOS and Windows a SIGINT/SIGTERM left
runner process groups and temp trees behind. Only the flock/proc-based
startup sweep genuinely needs Linux; it stays gated.

Portable shutdown blocks new scheduling, signals registered groups,
waits the grace interval, force-kills, then waits for each child's real
close event before deleting only the directories whose runners were
confirmed closed. A live-but-unconfirmed runner preserves its tree
rather than having the filesystem pulled out from under it.

Windows taskkill now sends a graceful close before escalating to /F.

Tests drive the portable path with process.platform overridden to
darwin and PATH sabotaged, so a regression back to the Linux branch
fails rather than silently passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An independent review of the temp-lifecycle change found nine defects
that the (green) test suite did not catch. Each fix below ships with a
regression test that fails without it.

Two could destroy data or unrelated processes:

- Shutdown could SIGKILL a recycled process group. The unregister
  closure refused to remove entries once termination began, so runners
  that closed during the grace window left stale PIDs behind, and all
  three kill paths signalled those PIDs as process groups. Signalling
  now goes through the live ChildProcess handle and bails once the child
  is reaped; unregistering always removes; the last runner closing wakes
  the wait instead of sleeping out the timer.

- The startup sweep recursively deleted any needlefish-shaped directory
  older than seven days with no ownership marker required. Since
  NEEDLEFISH_TMPDIR is user-settable, pointing it at a populated
  directory destroyed data we never created. Markerless legacy reaping
  is now opt-in via NEEDLEFISH_REAP_LEGACY_TMPDIRS; quarantine removal
  requires valid ownership metadata, a dead owner, an unlocked owner
  lock and an age grace.

The rest:

- A dead lock holder permanently poisoned every later allocation; it is
  now evicted and reacquired, with caches scoped per resolved temp root.
- A holder that died after writing its readiness file left a stale
  marker the poll could observe before the exit callback, letting two
  sweepers run unlocked. Readiness now verifies the target is actually
  locked and rechecks holder state on both sides of the probe.
- Malformed owner metadata threw out of the startup sweep, which every
  command awaits, so one truncated JSON file disabled the whole CLI. It
  now reads as "unowned", consistent with every other validation failure
  in that function.
- Termination cancellation surfaced as DEEP PASS FAILED and fed the
  residual-risk path as a model failure. RunnerTerminatingError is now
  typed and recognised by isRunnerSafetyError.
- The credential-leak regression test redirected TMPDIR while production
  resolves NEEDLEFISH_TMPDIR first, so on every runner that exports it
  the test inspected an empty directory and would have shipped a real
  leak green. The tests now control the variable production reads and
  assert the inspected root actually holds the allocation.
- Owner lock files accumulated forever; dead, unlocked, unreferenced
  ones are now collected, and one unattributable directory no longer
  aborts the whole pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@github-actions github-actions 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.

LGTM ✅ — Introduces managed per-invocation temp directories, orphan reaping, and signal-coordinated runner shutdown.

Coverage: 7/7 changed files deep-reviewed across 1 hotspot

Findings

No actionable findings. Prefer this over padding weak ones.

Checked (1)
  • [Runner temp lifecycle, cleanup, and termination coordination] No actionable correctness, lifecycle, or safety defect found in this surface.

3 calls · map 47s → deep:Runner temp lifecycle, cleanup, and termination coordination 2m 26s → critic 8s · total 3m 22s

@github-actions github-actions Bot added the needlefish:pass Needlefish review verdict label Jul 18, 2026
@frankekn
frankekn merged commit fab7966 into main Jul 18, 2026
2 checks passed

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 542b6852eb

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".


function killRunnerProcessTree(pid: number | undefined, signal: NodeJS.Signals): void {
function signalRunnerProcessTree(child: ChildProcessWithoutNullStreams, signal: NodeJS.Signals): void {
const pid = child.pid;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Do not skip live descendants after the leader exits

When a runner spawns a same-process-group helper that inherits stdout/stderr and then the runner process exits, Node can have child.exitCode set while close is still pending because the helper keeps the pipes open. This new guard makes timeout or termination skip process.kill(-pid, ...), even though that process group may still contain the helper, so the review can return after give-up with a live runner descendant holding the sandbox/temp tree. The previous process-group kill could still clean up this case; only skip once the group is actually gone.

Useful? React with 👍 / 👎.

Comment thread src/cli.ts
return;
}

await initializeTempLifecycle();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Defer temp initialization until after local diff selection

When NEEDLEFISH_TMPDIR points inside the target repo, including a relative value like tmp, this call creates lifecycle artifacts such as .needlefish-sweep.lock before local mode checks git status. A clean branch review is then classified as dirty by diffBundle and can switch to uncommitted mode, where the empty lifecycle file is skipped and the real branch diff is not reviewed. Initialize after the local bundle is built or ensure lifecycle artifacts cannot be created under the target repo.

Useful? React with 👍 / 👎.

Comment on lines +288 to +289
if (activeRunnerProcessGroups.size === 0) {
terminateImmediately(signal);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Clean registered temps before immediate portable exit

On non-Linux platforms there is no flock/proc reaper, but a SIGINT/SIGTERM that arrives after createManagedTempDirectory() and before the runner is registered still takes this immediate-exit path because activeRunnerProcessGroups is empty. That skips disposeManagedTempDirectory() and leaves the active temp tree behind; if the signal lands after prepareEphemeralHome(), copied runner credentials remain on disk. The immediate path should remove safe registered temp directories on portable platforms before exiting.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needlefish:pass Needlefish review verdict

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant