Repository navigation
test: restore leaked listeners at file boundaries and pin guard-only leak fixes - #464
Merged
Merged
Conversation
…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>
roodboi
marked this pull request as ready for review
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)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
onceSIGINT handler) stayed in the shared Bun process. The next test that calledprocess.emitfor that signal ran or consumed it, and the per-test check blamed that later test (1 -> 0).Loki follow stops on SIGINT and leaves no listener behindcalls the handler fromprocess.listeners()directly. That skips theoncewrapper's self-removal, so the follow'sfinallyoff()does the removal, not the real signal path.a refused teardown still restores HACK_HOME ...called the globalmock.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 rawboundedTest, so it skipped the file'sfixtureActive/fixtureUncertaingate.afterEachPATH 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'sprocess.killrestore after the settlement veto latches (the "settlement-latch restore").Fix
tests/helpers/global-state-guard.ts: newrestoreProcessListeners(snapshot). It edits only the difference: it removes listeners added since the snapshot and adds back missing snapshot listeners withprocess.on. Listeners present in both are left alone, so a keptoncelistener stays aonce. Bun does not exposeonce, 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 callsrestoreProcessListeners(fileBaseline)next torestoreGlobalState. 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.tests/helpers/owned-process-listeners.ts:withOwnedProcessListeners(event, run)removes the listeners present forevent, runsrun, and adds them back afterwards. A realprocess.emitinsiderunthen reaches only listenersruninstalled. Listenersrunleaves behind are kept, so the test and the guard still see them.tests/native-compose-adoption-routing-generation.test.ts:gatedTest(name, run(restored), timeoutMs). A case's gate reopens only if it callsrestored()after every global double and root it created is restored. The unknown-lifetime, incomplete-setup and failed-cleanup handling is nowendRoutingFixture(lifetime, fixture). The ordinary runnertest(...)uses both, with the same behavior as before.gatedTest, ends its fixture throughendRoutingFixture(the runner's own unknown-lifetime path), and restores only its own fixture's spies with the newfixture.restoreDoubles().mock.restore()and themockimport are gone.tests/helpers/retained-routing-adoption.ts: addsrestoreDoubles()(restores this fixture's spies only, andcleanup()uses it too) anddoublesInstalled().tests/native-compose-adoption-file-generation.test.ts: theafterEachbody is now the namedreleaseRetainedFileFixture()(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 towithOwnedProcessListenersfor tests that emit a real signal.Tests
tests/global-state-guard.test.tsrestoreProcessListenersremoves two leftovers (onandonce), adds back a removed snapshot listener, reports only the changed event, and is a no-op the second time. A keptoncelistener still removes itself on a realprocess.emit("SIGUSR2"). This runs insidewithOwnedProcessListeners("SIGUSR2").bun testsuite getsc2-leak-signal-listener.test.ts, which leaves a module-scopeonceSIGUSR2 listener.d-later.test.tsgets a test that emits SIGUSR2 around its own listener. With the guard, the run namesc2(process 'SIGUSR2' listeners changed (0 -> 1), plus the restore message), the later test passes, nothing blamesd-later.test.ts, and 3 files are reported.tests/loki-short-lived-logs.test.ts: newLoki follow drops its SIGINT listener through a real SIGINT emit. InsidewithOwnedProcessListeners("SIGINT"), it opens a follow (exactly 1 SIGINT listener), callsprocess.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-throughspyOn(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 refusedcleanup()restores HACK_HOME and keeps the fixture's doubles, and thatrestoreDoubles()removes them.tests/native-compose-adoption-file-generation.test.ts: newan unknown lifetime still restores PATH before the teardown refuses. It drivesreleaseRetainedFileFixture()with a retained (unknown) lifetime and checks: refusal,uncertainset, PATH back to its pre-case value, spawn spy kept. Its own lifetime then ends normally inafterEach.tests/native-config-protected-files-fixture.test.ts: the closed-pipe survivor negative checks thatprocess.killis back to its pre-test function right after the veto'ssignal.mockRestore().Mutation proofs (each applied and reverted with a script; Bun 1.4.2)
restoreProcessListenersd-later.test.tsis blamedrestoreProcessListenerskeeps leftoversrestoreProcessListenersnever adds back missing listenersrestoreProcessListenersdoesremoveAllListenersand re-addsonceturns persistent)process.oncebecomesprocess.on(thefinallyoffstays)stop()finallyoff()removedoncewrapper removes itself)mock.restore()againreadPrivateis no longer the foreign spyendRoutingFixtureskipsreleaseEnvironment()for an unknown lifetimefixtureUncertain = true) before the casenextit ran anywaycleanup()restores HACK_HOME only after the lifetime checksignal.mockRestore()expect(process.kill)(the guard also reportsprocess.kill replaced)Evidence
mise x bun@1.4.2), this Mac, with privateHOME,HACK_HOMEandDOCKER_CONFIGandHACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK=1.bun run --cwd packages/cli typecheck: 0 errors. Rootbun run check: passes (privacy-check ok; only existing warnings).ultracite checkon the changed files: clean, apart from one existing complexity warning attests/helpers/retained-routing-adoption.ts:250(unchanged code).gitleaks dirover agit log -p origin/next..HEADexport): no leaks.tests/global-state-guard.test.ts8/8;tests/loki-short-lived-logs.test.ts21/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).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: theorigin/nextcopy of the file fails the same way on this host.061638b6(the PR head):testpasses (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.docs:cli-referenceorgenerate:agent-pluginsregeneration.Gaps
withOwnedProcessListenersfor real emits.withOwnedProcessListenersandrestoreProcessListenersbring a removed listener back as a persistent one: Bun does not exposeonce. Between tests the guarded events normally have no listeners.closed-pipe survivornegative failed once on an existingtoBebefore reaching its new assertion (most likely its 2.5 s descendant-join deadline). This is existing load sensitivity and is not changed here.packages/dbrunning without the rootbunfigpreload, is info only and stays unchanged.Refs HACK-1416, HACK-1388
🤖 Generated with Claude Code