# 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 `