Skip to content

test(e2e): reclaim the run's temp dirs through a single per-run root - #358

Merged
mtskf merged 4 commits into
mainfrom
chore/e2e-teardown-user-data-dir
Aug 14, 2026
Merged

test(e2e): reclaim the run's temp dirs through a single per-run root#358
mtskf merged 4 commits into
mainfrom
chore/e2e-teardown-user-data-dir

Conversation

@mtskf

@mtskf mtskf commented Aug 14, 2026

Copy link
Copy Markdown
Owner

Summary

The E2E harness never removed the directories it created under $TMPDIR. launch.ts mkdtemp'd a --user-data-dir per run, and each temp-file suite mkdtemp'd a workspace dir but only unlinked the .md inside it. Measured on a dev box on 2026-08-14: 2 544 stale quoll-* dirs (912 of them quoll-e2e-*, 880 MB), and a clean run added 53 more.

This gives the run one root with one owner: launch.ts creates a single quoll-e2e- root holding ud (VS Code --user-data-dir) and w (parent of every suite workspace dir), exports the root to the Electron host through extensionTestsEnv, and removes exactly that path in a finally.

Changes

  • test/extension/temp-root.ts (new): owns the run root (createRunTempRoot / dispose) and is the only sanctioned allocation site (makeTempDir / makeTempDirSync).
  • test/extension/launch.ts: creates the root, passes it via extensionTestsEnv, points --user-data-dir inside it, and disposes it in a finally. process.exitprocess.exitCode so the finally actually runs on a failing run.
  • test/extension/e2e/harness.ts + 20 suites: allocate through the seam instead of fs.mkdtemp(os.tmpdir(), …). Existing per-test file/dir teardown is unchanged — it still keeps the live run tidy; the root removal is the backstop for what it misses.
  • test/extension/temp-dir-choke-point.test.ts (new): default-denies mkdtemp/tmpdir calls outside the seam, walking the TypeScript AST rather than source text, so the word appearing in a comment or string can neither trip the guard nor vacuate it.
  • test/extension/temp-root.test.ts (new): pins allocation placement, non-empty-root reclamation, idempotent dispose, the throw-on-missing-env contract, and the macOS socket-path budget.

Deliberate choices worth a reviewer's eye:

  • resolveRunTempWorkDir throws when the env var is missing instead of falling back to os.tmpdir() — a fallback would silently restore this exact leak.
  • dispose() surfaces EBUSY/EACCES; launch.ts names the path and fails the run. A leak we cannot reclaim must not exit green.
  • Path segments stay at ud/w: VS Code opens its IPC socket under --user-data-dir and macOS caps those paths at 103 chars, of which $TMPDIR alone is 48.
  • Nothing globs quoll-e2e-* — a parallel run (CI shard, another worktree) owns its own root and must survive ours.

⚠️ Note for reviewers: pnpm compile does not type-check this tree (the root tsconfig excludes test/). The E2E program is compiled only by node test/extension/build.mjs.

Related

  • TODO: "The e2e harness leaks its per-run quoll-e2e-* user-data dirs — teardown in-process"

Test Plan

  • pnpm test:unit — 248 files / 4961 tests passing (includes the 9 new ones)
  • node test/extension/build.mjs — E2E program type-checks
  • env -u ELECTRON_RUN_AS_NODE pnpm test:e2e — 103 passing; quoll-* count in $TMPDIR 2 597 before → 2 597 after (zero new dirs; the pre-change baseline run added 53)
  • Aborted run: runner hard-killed mid-suite leaves exactly one dir (its own root)
  • Guard non-vacuity: reintroducing os.tmpdir() in a suite turns the choke-point test red; pointing the scan at an empty dir turns the coverage assertion red
  • Teardown non-vacuity: making dispose() a no-op turns two temp-root cases red

mtskf added 4 commits August 14, 2026 11:12
The E2E harness never removed the directories it created under $TMPDIR:
launch.ts mkdtemp'd a user-data dir per run, and each temp-file suite
mkdtemp'd a workspace dir but only unlinked the .md inside it. Measured on
a dev box: 2 544 stale quoll-* dirs (912 of them quoll-e2e-*, 880 MB), and
a clean run added 53 more.

Give the run one root and one owner. launch.ts creates a single
quoll-e2e- root holding `ud` (VS Code --user-data-dir) and `w` (parent of
every suite workspace dir), exports the root through extensionTestsEnv,
and removes exactly that path in a finally. Suites allocate only through
temp-root's makeTempDir/makeTempDirSync, so nothing needs its own dir
teardown and no code globs quoll-e2e-* (a parallel run owns its own root).

Deliberate choices:
- resolveRunTempWorkDir throws when the env var is missing rather than
  falling back to os.tmpdir(), which would silently restore the leak.
- dispose() surfaces EBUSY/EACCES and launch.ts names the path and fails
  the run; an unreclaimable leak must not exit green.
- exitCode replaces process.exit so the finally actually runs on failure.
- Path segments stay at `ud`/`w`: VS Code opens its IPC socket under
  --user-data-dir and macOS caps those paths at 103 chars.
- temp-dir-choke-point.test.ts default-denies mkdtemp/tmpdir calls outside
  the seam, scanning the TypeScript AST rather than source text so a
  mention inside a comment or string can neither trip nor vacuate it.

Verified: clean run leaves zero new dirs in $TMPDIR (2 597 before and
after, 103 passing); a runner killed mid-suite leaves exactly its own one.
Review round on the temp-root change turned up one leak path the design
missed and one defect the mechanical migration introduced.

The interrupt path still stranded the whole root. @vscode/test-electron
ends innerRunTests with `if (exitRequested && listenerCount('SIGINT') === 0)
process.exit(1)`, so the first Ctrl+C — the graceful close, where the child
exits 0 — hard-exited this process from inside the await and skipped the
finally entirely. Owning a SIGINT listener keeps the exit on our side so
dispose() runs; a second Ctrl+C still force-closes through the library's own
handler. SIGTERM has no graceful path there at all, so it reclaims and
re-exits 143. An interrupted run now exits 130 instead of reporting a pass.

The migration also dropped `${slug}` from three suites' temp-dir names
(`fmtdoc-`, `sbar-`, `2panel-`), so an undisposed dir no longer said which
test made it. Restored, and `makeTempDir` now rejects any slug that is not a
bare name — `path.join` normalises `../`, so a separator would have landed
the dir outside the run root where nothing disposes it.

Also from the review:
- launch.ts gets an injectable seam so the wiring the whole change exists
  for — which env key carries the root, which dir VS Code gets, and that
  dispose() runs on the failure path too — is pinned by unit tests. Restoring
  `process.exit(1)` in the catch now turns them red; before, every suite
  stayed green.
- The socket-budget test measured the absolute path, so it passed on CI
  (`/tmp`, 56 chars of slack) and could never catch a rename of the `ud`
  segment. It now measures only the part this module controls.
- dispose()'s loudness is pinned: `not.toThrow()` on an already-removed root
  is satisfied by `force: true` alone, so a dispose() that swallowed
  everything passed it.
- The choke-point guard matched call-site spelling, so `import { mkdtemp as
  mkTmp }` or `fs["mkdtempSync"](…)` walked past it. Both are caught now,
  with an in-memory case exercising the new branches.
- The partial-init rollback rethrows the original error rather than letting a
  failed rollback replace it, and dispose() retries for ~3s so a still-
  draining VS Code cannot flip a green run red.
- preflightFixturesDir throws instead of process.exit()ing, so the "never
  exit past a live root" invariant no longer depends on statement order.

Verified: clean run 103 passing with zero new dirs in $TMPDIR (2 597 before
and after); restoring the old `process.exit(1)` turns the wiring test red.
Coverage for the partial-init rollback resisted both vi.spyOn (node:fs
properties are non-configurable) and any natural filesystem trigger; it is
tracked as a follow-up rather than left implied.
Two follow-ups from the second review round.

The run root was built from `fs.realpathSync(os.tmpdir())`, which I had added
only so an assertion could compare against the resolved path. On macOS that
turns every document path the suites hand VS Code from `/var/folders/…` into
`/private/var/folders/…`, and the disk-conflict watcher filters watcher events
by exact URI string — so the event never matched the document and
`dirty-doc-disk-conflict > keeps unsaved edits on 'Keep my edits'` timed out
waiting for a prompt that could not arrive. Measured: 2 failures in 4 runs
with the realpath in, 0 in 3 after removing it, against 0 in 2 for the
pre-change harness under the same load. The assertion now pins the
unresolved form, so reintroducing realpath turns it red.

The SIGTERM handler is removed. Codex flagged at Conf 97 that it disposes the
root while VS Code may still be running; a plain `kill` of the launcher alone
leaves the child alive to recreate user-data-dir under the path just removed,
trading one stranded root for a stranded root plus an orphaned editor. The
library exposes no child pid and no graceful stop, so there is no correct
handler to write here — an aborted run keeping its own single root is the
accepted outcome, and that is now stated in the code.

Also: the SIGINT listener no longer makes Ctrl+C a dead key during the VS Code
download. The library's own handler only exists after the child spawns, so
before that our listener was the only one and merely suppressed Node's default
termination; it now reclaims and exits 130 when nothing else is listening. And
the choke-point test's tmpdir exemption is anchored at the name boundary — an
unanchored suffix also exempted a renamed import ending in `tmpdir`.
⚠️ Correction to 3c56d60: its message described removing the SIGTERM handler
and adding the pre-spawn SIGINT branch, but neither change was in the commit —
an intervening `git checkout HEAD -- test/extension/` (used to A/B the old
harness against the flake) had silently reverted both. They land here, and the
realpath fix that commit actually did carry is unaffected.

Review round 2 found the signal handling entirely unpinned: deleting the SIGINT
registration, the `??= 130`, or the pre-spawn branch all left the suite green,
so the leak this PR closes could be silently reopened by a tidy-up. All four
mutations now go red.

- The pre-spawn branch compares against a baseline captured at registration
  rather than a bare `listenerCount() === 1`. "Has the library taken over?"
  means "did a listener appear after ours", which reads correctly both in a
  bare `node` run and under a host that already listens — the `=== 1` form was
  also untestable, since the test runner holds its own SIGINT listeners.
- SIGTERM stays unhandled, with the reasoning in the code: the library exposes
  no child pid, so a handler could only reclaim while VS Code may still be
  running, and a plain `kill` of the launcher leaves the child free to recreate
  user-data-dir under the path just removed.
- preflight moves onto the LaunchDeps seam. Its safety was never "it throws" but
  "it runs before the root exists" — a throw from after createRoot() escapes
  with no finally either — so the ordering is pinned instead of asserted in a
  comment that told readers position did not matter.
- The partial-init rollback IS testable: `vi.mock` swaps the module-registry
  entry rather than redefining a property on the frozen node:fs namespace, which
  is what defeated `vi.spyOn`. My earlier note calling it impossible was wrong;
  the follow-up TODO is removed and the test replaces it.
- The choke-point guard now pins its own exemption scope. Widening the `tmpdir`
  allowance tree-wide was a green edit, and `mkdirSync` is deliberately not
  banned — so that exemption is the only thing standing between the tree and a
  hand-rolled `fs.mkdirSync(path.join(os.tmpdir(), …))`.

Verified: 4973 unit tests green; two consecutive E2E runs 103 passing with zero
new roots; each new assertion confirmed by a killing mutation.
@mtskf
mtskf merged commit 42d59be into main Aug 14, 2026
2 checks passed
@mtskf
mtskf deleted the chore/e2e-teardown-user-data-dir branch August 14, 2026 02:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant