fix: address Copilot review round 2, and drop the asyncUtilTimeout raise

The inline finding was a real bug: main() never called the discovery check, so
the deny-by-default claim rested on test:scripts happening to run the same
comparison — a different command, which `npm run verify:test-timeouts` does not
invoke. `failures` is now seeded from it, and a probe config under clients/ was
observed to fail the guard.

Three suppressed findings, all correct. The afterEach in oauth-interactive
restated the shared hookTimeout and labelled it as a round trip, when it only
stops a server and unlinks a file; it now carries no budget. The file-lock
constants were named for the opposite of what they bound — 90s covers retries
running out against a holder that never yields, 60s covers the stale-lock
takeover that succeeds — so they are LOCK_RETRIES_EXHAUSTED_MS and
STALE_LOCK_TAKEOVER_MS. The browser-timeouts header said 15 budgets; it is 19.

Separately, and more importantly: the asyncUtilTimeout raise is reverted, on
measurement. Verifying round 2 turned up intermittent 15s timeouts across
unrelated unit files, so three arms of three full unit runs were taken in this
worktree at comparable load — unmodified v2/main 3/3 green, this change with
5000 producing 1, 4 and 0 timing-out files, and the same change with that one
line reverted 3/3 green. A wait that is MEANT to expire spends its whole budget
on the happy path, so a 5x raise is a 5x cost on exactly those tests and
saturates the worker pool, starving tests that were never slow.

Both web setup files therefore state asyncUtilTimeout at the library's own
value rather than raising it, which is what #2323's rule actually asks for: a
budget nobody chose is the defect, not a budget that is small. The tests that
assert absence by letting a wait expire are the real problem and are #2335.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-09-12 11:49:30 -04:00
commit eff0368491
3 changed files with 102 additions and 50 deletions

View file

@ -37,10 +37,19 @@ import {
resetFileLockWarnings,
} from "@inspector/core/auth/node/file-lock.js";
/** A test that waits out a full stale-lock takeover in a second process. */
const LOCK_TAKEOVER_MS = 90_000;
/** A test that waits on cross-process contention but not on a stale window. */
const LOCK_CONTENTION_MS = 60_000;
/**
* A test that waits for `proper-lockfile`'s retries to run out against a holder
* that never yields — a live second process, or a stale lock it cannot clear.
* Nothing is taken over here; the budget is spent in full by construction,
* which is why it is the longer of the two.
*/
const LOCK_RETRIES_EXHAUSTED_MS = 90_000;
/**
* A test that waits out the stale window and then *succeeds* by taking the dead
* holder's lock over. Shorter, because it ends the moment the takeover lands
* rather than when the retries stop.
*/
const STALE_LOCK_TAKEOVER_MS = 60_000;
import { FileSecretStore } from "@inspector/core/auth/node/file-secret-store.js";
import { SecretStoreUnavailableError } from "@inspector/core/auth/node/secret-store.js";
@ -319,7 +328,7 @@ describe("withSecretFileLock degrades rather than failing", () => {
// Nothing was written behind the holder's back.
expect(existsSync(target)).toBe(false);
},
LOCK_TAKEOVER_MS,
LOCK_RETRIES_EXHAUSTED_MS,
);
it(
@ -354,7 +363,7 @@ describe("withSecretFileLock degrades rather than failing", () => {
expect(existsSync(target)).toBe(false);
expect(warnings()).not.toContain("not protected");
},
LOCK_TAKEOVER_MS,
LOCK_RETRIES_EXHAUSTED_MS,
);
it(
@ -384,7 +393,7 @@ describe("withSecretFileLock degrades rather than failing", () => {
// Took the lock over — did not fall through to an unlocked write.
expect(warnings()).toBe("");
},
LOCK_CONTENTION_MS,
STALE_LOCK_TAKEOVER_MS,
);
it(
@ -415,7 +424,7 @@ describe("withSecretFileLock degrades rather than failing", () => {
// …and the entry it could not delete is still there, not half-removed.
expect(await store.get("srv", "env:A")).toBe("1");
},
LOCK_TAKEOVER_MS,
LOCK_RETRIES_EXHAUSTED_MS,
);
});

View file

@ -2,24 +2,37 @@ import "@testing-library/jest-dom/vitest";
import { afterEach } from "vitest";
import { cleanup, configure } from "@testing-library/react";
// Testing Library's own `asyncUtilTimeout` default is 1000ms
// (`@testing-library/dom/dist/config.js`), and it governs every `waitFor` and
// `findBy*` in the unit project — 788 call sites — none of which chose it. One
// `configure` call is the whole surface, which is why it is here rather than
// argued a site at a time (#2323).
// Testing Library's `asyncUtilTimeout` governs every `waitFor` and `findBy*` in
// this project — 788 call sites — and is the tighter of the two bounds on any
// async assertion here, so it is stated rather than inherited (#2323). Pinning
// it also means a Testing Library upgrade that changes its default cannot move
// every one of those sites silently.
//
// 5x, because a contended happy-dom render is the worst-measured case on this
// team's machine (8 logical cores, three or four concurrent worktree sessions
// each free to run the full `local:gate`), and because once the project's
// `testTimeout` rises to 15000 this becomes the *binding* constraint on every
// async assertion in the project — the enclosing budget stops being the thing
// that cuts a wait short and this starts being it.
// ⚠️ The value is 1000 — Testing Library's own default — and that is a
// MEASURED decision, not a shrug. #2323 proposed 5000 on the reasoning that a
// contended happy-dom render is the worst case and that this becomes the
// binding constraint once the project's `testTimeout` rises. Measured on this
// machine, 5000 made the suite WORSE: three arms of three full unit runs each,
// same worktree, comparable load —
//
// The trade-off, stated rather than hidden: a genuinely-failing async
// assertion now takes 5s to report instead of 1s. Only failing assertions pay
// it, once each, on runs that are already the slow path. A *passing* `waitFor`
// returns the instant its callback stops throwing and is unaffected.
configure({ asyncUtilTimeout: 5000 });
// • unmodified `v2/main`: 3/3 green
// • this change with 5000: 1, 4 and 0 files timing out at 15000ms
// • this change with the default: 3/3 green
//
// The failures were a different, unrelated set each time and every one passed
// in about a second in isolation, which is the shape of CPU starvation rather
// than of a slow assertion. The mechanism is that `asyncUtilTimeout` is not
// only a ceiling: any wait that is *meant* to expire — a test asserting
// something never appears, and anything that lets a poll run out — spends the
// whole budget on the happy path, so a 5x raise is a 5x cost on those tests,
// which saturates the worker pool and starves tests that were never slow.
//
// So the general rule this repo now follows — a budget must be a value someone
// chose — is satisfied by choosing it, not by raising it. Raising it is what
// #2323's own "do not raise" list would have said had the measurement been
// taken first. The tests that assert absence by letting a wait expire are the
// real defect and are tracked on #2335; when they are gone, revisit this.
configure({ asyncUtilTimeout: 1000 });
// Node 22+ exposes an experimental `localStorage` placeholder that overrides
// happy-dom's implementation. Without `--localstorage-file`, it's an empty

View file

@ -1,12 +1,17 @@
/**
* Setup file for the `storybook` Vitest project (#2323).
*
* It exists for one call. Storybook's instrumented Testing Library re-exports
* the same `asyncUtilTimeout: 1000` default as the unit project's, and it
* governs the 80 `waitFor` / `findBy*` sites across the story files. Those play
* functions run in a real Chromium against a real Storybook preview, so 1000ms
* is the *tighter* of the two bounds the project has — the per-test ceiling is
* 15000 (#2292) — and it is the one nobody chose.
* It exists for one call. Storybook's instrumented Testing Library carries its
* own `asyncUtilTimeout`, and it governs the 80 `waitFor` / `findBy*` sites
* across the story files. It is the *tighter* of the two bounds this project
* has — the per-test ceiling is 15000 (#2292) — so it is stated here rather
* than inherited, which also pins it against a Storybook upgrade changing the
* default under all 80 sites at once.
*
* The value matches the unit project's, and for the reason recorded in
* `setup.ts`: raising it was measured and rejected. A wait that is *meant* to
* expire spends its whole budget on the happy path, so a raise is a
* proportional cost on exactly those tests.
*
* ⚠️ Two things about this file that are deliberate and easy to undo:
*
@ -27,4 +32,4 @@
import { configure } from "storybook/test";
configure({ asyncUtilTimeout: 5000 });
configure({ asyncUtilTimeout: 1000 });