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:
parent
7ee8a14e56
commit
eff0368491
3 changed files with 102 additions and 50 deletions
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Reference in a new issue