Skip to content

test: restore leaked listeners at file boundaries and pin guard-only leak fixes - #464

Merged
roodboi merged 1 commit into
nextfrom
wf/g82-global-state-guard-followups
Oct 24, 2026
Merged

roodboi merged 1 commit into
nextfrom
wf/g82-global-state-guard-followups

Conversation

@roodboi

@roodboi roodboi commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Follow-ups to the global-state guard from #405 (HACK-1416, HACK-1388). Only test infrastructure, test fixtures and contributor guidance change. No product code changes.

Root cause

  • HACK-1416 (1), guard cascade. At a file boundary the guard restored env, cwd, umask and exit code, but not process listeners. A listener that a finished file left behind (for example a once SIGINT handler) stayed in the shared Bun process. The next test that called process.emit for that signal ran or consumed it, and the per-test check blamed that later test (1 -> 0).
  • HACK-1416 (2), test fidelity. Loki follow stops on SIGINT and leaves no listener behind calls the handler from process.listeners() directly. That skips the once wrapper's self-removal, so the follow's finally off() does the removal, not the real signal path.
  • HACK-1388 (1). a refused teardown still restores HACK_HOME ... called the global mock.restore(). That restores every spy in the shared process, including spies other fixtures keep on purpose while their settlement is unknown. It also ran through raw boundedTest, so it skipped the file's fixtureActive/fixtureUncertain gate.
  • HACK-1388 (2). Three leak fixes from test: fail and contain process-state leaks between test files #405 were checked only by the runtime guard, not by a test of their own: the file-generation afterEach PATH restore, the routing runner's HACK_HOME restore for an unknown lifetime (the issue calls it the "routing worktree restore"), and the protected-files fixture's process.kill restore after the settlement veto latches (the "settlement-latch restore").

Fix

  • tests/helpers/global-state-guard.ts: new restoreProcessListeners(snapshot). It edits only the difference: it removes listeners added since the snapshot and adds back missing snapshot listeners with process.on. Listeners present in both are left alone, so a kept once listener stays a once. Bun does not expose once, so a missing listener comes back as a persistent one, at the end of the list. Removing a leftover listener cannot let unsettled work reach the host. Replaced functions are still never restored.
  • tests/helpers/global-state-guard-preload.ts: at a file boundary, after reporting a change, the guard now calls restoreProcessListeners(fileBaseline) next to restoreGlobalState. The message now says "Restored the environment, cwd and process listeners ...". The per-test check is unchanged: it still fails the test and keeps listeners. The guard still fails the run; no allowlist, skip or special case was added.
  • New tests/helpers/owned-process-listeners.ts: withOwnedProcessListeners(event, run) removes the listeners present for event, runs run, and adds them back afterwards. A real process.emit inside run then reaches only listeners run installed. Listeners run leaves behind are kept, so the test and the guard still see them.
  • tests/native-compose-adoption-routing-generation.test.ts:
    • The gate is now gatedTest(name, run(restored), timeoutMs). A case's gate reopens only if it calls restored() after every global double and root it created is restored. The unknown-lifetime, incomplete-setup and failed-cleanup handling is now endRoutingFixture(lifetime, fixture). The ordinary runner test(...) uses both, with the same behavior as before.
    • The refused-teardown case now runs through gatedTest, ends its fixture through endRoutingFixture (the runner's own unknown-lifetime path), and restores only its own fixture's spies with the new fixture.restoreDoubles(). mock.restore() and the mock import are gone.
  • tests/helpers/retained-routing-adoption.ts: adds restoreDoubles() (restores this fixture's spies only, and cleanup() uses it too) and doublesInstalled().
  • tests/native-compose-adoption-file-generation.test.ts: the afterEach body is now the named releaseRetainedFileFixture() (same logic), so a test can drive it.
  • .ai/skills/hack-repo-verify/SKILL.md: says listeners are put back at the file boundary, and points to withOwnedProcessListeners for tests that emit a real signal.

Tests

  • tests/global-state-guard.test.ts
    • New unit case: restoreProcessListeners removes two leftovers (on and once), adds back a removed snapshot listener, reports only the changed event, and is a no-op the second time. A kept once listener still removes itself on a real process.emit("SIGUSR2"). This runs inside withOwnedProcessListeners("SIGUSR2").
    • The nested bun test suite gets c2-leak-signal-listener.test.ts, which leaves a module-scope once SIGUSR2 listener. d-later.test.ts gets a test that emits SIGUSR2 around its own listener. With the guard, the run names c2 (process 'SIGUSR2' listeners changed (0 -> 1), plus the restore message), the later test passes, nothing blames d-later.test.ts, and 3 files are reported.
  • tests/loki-short-lived-logs.test.ts: new Loki follow drops its SIGINT listener through a real SIGINT emit. Inside withOwnedProcessListeners("SIGINT"), it opens a follow (exactly 1 SIGINT listener), calls process.emit("SIGINT"), and checks that the count is 0 at once, before the tail closes. Then the follow exits 0 and the count is still 0.
  • tests/native-compose-adoption-routing-generation.test.ts: the refused-teardown case installs a pass-through spyOn(privateState, "readPrivate") that stands for another fixture's spy, and checks that it is still installed after teardown. It also checks that the runner path restores HACK_HOME, that a refused cleanup() restores HACK_HOME and keeps the fixture's doubles, and that restoreDoubles() removes them.
  • tests/native-compose-adoption-file-generation.test.ts: new an unknown lifetime still restores PATH before the teardown refuses. It drives releaseRetainedFileFixture() with a retained (unknown) lifetime and checks: refusal, uncertain set, PATH back to its pre-case value, spawn spy kept. Its own lifetime then ends normally in afterEach.
  • tests/native-config-protected-files-fixture.test.ts: the closed-pipe survivor negative checks that process.kill is back to its pre-test function right after the veto's signal.mockRestore().

Mutation proofs (each applied and reverted with a script; Bun 1.4.2)

Mutation Result
Preload never calls restoreProcessListeners nested guard case fails: d-later.test.ts is blamed
restoreProcessListeners keeps leftovers unit case and nested case fail
restoreProcessListeners never adds back missing listeners unit case fails
restoreProcessListeners does removeAllListeners and re-adds unit case fails (the kept once turns persistent)
Loki process.once becomes process.on (the finally off stays) only the new real-emit case fails (count 1 right after the emit); both existing SIGINT cases still pass
Loki SIGINT handler does not call stop() new real-emit case fails (timeout)
Loki finally off() removed the two existing cases fail; the real-emit case passes, as expected (the once wrapper removes itself)
Refused-teardown case uses global mock.restore() again case fails: readPrivate is no longer the foreign spy
endRoutingFixture skips releaseEnvironment() for an unknown lifetime case fails: HACK_HOME is still the fixture home
Gate already closed (fixtureUncertain = true) before the case case refuses ("Prior retained routing fixture lifetime is unknown") — it is gated; on next it ran anyway
Routing cleanup() restores HACK_HOME only after the lifetime check case fails
File-generation teardown restores PATH only after the lifetime check new case fails: PATH is still the fixture root
Protected-files negative drops signal.mockRestore() case fails on its own expect(process.kill) (the guard also reports process.kill replaced)

Evidence

  • Bun 1.4.2 (mise x bun@1.4.2), this Mac, with private HOME, HACK_HOME and DOCKER_CONFIG and HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK=1.
  • bun run --cwd packages/cli typecheck: 0 errors. Root bun run check: passes (privacy-check ok; only existing warnings). ultracite check on the changed files: clean, apart from one existing complexity warning at tests/helpers/retained-routing-adoption.ts:250 (unchanged code).
  • gitleaks 8.30.1 (gitleaks dir over a git log -p origin/next..HEAD export): no leaks.
  • Focused runs: tests/global-state-guard.test.ts 8/8; tests/loki-short-lived-logs.test.ts 21/21; the new routing, file-generation and protected-files cases pass on their own (the protected-files case passed 6 of 7 runs under host load of about 250; the one failure was not its new assertion, see Gaps).
  • The full routing-generation and file-generation files could not finish green locally: the host load average was 50-280 from other work, so slow cases hit their budgets (known nonzero child ... at 30 s, the HACK-1327 pattern) and each file's gate then refused later cases, as designed. The first file-generation case, file8 refuses foreign receipt families ..., times out at 15 s here with or without this change: the origin/next copy of the file fails the same way on this host.
  • Hosted CI at 061638b6 (the PR head): test passes (38m15s; the full suite), plus all four docker-e2e shards, Candidate core (ubuntu), Config compiler (ubuntu x64/arm), Runtime state models, linux-process-lifetime, runtime-images and secret-scan. The macOS Candidate core and Config compiler jobs were still queued when this was written.
  • No CLI surface or consumer agent guidance changed, so no docs:cli-reference or generate:agent-plugins regeneration.

Gaps

  • The per-test check still keeps a test's leftover listeners until its file ends. A later test in the same file that emits that signal can still be blamed. Use withOwnedProcessListeners for real emits.
  • withOwnedProcessListeners and restoreProcessListeners bring a removed listener back as a persistent one: Bun does not expose once. Between tests the guarded events normally have no listeners.
  • The gated refused-teardown case now refuses after an earlier routing case leaves an unknown lifetime, where before it ran anyway. That is the requested behavior; it adds one more line to an existing in-file cascade.
  • Under heavy local load (about 250), the protected-files closed-pipe survivor negative failed once on an existing toBe before reaching its new assertion (most likely its 2.5 s descendant-join deadline). This is existing load sensitivity and is not changed here.
  • HACK-1388 (3), packages/db running without the root bunfig preload, is info only and stays unchanged.

Refs HACK-1416, HACK-1388

🤖 Generated with Claude Code

…leak fixes

The global-state guard now puts the guarded process listeners back at each
file boundary after reporting the change, so a finished file's leftover
handler is no longer run or consumed by a later test's process.emit and
charged to that test (HACK-1416). Only the difference is edited, so a kept
once listener stays a once listener.

The Loki follow also gets a test that removes its SIGINT listener through a
real process.emit("SIGINT") inside a fixture that owns every SIGINT listener.

The routing refused-teardown case restores only the spies its fixture
installed instead of the global mock.restore(), and runs through the file's
fixture gate (HACK-1388). The gate and the unknown-lifetime teardown are now
shared by the gated runner and that case, which pins the runner's HACK_HOME
release. File generation's PATH restore under an unknown lifetime and the
protected-files settlement veto's process.kill restore get deterministic
assertions.

Refs HACK-1416, HACK-1388

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@linear-code

linear-code Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

HACK-1388

HACK-1416

@roodboi
roodboi marked this pull request as ready for review October 24, 2026 19:26
@roodboi
roodboi merged commit ce7d9c8 into next Oct 24, 2026
16 checks passed
@roodboi
roodboi deleted the wf/g82-global-state-guard-followups branch October 24, 2026 19:26
roodboi added a commit that referenced this pull request Oct 24, 2026
…ches and restore owned listeners on timeout (#497)

## Summary

Fix unit G103: the HACK-1455 follow-ups after #469 (storage diagnostics), #464 (global-state
guard) and #465 (label review). Diagnostics and test infrastructure only: no carrier check,
deadline, retention or recovery rule changes.

## Root causes and fixes

### #469: storage diagnostics

1. **Binding-check call sites were unpinned.** The only test of
`assertNativeComposeStorageBindingUnchanged` called the helper directly, and nothing drove the
real Docker carrier with a binding that changed mid-step. So any call site could go back to `if
(!same(before, x)) return refuse();` unnoticed.    New `tests/native-compose-storage-binding-
call-sites.test.ts` runs the real `createNativeComposeDockerStorageXattrCarrier` in process on
the synthetic daemon from `tests/helpers/native-compose-storage-docker.ts`. It drives an
original readonly verify (inspect, journaled invoke, deferred finish) and a cold provision. Only
the saved material binding is a stand-in: `assertNativeComposeMaterialAuthority` is spied, and
the chosen read returns the same binding with another receipt hash. The table names the read
that reaches each call site and the stage it must report. The baseline case pins the total read
count (53 for verify, 4 for provision), so a change to the read order fails there first.    The
case runs with the synthetic `docker` first on `PATH` and an isolated Docker client config, so
the engine-ID admission keeps the synthetic CLI and never dials a real socket. `PATH` and the
`DOCKER_*` selectors are restored in `finally`, and a timed-out case's carrier is aborted in
`afterEach`. 2. **The owner-creation `refuseFrom` was untested.** New `tests/native-compose-
storage-command-record.test.ts` cases make the creation check refuse before the record is
written (check 1) and after it is read back (check 2). Each asserts the issued boundary and its
post-deadline reason. The carrier table's read 6 covers the same path end to end. 3. **The
helper's stage was not pinned.** The helper unit test now runs with two stages (`storage-helper-
start`, `storage-provision`). The carrier table also checks five different stages. 4.
**Remaining untagged mismatches.**    - New `assertNativeComposeStorageCarrierStateUnchanged`
replaces `same(repeated.state, stopped.state)` and `same(finalCarrier.state, stopped.state)`. A
stopped carrier whose state changed is the same instability as a changed creation time or
policy, so it reports `storage-helper-policy` / `helper-policy-stability`.    - The finalizer's
binding check now uses `assertNativeComposeStorageBindingUnchanged` at `storage-helper-cleanup`
(the carrier's own cleanup stage). The owner-confirmed check still runs first, as before.    -
Both reasons are outside `deadlineReasons`, so they never read `storage-budget` past the
deadline. The reference docs say so.    - A new `fault-carrier-state-changed` synthetic-daemon
fault returns another `FinishedAt` on the Nth policy inspection. 5. **The xattr-owner guard
storage proof case** now uses the file's `STORE_CASE_BUDGET_MS` instead of Bun's 5 s default.
G99 / HACK-1439 has no open PR or remote branch yet, so there was nothing to merge.

### #464: global-state guard

6. **The refused-teardown case could bypass the fixture gate.** It now asserts that it holds the
gate (`fixtureActive`, `!fixtureUncertain`). As a plain `boundedTest` it fails. 7.
**`restoreProcessListeners`' docstring claimed removal had no effect.** It said that removing a
leftover listener "cannot let unsettled work reach the host". That is wrong for cleanup
handlers: a leftover `SIGINT`/`SIGTERM` handler that kills a child on Ctrl-C stops running, and
the child can outlive an interrupted run.    **Decision:** keep report-and-restore to the
baseline. The guard does not call the handler or wait for what it owns. It cannot know what the
handler owns or whether running it now is safe, and running another file's handler at an
arbitrary point is the cross-file interference the restore exists to prevent. The leak report
names the file and fails the run, so the fix belongs in that file's teardown. The docstring and
the verify skill now say this. 8. **`withOwnedProcessListeners` restored only when its callback
settled.** On a timeout the callback never settles, so other code's listeners stayed removed.
Its restore is now idempotent and registered three ways:    - the callback's own `finally`;    -
`onTestFinished`, which Bun runs after a timeout;    - a process-wide set that the guard preload
empties before it compares the test's state. Without this, the guard (whose `afterEach` runs
before `onTestFinished`) would blame the timed-out test.

A saved `once` listener now comes back as `once`. Bun does not expose `once`: `listeners()` and
`rawListeners()` both return the bare function. So the new `tests/helpers/process-once-
listeners.ts` wraps the registration methods of `process` and remembers the last registration
kind; the preload installs it before any file loads. The guard's file-boundary
`restoreProcessListeners` uses the same registry for missing listeners.

### #465: label review

9. `hack_ownership_label_namespaces_block_enrollment` now covers the dependency-cache
declaration form. Without an ownership key that form reports no owner label; with each of the
four ownership keys it still reports one and refuses enrollment.

## Mutation proof

Each mutant was applied to the source and run against its own focused test with a scratch
driver; the file was restored byte for byte afterwards. The kill reason was checked in each log;
for the binding call sites it is `private-state` becoming `unclassified` (and `storage-budget`
once late).

| Mutant | Killed by | |---|---| | Each of the 12 binding call sites, one at a time, back to the
untagged `same()` + `refuse()` form: observe, provision (latest, admission, after), owner check,
admitted, create admission, final, start admission, cleanup authority, remove admission,
finalizer | the matching row of the carrier table (verify read 2; provision reads 2, 3, 4;
verify reads 6 and 12, 8, 9, 21, 22, 34, 35, 49) | | `repeated` state check back to `if
(!same(...)) return refuse()` | state row, inspection 7 | | `finalCarrier` state check back to
the untagged form | state rows, inspections 8 and 10 | | Owner creation `return
refuseFrom(error)` becomes `return refuse()` | both new command-record cases, plus carrier read
6 | | Helper hard-codes `"storage-helper-start"` | the `storage-provision` row of the helper
test | | Guard storage proof case back on Bun's default timeout | that case timed out at 5 s on
this host at load average 50-60 (load-dependent, see gaps) | | Refused-teardown case `gatedTest`
becomes `boundedTest` | the case itself (gate assertion) | | No `onTestFinished` restore |
timed-out nested suite without the preload | | Preload does not call
`restoreUnsettledOwnedProcessListeners` | timed-out nested suite with the preload (the guard
blames the timed-out test) | | `withOwnedProcessListeners` re-adds every listener as persistent
| all 4 listener tests | | Guard `restoreProcessListeners` re-adds every listener as persistent
| the extended file-boundary restore test | | runtime-core: ownership labels exempt under a
dependency cache | `hack_ownership_label_namespaces_block_enrollment` (owner-label assertion of
the new form) |

## Evidence

- Bun 1.4.2 (`mise x bun@1.4.2`), `bun install --frozen-lockfile`; no `bun.lock` change.
- Root `bun run check`: privacy-check ok, 34 warnings, all existing complexity warnings
  (`invoke` 59 -> 57). `bun run --cwd packages/cli typecheck`: 0 errors.
- gitleaks 8.30.1 (`gitleaks dir` over the `git log -p origin/next..HEAD` export): no leaks.
- Rust (cargo +1.97.1): `cargo fmt --check`, `clippy --all-targets -D warnings` and
  `project_contract hack_ownership_label_namespaces_block_enrollment` all pass. The TS parity
  scan `tests/hack-ownership-labels.test.ts` passes (3).
- Focused files, run serially on a shared Mac at load average 20-60:
  - new carrier call-site file: 17 pass (285 s at load ~55; each case 4-40 s)
  - command record: 34 pass
  - docker witness: 90 pass
  - listener and guard files: 12 pass
  - routing gate cases: 4 pass
  - Loki SIGINT cases: 3 pass
  - guard storage proof case: pass
  - policy stability CLI: 4 pass; the fifth hit its 120 s command kill at load ~60 before any
  helper inspection, then passed alone at load ~20
- Trial merge with the G99 branch `wf/g99-test-reliability-under-load` (HACK-1439; not merged,
  no PR yet): clean. That branch sets `setDefaultTimeout(STORE_CASE_BUDGET_MS)` for the whole
  xattr-owner file, which agrees with the explicit budget here.
- Full suite: hosted CI at the exact PR head a8f6c35 (run 40129069973) passed `test` (shards
  1-4), docker-e2e (shards 1-4), Linux and macOS candidate core, the Linux and macOS-arm config
  compilers, runtime state models, linux-process-lifetime, runtime-images and secret-scan. On
  the hosted runner, the 19 new carrier and listener cases took 0.06-0.9 s each.
- **One failure: `Config compiler (macos-15-intel)`.** `tests/native-compose-file-
  owner.test.ts`, "cancellation during the last durable up arm …", hit its explicit 5000 ms
  timeout (5312 ms). That file and test are not changed here. In the last green `next` run
  (40125320831) the same case took 3071 ms, and its `down` sibling took 5418 ms under a 30 s
  budget. It is not on the known-flake list, so it was not rerun; it is reported as a finding.

## Gaps

- The carrier table selects reads by their order. The baseline case pins the totals (53 and 4),
  so a change to the order fails loudly, but the table must then be re-derived (the test header
  says so).
- The binding stand-in is a spied `assertNativeComposeMaterialAuthority`. No real receipt
  publication happens between two carrier reads, and no CLI fault changes the saved binding mid-
  proof.
- The new carrier file adds 18 real-carrier runs: under 1 s each on hosted CI, but 4-40 s each
  on a heavily loaded Mac.
- The intel config-compiler job is red for an unchanged test's 5 s budget (see evidence). It
  needs a separate fix or a rerun decision.
- The guard-proof budget mutant is killed only on a loaded host. A timeout value has no
  deterministic mutation proof.
- The `once` registry wraps `process.on`, `once`, `addListener`, `prependListener` and
  `prependOnceListener` for the test process. A function registered for one event both ways at
  once is read as its last registration kind. Without the root preload (for example
  `packages/db`), `once` listeners added before the helper first loads come back persistent.
- When the preload's restore is removed, the guard's `afterEach` error also stops Bun from
  running that test's `onTestFinished`. So in this repository the preload restore is the one
  that matters; `onTestFinished` covers runs without the preload.
- Item 7 is a documented decision (report and restore; never call or await a leftover handler),
  not a behavior change. Nothing beyond the docstring can be tested.

Refs HACK-1455

🤖 Generated with [Claude Code](https://claude.com/claude-code)
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