Skip to content

test: give each launcher-spawning file its own TMPDIR - #347

Open
codeslake wants to merge 6 commits into
cnighswonger:mainfrom
codeslake:fix/test-tmpdir-isolation
Open

test: give each launcher-spawning file its own TMPDIR#347
codeslake wants to merge 6 commits into
cnighswonger:mainfrom
codeslake:fix/test-tmpdir-isolation

Conversation

@codeslake

@codeslake codeslake commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

The shared /tmp is not the launcher's

Eight test files spawn the launcher, and the launcher writes under its own os.tmpdir()cache-fix-proxy-<port>.sha256 and a scratch CA. On a box that is also running the product, that record is named after a port a live holder may be serving, and the suite's copies land beside the real ones.

Each spawning file now gets a private temp directory, set at module scope before its body runs:

import "./file-tmpdir.mjs";   // for side effect, first among its imports

The env var rather than a path threaded through each spawn: the launcher is a child and reads its own os.tmpdir(), so the redirection only reaches it by being inherited. node:test gives each file its own process, so one directory per file falls out of that.

Every key os.tmpdir() reads, not just this platform's

Measured from node's own os.tmpdir source, and confirmed empirically on 18 / 20 / 24:

platform read order
POSIX TMPDIRTMPTEMP/tmp
Windows TEMPTMPSystemRoot\temp

Setting only TMPDIR left a Windows run writing to the shared root — and the smoke case could not report it, because it spawned its probe with TMPDIR: outer and asserted outer was empty. On Windows outer is empty because os.tmpdir() never looked at it, not because anything was isolated. A green suite proving the wrong path.

All three keys are set now, and the probe spawn points all three at the inherited directory so the redirect is actually exercised whatever the platform reads.

Cleanup on exit, not after()

process.on("exit") runs after every hook, so it cannot delete the directory out from under a sweep that reaps ports once the cases are done, and it is armed before the importing file's body runs rather than at the end of it.

Verification

The case drives a real child and checks both markers — its own and its grandchild's — before asserting the inherited directory is empty, because an empty directory proves nothing if neither was ever written.

reverted dies
the redirect removed entirely the process left scratch in the tmpdir it inherited
TMPDIR alone, as it was still on the inherited root: TMP,TEMP
the cleanup hook removed the process left scratch in the tmpdir it inherited

Three for three, from one case. An earlier revision split this across two and the second was green against a helper that redirects nothing — it asserted the three keys agreed, and with all three pointed at the inherited directory they agree whether or not anything moved. Agreement was never the property; movement is.

8 files changed, +104. New files: 2 (test/file-tmpdir.mjs, test/tmp-isolation.test.mjs). New exports, env vars: 0 — TMPDIR/TMP/TEMP are set, not introduced.

Known, and not fixed here

  • CI runs this suite on Linux only (ubuntu-latest ×3; the one macOS job is path-filtered to a shim and never runs npm test). The Windows half is reasoned from node's source and a resolution-order simulation, not from a Windows run.
  • Four single-key TMPDIR overrides elsewhere in the suite stay Windows-blind (proxy-wrapper.test.mjs:1143, :1338, :1343; proxy-held-port.test.mjs:1994), and two launcher-spawning files do not import the helper (proxy-probe-bounded.test.mjs:86, install-service.test.mjs:830). Both measured leaving nothing behind today.

@codeslake
codeslake force-pushed the fix/test-tmpdir-isolation branch from d53d23d to cd40180 Compare August 20, 2026 08:34
The launcher writes cache-fix-proxy-<port>.sha256 under os.tmpdir() on every
spawn, and the tests spawn it with `{ ...process.env }`. So each run left those
records in the shared /tmp, and 2,145 had accumulated on this host.

Set once per file rather than at each spawn site, because the env is inherited —
this also covers sites added later, which is what went wrong the first time.
proxy-held-port already had a private TMPDIR at one of its eleven spawn sites,
with a comment explaining exactly why ("Asserting a global path can only ever
measure the machine's history"); the other ten never got it.

Measured with a private TMPDIR per run, before -> after:
  proxy-held-port        24 -> 0   (34/34)
  proxy-holder-handover  10 -> 0   (11/11)
  proxy-wrapper           7 -> 0   (45/45)
  stdio-epipe-survival    2 -> 0   (4/4)
  proxy-shutdown-once     1 -> 0   (3/3)
  proxy-server            0 -> 0   (32/32)  consistency, not a fix
The counts include the ccf-runsvc-, ccf-wild- and ccf-v6- directories these
files also left behind, not only the .sha256 records.

proxy-wrapper is a different family from the same cause: runWrapper forks the
wrapper with the inherited env and the launcher writes its scratch CA under
os.tmpdir(), so seven cache-fix-ca-scratch-* dirs survived each run for the seven
days its reaper waits. It goes through that file's own tempDir() registrar rather
than a raw mkdtempSync — the file has a meta-assertion requiring exactly that,
and it caught the first attempt.

In five of the six the cleanup hook is appended at EOF, so it cannot delete the
dir out from under a sweep that reaps ports after the cases: node runs root hooks
in registration order. proxy-wrapper is the exception — its removal rides the
file's existing tempDirs hook near the top, which is harmless there because that
is the file's only root hook, but the property does not hold uniformly.

Co-Authored-By: Claude <noreply@anthropic.com>
@codeslake

Copy link
Copy Markdown
Contributor Author

Field data from running this branch, in case it is useful for review.

Before (a tree without this PR or #345): three consecutive full-suite runs left 102 orphaned launcher fingerprint records in the shared temp directory — one per launcher the suite spawned, on ephemeral ports, all with no surviving process. They accumulate silently: nothing in the suite output mentions them, and each run adds to the pile left by the last.

After (a tree carrying this PR and #345): a full-suite run left zero records in the shared temp directory.

Same suite, same machine, same shell, back to back.

What this does and does not attribute. Giving each launcher-spawning file its own TMPDIR is the direct mechanism for the shared directory staying clean, so this observation is evidence for this PR specifically. It does not isolate #345's contribution — the two shipped together in the tree under test, and a per-file TMPDIR alone would produce the same zero here. I am not claiming a split I did not measure.

Why the shared directory matters beyond tidiness. runningOurCode() reads these records to decide whether an arriving launcher is looking at the same build. A stale record for a dead port is a wrong answer to that question, and the surrounding checks all stay green while it is wrong — the port answers, /health answers, and the deploy reports success. The suite writing into the shared directory means a developer's own host accumulates that ambiguity as a side effect of running tests.

Happy to re-run with any variation that would make the attribution sharper.

🤖 Generated with Claude Code

— Proxy Builder

The launcher publishes `cache-fix-proxy-<port>.sha256` and its scratch CA
under its own os.tmpdir(), so a test run wrote both into the shared /tmp.
On a box that also runs the product, that record is named after a port a
live holder may be serving.

Measured at the parent commit, one run of proxy-shutdown-once.test.mjs into
a private TMPDIR: one cache-fix-proxy-<port>.sha256 left behind. After this
change the same run leaves the directory empty, and a full suite run leaves
no cache-fix-* entry at all.

The mechanism is an env var because the launcher is a child process and
reads its OWN os.tmpdir(). node:test gives each file its own process, so one
directory per file falls out of that, and later spawn sites are covered
without being edited.

Cleanup hangs off `exit` rather than an after() hook: it runs after the port
sweeps, so there is no hook-registration order to get right, and it is armed
before the importing file's body instead of at the end of it. Measured, a
module-level failure between the mkdtemp and an after() registration leaves
the directory behind; against this module the same failure leaves nothing.

test/tmp-isolation.test.mjs measures the property through a real process
boundary rather than reading the source: the probe and a grandchild both
write markers and check them, and the tmpdir the probe inherited must be
empty once it exits. Red both ways - dropping the assignment leaves the
markers there, dropping the cleanup leaves the private dir there.

Co-Authored-By: Claude <noreply@anthropic.com>
@codeslake

Copy link
Copy Markdown
Contributor Author

test (20) is red on this branch and the cause is not this PR. test (18) and
test (22) pass on the same commit. Measurements below; the last section is a
gap in a component this PR does not touch, reported because it is what stops
anyone proving the cause.

The failing case

not ok - holder handover (SIGUSR2)
  not ok - carries the address when the holder and its child are both killed
           and no hop is configured

It is not the TMPDIR isolation

Three arms, node 20, the exact case, whole file. Round 2 interleaved the arms
per cycle so ambient load fell equally on each, pinned to 2 cores:

arm tree quiet interleaved total
A this branch 10/10 5/5 15/15
B the PR's original inline shape 10/10 5/5 15/15
C main, no isolation at all 10/10 5/5 15/15

45/45 green, so there is no rate difference to attribute. Green runs alone
would not settle it, so three structural discriminators:

  • There is no reader to break. bin/gap-relay.mjs — the process whose
    survival the assertion measures — imports exactly node:net and node:tls,
    and contains zero occurrences of tmpdir, TMPDIR or any file read across
    its 468 lines. It is spawned detached: true with every stream ignored. It
    has no temp path to disagree about.
  • Selectivity. Rows 5 and 6 are one it body in a for loop, in one
    process, under one TMPDIR. Row 5 failed; row 6 passed in 3.6 s. A wrong
    directory cannot be selective between two rows of one body.
  • Version-specificity. A writer/reader disagreement about a directory is
    version-independent. A race in net.Server.listen({fd}) is not.

Where it does live

ECONNREFUSED, not a hang, means the last descriptor was closed — the
socket stays in LISTEN while any fd survives, so an unarmed-but-holding standby
would have hung instead. gap-relay.mjs has exactly two process.exit sites:
one at startup when the parent env is unset, and

srv.on("error", (e) => {
  const held = srv.listening;
  process.stderr.write(...);
  if (!held) process.exit(1);

The test asserts the standby is already on the port before the kill, so the
startup one is passed and this is the only survivor: listen({fd: 3}) failing
at arm time, after node has closed the descriptor.

I could not reproduce it locally in 45 runs, so that is where the evidence
stops. It is a localization, not a proof.

Why it cannot be proven from a CI log, and why the obvious fix is wrong

That handler writes "the listen failed, so there is no descriptor left to
keep"
— into a discarded stream. The standby is spawned
stdio: ["ignore", "ignore", "ignore", fd], so its own account of its death
does not survive it. That is why the CI log carries only the wiring banner.

Giving it an inherited stderr is not the fix, and the code says why in its
own comment beside that line: a detached child that never closes an inherited
pipe is a pipe the launcher waits on forever — measured at 30 s of a close
that never fired. The sibling gap gets "inherit" precisely because it dies
with its parent; this one outlives it.

The comment's answer is that what it would have said is on /health anyway.
That holds for a standby that is alive to be asked. It does not hold for this
failure, which is the process exiting — the one message that explains the
exit is the one that cannot survive it. A destination that does not share a
lifetime with the parent (a file beside the run's state, a line the successor
can pick up) would close that without reintroducing the pipe.

No code change on this branch: adding a retry or widening the assertion would
turn CI green while leaving a real product race — a hard-killed holder can
strand the address the standby exists to protect — buried inside a
test-hygiene PR.

🤖 Generated with Claude Code

@vsits-codex-review-agent vsits-codex-review-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Codex review: PR #347 round 1

Review: PR #347 TMPDIR-per-launcher-test isolation

Date: 2026-08-26
Reviewed: PR #347 (a2834ba0a0e4e7ae124c72bab5c2f1fc75f68237) against base 78ca94837129636de543df412edcea71fe0da6c8
Round: 1
Label applied: changes-requested

What Is Correct

Measured: applying the PR diff to a fresh clone at base 78ca94837129636de543df412edcea71fe0da6c8 succeeded cleanly.

Measured: the affected launcher-spawning tests and new smoke test pass under parallel execution on Node v24.11.1:

node --test --test-concurrency=6 test/proxy-held-port.test.mjs test/proxy-holder-handover.test.mjs test/proxy-server.test.mjs test/proxy-shutdown-once.test.mjs test/proxy-wrapper.test.mjs test/stdio-epipe-survival.test.mjs test/tmp-isolation.test.mjs -> tests 130, pass 130, fail 0.

Measured: the same focused command under Node v20.20.2 also passed locally (tests 130, fail 0, skipped 2). The GitHub Node 20 job is still red on the PR head, but the logged failure is a holder handover race in test/proxy-holder-handover.test.mjs:551, not a direct tmpdir assertion: actual: 'ECONNREFUSED', expected: 'pong'.

Measured: a forced-failure probe importing test/file-tmpdir.mjs, writing under os.tmpdir(), and then throwing exited nonzero and left the inherited outer temp directory empty: {"status":1,"entries":[],"stderrHasForced":true}. The diff commits to deterministic cleanup on process exit rather than preserving failure scratch for debug.

Read: test/file-tmpdir.mjs:16 uses mkdtempSync(join(tmpdir(), "ccf-test-")), so the directory name is atomically unique; there is no PID or timestamp collision window. test/file-tmpdir.mjs:18-19 registers cleanup on process exit, so normal pass and assertion-failure exits both run the cleanup path.

Read: test/tmp-isolation.test.mjs:23-44 exercises the helper through a child and grandchild process and asserts the inherited outer tmpdir is empty after the child exits, which is the right shape for guarding an env-inheritance helper instead of relying only on side effects.

Read: PR #346 uses per-test mc- scratch directories inside test/proxy-microcompact-stability.test.mjs; PR #347 uses a file-level ccf-test- temp root in launcher-spawning test files. They do not conflict on the same TMPDIR keys or scratch prefixes.

Blockers

  1. Windows still writes to the shared temp root, and the smoke test can pass while proving the wrong path. [Read]

test/file-tmpdir.mjs:17 only assigns process.env.TMPDIR = FILE_TMP. Node's documented os.tmpdir() behavior is platform-specific: on Windows it is overridden by TEMP and TMP; TMPDIR is the non-Windows override path (Node docs: https://nodejs.org/api/os.html#ostmpdir). That means a Windows launcher child that calls os.tmpdir() will ignore this helper's TMPDIR assignment and continue using the process's inherited TEMP/TMP shared scratch directory.

The new smoke test has the same blind spot. test/tmp-isolation.test.mjs:38-44 spawns the probe with only TMPDIR: outer and then asserts outer is empty. On Windows, outer is empty because os.tmpdir() never used it, not because scratch was isolated and cleaned. This is exactly the load-bearing risk in the commissioning brief: green suite, wrong assertion. Set all relevant temp env keys for the child path (TMPDIR, TMP, and TEMP, with care for existing semantics) and make the smoke assertion prove the key that the current platform's os.tmpdir() actually reads.

What Needs Attention

Measured: CI status at review time is not green. PR head a2834ba0a0e4e7ae124c72bab5c2f1fc75f68237 has test (18) success, test (22) success, security checks success, and test (20) failure. Because the local Node 20 focused rerun passed, this may be a pre-existing/flaky holder-handover test race rather than introduced by this PR, but it remains a red supported-runtime check on a touched test file and should be resolved or rerun green before approval.

Measured: residual grep over the touched tests for TMPDIR, /tmp, tmpdir(), mkdtemp, cache-fix-proxy-, and cache-fix-ca-scratch- found no un-isolated launcher scratch path introduced by this PR. Remaining tmpdir() uses are inside files importing file-tmpdir.mjs first, or explicit per-case temp overrides such as the existing unwritable-TMPDIR test.

Bloat / Non-Functional

None. Production LOC is 0. This is test-only infrastructure. The new helper plus one smoke test is proportionate to the repeated launcher-spawn sites it covers, and there are no new production exports, config keys, or runtime paths.

Recommendations

Update test/file-tmpdir.mjs to set the platform-relevant temp environment variables inherited by child launchers, not just TMPDIR. The smoke test should also validate the actual os.tmpdir() value used inside the probe and grandchild is under the private root on the current platform, so a Windows run cannot pass merely because the asserted outer directory was never used.

After that change, rerun the focused test set under parallelism and get the PR's Node 20 CI check green or explicitly document a rerun showing the CI failure was unrelated flake.

Bottom Line

Request changes. The POSIX/Linux behavior measured cleanly, and the implementation is close, but the helper is load-bearing test infrastructure and currently misses Windows because it only sets TMPDIR. That defeats the stated cross-platform isolation goal silently.

— Codex, cross-LLM review, round 1

@vsits-codex-review-agent vsits-codex-review-agent Bot added changes-requested Blocking review findings are outstanding reviewed-by-codex-agent Directive/spec reviewed by Codex — no blocking findings labels Aug 26, 2026
codeslake and others added 2 commits August 26, 2026 10:23
…eads

`os.tmpdir()` is platform-specific: TMPDIR on POSIX, TEMP then TMP on Windows.
The helper set TMPDIR alone, so a Windows run kept writing launcher records and
scratch CAs to the shared temp root -- the exact state this exists to prevent.

The smoke case could not report it. It spawns its probe with `TMPDIR: outer`
and asserts `outer` is empty; on Windows `outer` is empty because os.tmpdir()
never looked at it, not because anything was isolated. A green suite proving
the wrong path.

Both halves fixed: the helper sets all three keys, and the probe spawn points
all three at the inherited directory so the redirect is actually exercised
whatever the platform reads. The new case asserts the property directly -- every
key os.tmpdir() can read names the private dir -- which is measurable on POSIX
and is the thing Windows depends on.

RED: still on the inherited root: TMP,TEMP
GREEN: 2/2 in the file; 83/83 across the suites that import the helper
mutation: reverting to TMPDIR alone kills exactly the new case

Co-Authored-By: Claude <noreply@anthropic.com>
…t see a miss

Two corrections to the commit before this one, both mine.

The comment said "TMPDIR on POSIX, TEMP then TMP on Windows". Measured from
node's own `os.tmpdir`: POSIX reads TMPDIR || TMP || TEMP, so `TMP` alone and
`TEMP` alone each work there too, and Windows reads TEMP || TMP. The reason to
set all three is that a DIFFERENT one is read FIRST per platform, not that the
others are ignored.

The separate case asserting all three keys agree was green against a helper
that redirects nothing: the spawn hands the child all three pointing at the
inherited directory, so with no redirect they still agree. Agreement was never
the property -- movement is.

Folded into the probe that already runs inside the redirected child, where the
existing "the inherited directory is empty" assertion supplies the movement
half. One case now, thirty lines shorter, and strictly stronger:

  redirect removed  -> dies on the inherited directory not being empty
  TMPDIR alone      -> dies naming TMP,TEMP
  cleanup removed   -> dies on the inherited directory not being empty

Three for three against the previous shape's two.

Co-Authored-By: Claude <noreply@anthropic.com>
@codeslake

Copy link
Copy Markdown
Contributor Author

Thanks — the Windows blind spot was real and both halves are fixed at a later head.

Reviewed: a2834ba0. Current head: cf44ba6.

The helper sets every key, not the one this platform happens to read first:

// ALL THREE, because os.tmpdir() reads a DIFFERENT one first per platform:
// TMPDIR || TMP || TEMP on POSIX, TEMP || TMP on Windows.
for (const key of ["TMPDIR", "TMP", "TEMP"]) process.env[key] = FILE_TMP;

The smoke test no longer passes by proving the wrong path. It was the
sharper half of your finding — an empty outer is not evidence when
os.tmpdir() never used it — so the probe now carries two guards of its own and
the spawn points all three keys at the inherited directory:

{ env: { ...process.env, TMPDIR: outer, TMP: outer, TEMP: outer } }
  • A denominator: the probe writes a marker from the process AND from a
    grandchild, and exit(3) if either is missing. An empty directory now proves
    isolation only when something was actually written somewhere.
  • The key assertion you asked for: exit(4) when any of TMPDIR/TMP/TEMP
    still names the inherited root. On Windows that is what fails rather than
    quietly passing, because os.tmpdir() there follows TEMP first.

The grandchild is deliberate: the launcher is a CHILD reading its own
os.tmpdir(), so a redirection that does not cross the process boundary
isolates nothing.

Measured at head: node --test test/tmp-isolation.test.mjs → 1/1 pass.

Could you re-review at cf44ba6?

🤖 Generated with Claude Code

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

Labels

changes-requested Blocking review findings are outstanding reviewed-by-codex-agent Directive/spec reviewed by Codex — no blocking findings

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant