0171 - The wait that drives one promise to settlement runs extensions' host work
Date: 2026-10-31
Area: engine, runtime
Related: ADR 0142, ADR 0113, ADR 0148
Context
An embedder's runtime extension can expose an async API whose promise it
settles from WaitForIdle, reporting HasPendingWork until then
(ADR 0142).
Awaited at the top level of a module, such a promise settles: the run's idle
wait calls the extension. Awaited inside a goccia:test test(), it failed
with Promise still pending after microtask drain
(#1556). The test
library drives an async test's returned promise, .resolves/.rejects and
async hooks to settlement with WaitForFetchPromise, and a synchronous
await uses it too. That wait knew fetch, Atomics.waitAsync and real-mode
timers, and nothing an extension owns.
Decision
WaitForFetchPromise runs the running engine's extension work once no
fetch is in flight, before it waits on an Atomics.waitAsync waiter or runs a
timer. TGocciaEngineExtension and
TGocciaRuntimeExtension gain a virtual RunPendingWorkForPromiseWait. Its
default runs WaitForIdle when HasPendingWork is True and returns whether
it was. TGocciaRuntimeCore forwards it to each of its extensions. The engine
installs a hook (GPromiseWaitHostWork in Goccia.FetchManager) that calls
it on every extension of the innermost running engine on the thread. When any
had work, the wait drains microtasks again before it checks anything else.
-
After fetch. An extension's
WaitForIdlemay block until its work ends, and a script may end that work only after a fetch it awaits settles. Run while the fetch was in flight, it would hold the fetch until the extension gave up. Fetch completions are only polled on this thread, so the extension's work waits for them instead. -
Before
Atomics.waitAsyncwaiters and virtual time. A waiter with no timeout keeps that part of the wait polling indefinitely, and real work outranks virtual time, as for fetch. -
Fetch and timers opt out. The wait already polls fetch completions and runs real timers one at a time, in an order chosen so that a fetch raced against a timeout decides the race. Their
WaitForIdlewould wait for every request, or run virtual time ahead of outstanding work, so both extensions return False. -
The host-call owner keeps its checkpoint. With
OnPauseCheckpointset, the owner's work is left for the idle wait, which offers the pause checkpoint before it calls the owner (ADR 0148).
Considered options
- Run
WaitForRuntimeIdlefrom the promise wait. It runs every extension, timers and fetch included, and offers pause checkpoints in the middle of a test. - A separate hook the test library calls. A synchronous
awaitreaches the same wait and has the same gap. - A non-blocking step as the default.
WaitForIdleis allowed to block on its work (ADR 0142), and an extension that never overrides anything would then never be waited for here, which is the failure this decision fixes. - Run the extension work before polling fetch. A test that starts host work, awaits a fetch and only then ends the host work would block in the extension until it gave up.
Consequences
- An extension that settles its promises from
WaitForIdleworks insidetest(),.resolves/.rejects, async hooks and a synchronousawait, with no change to the extension. - An extension whose
WaitForIdleshould not run there, as for fetch and timers, overridesRunPendingWorkForPromiseWaitto return False. - With a pause-checkpoint handler installed, a host call awaited inside a test still is not served by the promise wait.
- While any fetch is in flight, an extension's work waits for it, even when the awaited promise is the extension's own.
- The default runs the whole
WaitForIdle, which settles everything the extension has. An extension whoseWaitForIdleblocks on one piece of work that only the script can end (until it gives up) would block a test that awaits another piece first, exactly as the idle wait does at the top level of a module. Such an extension overridesRunPendingWorkForPromiseWaitto settle the work that is ready and return, waiting only when none is; the wait calls it again after the script has run.