# Test waits: rationale and case studies [waiting-in-tests.md](waiting-in-tests.md) is the working guidance for waiting in tests: the primitives that resolve on real events, the harnesses that control time, the check that keeps new polling `waitFor` out of the integration suites, and the catalog of places where a bounded poll stays. This document holds the analysis behind that guidance: the full argument for why a bounded timeout is never a guarantee, the sizing of the one backstop that argument bears on hardest, how the runner clock classifies timers across SES lockdown, the real-clock exemptions that were retired and what each hang turned out to be, why the runtime-client suite keeps the real clock, why neither runtime-disposal teardown carries a bound, worked examples studied in enough depth to copy — proving a negative in the CSP suite and the FUSE exec suite's design — how a pattern-test assertion comes to be read exactly once, and the production waits that apply the same principle outside tests. Nothing here is needed to write an ordinary test. Come here when you need to know why a rule is what it is, or before changing the machinery a rule describes. ## Why a bounded timeout is never a guarantee This is the full argument behind [Wall-clock time is not a measure of progress](waiting-in-tests.md#wall-clock-time-is-not-a-measure-of-progress): why no fixed bound, on any clock, can tell "stuck" from "everyone was stopped." A timeout counts wall-clock time, and wall-clock time diverges from real progress whenever the world outside the process pauses. A laptop is closed and reopened. A CI virtual machine is paused for an hour of host maintenance, or is live-migrated to another host, and resumes where it left off. A container is frozen by the cgroup freezer or a checkpoint. The clock is stepped by NTP. Each of these advances wall time — sometimes by a large, arbitrary amount — while the timed operation did not run at all, so a timeout fires on it exactly as it would on a genuine hang. This is a stronger objection than "the machine might be slow, so pad the bound." Slowness you can pad against; a clock discontinuity you cannot, because no fixed bound survives an arbitrary jump — a suspend one second past the deadline trips a fifteen-second bound as surely as a one-second one. A bound sized "comfortably above how long the operation ever takes" is therefore safe against slowness only, not against this. The exposure also turns on details you do not control: GNU `timeout` arms a `CLOCK_REALTIME` timer, which counts suspend time, so the bound fires on resume; a `CLOCK_MONOTONIC` bound would survive a suspend-to-RAM but not a frozen process whose system clock kept running. No clock is safe against every kind of pause. So a bounded timeout is never a guarantee, only a heuristic with a real false-positive mode. That is what grounds the acceptability test [the main document](waiting-in-tests.md#wall-clock-time-is-not-a-measure-of-progress) states — not "is the bound comfortably large" but "is firing early safe" — and the two kinds it sorts bounds into: the harmless early fire, which only repeats work, and the harmful one, which fails a passing test, drops a real result, or corrupts state. Those two kinds map onto the bounds the repository keeps. The shutdown escalation in [the FUSE mount handshake](#the-fuse-mount-handshake) keeps a bound whose early fire is harmless — it `SIGKILL`s a child that was already exiting, reaching the same end either way. The rest — the polling waits under [Where the polling `waitFor` stays](waiting-in-tests.md#where-the-polling-waitfor-stays) and [the FUSE cleanup's teardown bound](#cleanup-hard-kills-on-a-failure-unmounts-gracefully-on-a-pass) — keep a bound whose early fire fails the run. They exist because no event reports the condition they wait on, so a large-enough clock jump can trip one on a healthy run and fail it. That is a fragility we accept for want of an alternative, sized so only a multi-minute jump reaches it — not one we have designed away. The deno-web-test per-test stuck detector ([next section](#sizing-the-deno-web-test-backstop)) is another bound of this kind, and the most exposed: a competing ceiling keeps it from being sized that high. When an event boundary does exist, use it, and neither kind of exception arises. ## Sizing the deno-web-test backstop [Browser-hosted unit tests have a harness backstop](waiting-in-tests.md#browser-hosted-unit-tests-have-a-harness-backstop) describes the `testTimeout` bound `packages/deno-web-test` places on each test. This section records what the bound replaced and the competing ceiling that sizes it. Without the harness bound, a stuck test ran until astral's retried deadline on `page.evaluate` ran out of attempts, 53 to 57 seconds later, and threw a `RetryError` that named no test, printed no summary, and abandoned every test file still queued. That deadline is on every `page.evaluate` a browser test makes, not only the ones this harness issues, so it is worth knowing what it comes to elsewhere. Astral wraps the protocol call in `retry(() => deadline(call, timeout))` over a promise it creates once, so the five attempts re-wait on that same in-flight call rather than reissuing it: the evaluate runs once, and an answer that arrives late is still returned by whichever attempt is live for it. What differs between harnesses is the timeout. `deno-web-test` leaves astral's own ten seconds, which is where the fifty-second floor above comes from. `packages/integration`'s `Browser.launch` writes sixty seconds onto the page instead, so an evaluate the browser integration suites make ends after five minutes of deadline plus up to fifteen seconds of the retry's backoff. Neither is a bound this repository keeps, and neither wants a second one underneath it. By the test in [the section above](#why-a-bounded-timeout-is-never-a-guarantee), the harness bound's early fire is not safe: it fails a passing test. So it is a bound kept for want of an alternative, alongside the polling waits and the FUSE teardown bound — there is no event for "this test will never finish." It is the worst-placed member of that group, and the reason is worth stating plainly. Those other bounds sit so far above their work that only a multi-minute clock jump reaches them. This one cannot: astral's own deadline runs out around fifty seconds and takes the run down unnamed, so the bound has to fire below that, and a clock jump between the bound and fifty seconds fails a healthy test. Astral's retry would have ridden that jump out — it re-wraps the same evaluate across five attempts, so a test that finishes late is still returned — where this single timer does not. The bound is kept only because astral's un-named, whole-run failure is the worse outcome on a genuine hang, not because it escapes the clock-jump fault. That trade is what sets the default, and it sets it high rather than low. The window in which this bound fires but astral would not is exactly the gap between the bound and astral's floor, so the bound wants to sit as close under that floor as reliable naming allows — the opposite of the "leave a wide margin" instinct, which here only widens the exposure. Astral's floor is a hard fifty seconds, five ten-second timers that no machine runs through faster, so forty seconds clears it with room for the retry's backoff on top while keeping the clock-jump window down to about ten seconds. The slowest healthy test in any of these suites is about a second, and that one is deliberately waiting out a timer, so real work never approaches forty. A suite that somehow needs more should raise `testTimeout`, and keep it under the fifty-second floor. ## How the runner clock classifies timers across SES lockdown [The runner suite's fake clock](waiting-in-tests.md#the-runner-suite-advancing-the-runtimes-own-timers) sorts each positive-delay `setTimeout` by who scheduled it, reading the scheduling frame from a stack trace — and that has to keep working after a runtime is running. SES's `errorTaming` blanks `new Error().stack` from the first `Runtime` a test builds onward — safe taming still captures each error's frames but hides them behind the tamed `stack` accessor, which reads back empty for the rest of the process. So the harness does not read the frame that way. It reads it through `getStackString`, the hook SES installs on the global during lockdown, which still returns the real frames after the plain accessor has gone empty; the runtime's own error mapping reads stacks through the same hook. A positive-delay `setTimeout` written in test code is therefore classified as a `test/` timer and freezes across the lockdown boundary, exactly as it does before any runtime exists. ## The runner clock: retired exemptions and converted waits The runner preload's `realClockFiles` list names each file that stays on the real clock and its reason; [the main document](waiting-in-tests.md#the-runner-suite-advancing-the-runtimes-own-timers) describes why an entry belongs there. The histories below record entries and wall-clock waits that were retired rather than justified. Read them before adding a new entry: each of these looked timing- or transport-bound from the failure, and was not. The list used to carry two more entries, for a reason worth recording because it shows what a production deadline costs a test suite. Both held tool-calling cases whose delegate runs a child agent against a result schema the model supplies in the tool input, so the child could not form its own request until that input had settled through the graph, and the round trip carried the delegate's completion across a macrotask boundary. The pump read that boundary as an idle event loop and jumped logical time to the earliest pending production timer, which was the deadline the tool-calling path armed around its wait, and the delegate aborted while its child was still in flight. Retiring that deadline retired the exemptions with it. ([Retiring the LLM Tool-Call Deadline](../history/development/proposals/retiring-llm-tool-call-deadlines.md) is the archived proposal.) A frozen wall-clock guard converts rather than exempts. [The main document](waiting-in-tests.md#the-runner-suite-advancing-the-runtimes-own-timers) notes that a `setTimeout(reject, ms)` guarding a wait freezes along with every other test sleep and backstops nothing; the remedy is to resolve the wait on the event itself and let a signal that never arrives quiesce the loop. That is what let the multi-space mergeable-commit test move onto the fake clock — its retry backoff is a `src/` timer that auto-advances, and the fast-fail-versus-windowed distinction it checks is decided by the rejection's error type rather than by elapsed time, so collapsing the backoff timing preserves the outcome each case asserts. A third exemption was retired rather than justified. The `list-resume-container-defer` suite looked transport-bound — a resuming runtime over a loopback memory client that never settled under the fake clock — but the hang was the test's own design. Its transport withheld the result-container document from the resuming client's syncs while the server still held it, a state no client can reconcile: every commit carrying the client's read of that document as absent at seq 0 is rejected as stale, the catch-up the rejection waits on can never deliver the withheld document, and the retry loop runs forever. On the real clock the test passed anyway, because the server's effect-batching timer left short quiet windows in each retry cycle in which `runtime.idle()` resolved and the test read its locally recovered value; the fake clock's auto-advance closes those windows, so the wait for quiescence never returned. The fix modeled the scenario the suite claims — a container that was never persisted — by redirecting the container's operations out of the first runtime's commits, so the server genuinely never stores the document, the resume's seed write is accepted, and the system settles on both clocks. The suite's sequence tests do still withhold the server's answers about that one document — but bounded, and released within the test: the hold proves the recovery waits for the absence confirmation before writing, and it ends before any retry can accumulate against the withheld answer. ## Sizing the auto-advance runaway ceiling Auto-advance turns a `src/` backoff into a hot loop whenever the condition it retries against cannot change — a withheld document, a commit that will be rejected the same way every round. Each round arms a fresh timer, the pump fires it on the next turn of the event loop, and the cycle allocates. Left to run it ends as a V8 out-of-memory abort, which names no test and leaves nothing to read. The ceiling therefore has to sit below the point where such a loop exhausts the heap, not at a round number far above it. Measured across the runner suite, the heaviest user of auto-advance, one test reaches 160 fires and every other one stays under a hundred, so a ceiling an order of magnitude above that separates a livelock from every healthy test while still tripping long before memory runs out. The error names the arming site that accounted for most of the fires, which is the frame worth having: in a stalled resume it is the storage layer's conflict repair or the memory server's refresh scheduler, and either one identifies the condition that cannot clear. A test that legitimately advances through many windows should say so with `clock.tick(ms)` rather than lean on the pump. One whose work logical time cannot pace at all belongs in `realClockFiles`. ## The pump waits for the loop to go idle [The runner clock](waiting-in-tests.md#the-runner-suite-advancing-the-runtimes-own-timers) fires a production timer only once every zero-delay turn that was armed has run. What that ordering protects is any wait that races an event against a deadline where the event arrives across several turns and the deadline is one `src/` timer. The conflict read-repair wait in `packages/runner/src/storage/v2.ts` is that shape. A commit the server refuses for a stale basis carries a retry gate, the caught-up frame the server stages for the session, and the storage layer holds the rejection until that frame has landed — or until a 30 s backstop lets it through with the replica still behind, logging one warn its module logger sits above. The frame crosses the loopback transport a turn at a time, and the memory server's refresh takes turns of its own, so at the moment the rejection arms the backstop the frame is still several turns away. A pump that fired the earliest production timer on the next turn of the real event loop, whatever zero-delay turns were still queued, fired that backstop between those turns. Instrumenting the race across the runner suite found 371 such waits: 33 of them the backstop arm won, each with exactly 30 s of logical time elapsed and the frame landing at that same instant, queued behind the jump, and 12 more the caught-up arm won only because a sibling wait's jump had already moved the clock. Every one of those tests passed. One, the refused `fetchProgram` takeover, passed only because the jumps had aged a claim that the test's own `clock.tick` was written to age; with the turns run first, the same test hung on an event its fixture never produced — which is the failure its design had always described, and the test now ages the claim before the run that reads it. So the pump yields. When a zero-delay turn is armed, or a kick is queued to run one, it re-queues itself behind the kick and looks again, and it jumps only when no turn is left. A turn chain that never ends starves the pump rather than hanging the test: past `AUTO_ADVANCE_STARVATION_LIMIT` consecutive yields it throws, naming the condition, as `settle()` does for zero-delay work that regenerates. `packages/runner/test/clock-preload-auto-advance.test.ts` pins the ordering, and the SES-lockdown classification pin waits on the `src/` sleep it arms rather than on one turn, because one turn no longer carries a jump. Two convergence tests drained in fixed rounds of round trips — `pull()`, `idle()`, `synced()` — and read the value at the end. Such a loop never lets the loop go idle, so under the idle rule the memory server's refresh never fired inside it and the value never arrived; on the real clock the refresh fires on wall time, in the middle of whichever round is running, which is the dependence the fixed rounds had been riding. Both now wait on the value they converge to, through `waitForCellValue`, which idles the loop between committed changes and so fires the timers the convergence rides on. The pump also fires every production timer armed for the instant the clock stands at: two armed together for one instant both fire, where the second had been passed over as already past, and the same pin file holds that. The ordering makes the read-repair wait resolve on the frame, at one refresh cadence of logical time. It does not make the backstop's firing visible when the frame genuinely never comes; that is the silent-backstop guard's job, described in [the main document](waiting-in-tests.md#a-production-backstop-cannot-carry-a-test). ## Why the runtime-client suite stays on the real clock `packages/runtime-client` keeps its unit tests on the real clock, and the reasons are worth recording because the package looks, at a glance, like a candidate for the runner preload. [The main document](waiting-in-tests.md#the-runtime-client-suite-stays-on-the-real-clock) states the working rules; this is the analysis behind them. Most of its tests need no wait at all, or only a macrotask drain — a `setTimeout(fn, 0)` that lets the scheduler's own `queueTask` dispatch land, an awaited round-trip, or a sink fire once. Those drains cross one macrotask boundary, carry no real-time floor, and stay as they are. The two positive-delay sleeps that were not drains have been made event-driven instead. The test that proves `dispose()` stays pending until the worker confirms its flush now drains the task queue rather than sleeping ten milliseconds: nothing resolves the held Dispose reply, so once every queued continuation has run a correct dispose is still pending. The pending-request age-ordering test steps a frozen `performance.now()` forward between its two sends rather than sleeping, so the first request is measurably older and the descending-age sort is exercised deterministically — with equal ages a stable sort preserves insertion order whichever way the comparator runs, leaving the direction untested. Two tests genuinely measure real time and cannot move to a fake clock: the main-thread loop-lag probe (`loop/mainLag`, armed in `client/connection.ts`) and its worker twin (`runner.loop/workerLag`, armed at the `backends/web-worker` entry). Each arms a 100-millisecond `setInterval` and records how far past schedule a tick fires; each test blocks the thread with a busy-wait past one sample so the due tick can only fire late, then reads the recorded lag. A fake clock fires timers exactly on schedule, never late, and cannot advance while a synchronous busy-wait holds the thread, so it cannot reproduce loop lag at all. These stay on real wall-clock time, and their busy-wait plus short trailing yield is the honest way to observe a late fire. The package also cannot adopt the runner preload wholesale. Those two loop-lag intervals are armed unconditionally — in the connection constructor and at the worker entry's import — and only unref'd, not gated behind an off-by-default flag the way runner's one repeating timer is. Unref keeps them from tripping Deno's op-leak sanitizer under the real clock, but auto-advance ignores unref: a `setInterval` scheduled from `src/` re-arms forever, so every connection a test builds would drive the clock to the runaway guard. Adopting the harness would mean excluding the very files that own timers, and no test in the suite observes a controllable time window a fake clock would help with. ## The runtime disposal teardowns [Tearing a runtime down waits on its worker](waiting-in-tests.md#tearing-a-runtime-down-waits-on-its-worker) states what the two teardowns do. This is the analysis behind the part of it that looks like an omission: the failure each is exposed to is a worker that answers nothing, and neither carries a clock. The browser half needs none because it already has one, astral's, described in [the section above](#sizing-the-deno-web-test-backstop). Not capping a slow call is what makes that bound tolerable, and the property is astral's rather than ours. So what the browser half was missing was never a bound but a name. A `RetryError` names no request, and the teardown that catches it is cleanup, so a run ends with a warning that says a disposal failed and nothing about why. Reporting the page closes that, because `RuntimeClient.getPendingRequests` is main-thread bookkeeping: the request records live on the page's side of the boundary, and reading them asks the worker for nothing. A worker that has stopped answering is the case the read still answers, and what it answers with is the disposal's own request and its age. The in-process half is the one where a bound is arguable, and the argument is worth setting out because the first two objections to it are weaker than they look. The first is that an early fire drops writes. It would: the reply the teardown waits for is the worker's confirmation that it has flushed, so terminating the worker before that reply arrives loses whatever was still buffered. How much that is, the main thread cannot tell. The worker flushes before it replies, so a reply that never arrived leaves every state open: nothing written, part of it, or all of it with the reply lost afterwards. What narrows the objection is not how far the flush got but what terminating adds to it. A worker that is genuinely stuck writes nothing further whether it is terminated or left alone, so a bound firing on one costs only the wait it ends. The exposure is the slow-but-healthy disposal — a large flush, a contended machine, a clock jump — which a bound would kill mid-flush, losing writes that were still on their way. In this suite even that is local: each test opens a space under a fresh random name and disposes as the last thing in its scope, so no later read reaches the writes a killed flush dropped. By the test in [Wall-clock time is not a measure of progress](waiting-in-tests.md#wall-clock-time-is-not-a-measure-of-progress), an early fire there is safe. The second is the cost of writing it. The teardown is `dispose()` itself, reached through `Symbol.asyncDispose` at every `await using` in the file, so a bound has nowhere to sit that is neither production code — which would put a clock back on the path production deliberately does without — nor a change to what the file's helper hands back at every one of those bindings. What settles it is neither of those. It is that a hang there is already placed, and a bound adds one fact to that. Deno's runner prints a test's name when it starts and its verdict when it ends, so the last test printed without an `ok` is the one whose teardown is waiting, and what it waits for is a `Dispose` reply by construction — that is the only request a teardown sends. A bound would add the age of the wait, which the step's own duration already gives, and it would add a timer on a cleanup path to do it. The step limit ends the run either way. The runner prints that name unbuffered only while it runs the file's tests in sequence, which is what this package's test task does; `--parallel` withholds it. Two claims there were checked rather than assumed, because the whole argument rests on them. A live `Worker` holds Deno's event loop open, so the fail-fast that [the primitives section](waiting-in-tests.md#the-primitives-to-use-instead) relies on for in-process waits does not fire: a test awaiting a promise nothing resolves fails on its own in milliseconds, and the same test with a live `Worker` beside it runs until it is killed. That run's output ends at the test's name followed by its `...`, which is the line a reader places the hang by. ## Proving a negative: the CSP suite as a worked example [Proving a negative](waiting-in-tests.md#proving-a-negative) gives the shape: send a barrier that must arrive after the event being ruled out, wait for the barrier, and pair the conversion with a control that can fail. The iframe-sandbox CSP suite (`packages/iframe-sandbox/test/iframe-csp.browser.test.ts`) is the worked example of how much checking both requirements take. The barrier has to be genuinely ordered after the event, which is a claim about the specific mechanism and not about elapsed time. The CSP suite is a good illustration of how far that varies for one browser and one policy: a blocked `` or `` reports its violation before the document's load event, while a blocked `