The test suite leaks temp dirs: 89 mkdtempSync sites, 0 cleanups — 6731 dirs / 189MB in one day, some holding age keys #117

Closed
opened 2026-07-19 22:17:30 +00:00 by dan-claude-bot · 0 comments
dan-claude-bot commented 2026-07-19 22:17:30 +00:00 (Migrated from github.com)

Found during the 0.1.1 release drill. This is a test-suite hygiene bug, not a runtime defectcast itself does not leak. Scoping it that way up front because the raw symptom looks much worse than it is.

Symptom

After a day of test runs on one machine:

$ ls -d /tmp/cast-* | wc -l
6731
$ du -shc /tmp/cast-*
189M    total

Oldest 12:16, newest 22:08 — all from a single day. Nothing reaps them, so the count is monotonic for the life of the machine.

Cause

test/*.ts creates temp directories and never removes them:

mkdtempSync sites in test/:  89
rmSync sites in test/:        0

Not "some are missed" — there is no cleanup at all. No afterAll, no afterEach, no try/finally unlink, no EXIT trap.

The leaked prefixes trace only to test files, which is what makes this test-only:

  • test/greenfield-cli.test.ts:151cast-home- (a fake HOME for a spawned dist/cli.js diff)
  • test/secrets.test.ts:127,138cast-home-
  • test/release.test.ts:593cast-inst-
  • test/backup-cli.test.ts:24,113,117 and test/capture-cli.test.ts:37,113,124cast-age-, cast-co-, cast-state-
  • test/install-sh.test.ts:62cast-install-

grep -rn "cast-home\|cast-inst" src/ bin/ returns nothing. The reconciler's own tempdir handling is not implicated by this finding, and I did not audit it here.

Why it is worth fixing rather than ignoring

It contains key material. A sampled directory held exactly one file:

$ ls -a /tmp/cast-home-8rIUzI
.  ..  age-staging.key

These are test-fixture age keys, not real secrets — but the suite is scattering private-key-shaped files across /tmp at ~6700 per machine-day and leaving them world-readable-by-default for whatever /tmp's mode allows. That is a bad habit to leave in a repo whose entire job is managing an age-encrypted secret store, independent of whether these particular keys matter.

It is unbounded. 189 MB/day on this host. On a long-lived CI runner or a developer box that runs the suite often, this grows until /tmp fills. Nothing reports it.

It makes drill and CI residue checks noisy. This was found while hunting for residue from a release drill: of the 6731 directories, only 261 were attributable to the drill itself and ~96% predated it. Anyone auditing what a run left behind has to first subtract a large pre-existing pile, which is exactly the kind of noise that hides a real leak.

Suggested fix

The mechanical fix is a shared helper rather than 89 individual finally blocks — something like a tmp() that registers the path and an afterAll that reaps the batch, so new tests get cleanup by default instead of by remembering. Vitest's onTestFinished/afterAll both work; the important property is that the default path cleans up, since the current state shows what happens when it is opt-in.

Worth pairing with a suite-level assertion — after the run, /tmp/cast-* should be empty — so a future test that allocates without cleaning fails loudly rather than adding to the pile. That is the same shape as the eof_guard_sweep in heavy-duty/box#112: a class check that stops the next occurrence surviving review.

Not verified

Whether src/ has its own tempdir lifecycle problem. This finding is strictly about test/. The runtime prefixes did not appear in /tmp, which is evidence but not an audit.

Refs

Found during the release: 0.1.1 drill (#116), step 6/7 residue hunt. The 0.1.1 release itself is unaffected — it touches zero runtime source (src/ tree hash is byte-identical to 0.1.0).

Found during the 0.1.1 release drill. **This is a test-suite hygiene bug, not a runtime defect** — `cast` itself does not leak. Scoping it that way up front because the raw symptom looks much worse than it is. ## Symptom After a day of test runs on one machine: ``` $ ls -d /tmp/cast-* | wc -l 6731 $ du -shc /tmp/cast-* 189M total ``` Oldest `12:16`, newest `22:08` — all from a single day. Nothing reaps them, so the count is monotonic for the life of the machine. ## Cause `test/*.ts` creates temp directories and never removes them: ``` mkdtempSync sites in test/: 89 rmSync sites in test/: 0 ``` Not "some are missed" — **there is no cleanup at all**. No `afterAll`, no `afterEach`, no `try/finally` unlink, no EXIT trap. The leaked prefixes trace only to test files, which is what makes this test-only: - `test/greenfield-cli.test.ts:151` — `cast-home-` (a fake `HOME` for a spawned `dist/cli.js diff`) - `test/secrets.test.ts:127,138` — `cast-home-` - `test/release.test.ts:593` — `cast-inst-` - `test/backup-cli.test.ts:24,113,117` and `test/capture-cli.test.ts:37,113,124` — `cast-age-`, `cast-co-`, `cast-state-` - `test/install-sh.test.ts:62` — `cast-install-` `grep -rn "cast-home\|cast-inst" src/ bin/` returns **nothing**. The reconciler's own tempdir handling is not implicated by this finding, and I did not audit it here. ## Why it is worth fixing rather than ignoring **It contains key material.** A sampled directory held exactly one file: ``` $ ls -a /tmp/cast-home-8rIUzI . .. age-staging.key ``` These are test-fixture age keys, not real secrets — but the suite is scattering private-key-shaped files across `/tmp` at ~6700 per machine-day and leaving them world-readable-by-default for whatever `/tmp`'s mode allows. That is a bad habit to leave in a repo whose entire job is managing an age-encrypted secret store, independent of whether these particular keys matter. **It is unbounded.** 189 MB/day on this host. On a long-lived CI runner or a developer box that runs the suite often, this grows until `/tmp` fills. Nothing reports it. **It makes drill and CI residue checks noisy.** This was found while hunting for residue from a release drill: of the 6731 directories, only **261** were attributable to the drill itself and ~96% predated it. Anyone auditing what a run left behind has to first subtract a large pre-existing pile, which is exactly the kind of noise that hides a real leak. ## Suggested fix The mechanical fix is a shared helper rather than 89 individual `finally` blocks — something like a `tmp()` that registers the path and an `afterAll` that reaps the batch, so new tests get cleanup by default instead of by remembering. Vitest's `onTestFinished`/`afterAll` both work; the important property is that the *default* path cleans up, since the current state shows what happens when it is opt-in. Worth pairing with a suite-level assertion — after the run, `/tmp/cast-*` should be empty — so a future test that allocates without cleaning fails loudly rather than adding to the pile. That is the same shape as the `eof_guard_sweep` in `heavy-duty/box#112`: a class check that stops the next occurrence surviving review. ## Not verified Whether `src/` has its own tempdir lifecycle problem. This finding is strictly about `test/`. The runtime prefixes did not appear in `/tmp`, which is evidence but not an audit. ## Refs Found during the `release: 0.1.1` drill (#116), step 6/7 residue hunt. The 0.1.1 release itself is unaffected — it touches zero runtime source (`src/` tree hash is byte-identical to 0.1.0).
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: heavy-duty/cast#117
No description provided.