0142 - A run waits until no extension has pending work, and an entry still pending then fails the run
Date: 2026-10-15
Area: engine, runtime, cli
Related: ADR 0138, ADR 0113, ADR 0124, ADR 0125
Context
A module entry whose first top-level await settled at once, followed by
await fetch(url), ended right after the request started, with exit 0
(#1559). That happened
on every path through TGocciaEngine.Execute: --copy and --sandbox, and
host mode while the tree-walk interpreter still existed. Execute runs the entry as an async
module, so the rest of its body runs as a job in the run's final
WaitForRuntimeIdle. That wait ran each extension's WaitForIdle once,
before it drained the microtask queue. A fetch started by a job in that drain
had nothing left waiting for it, so the run ended and the request was
discarded. The entry's evaluation promise was still pending, and Execute
reported that as success. A host-mode run, which compiles the entry itself,
worked only because it compiles top-level await as a synchronous await that
pumps fetch itself.
ES2026 §16.2.1.6.1.3 Evaluate() returns the module's evaluation promise;
§9.5 leaves it to the host to decide when its run is finished.
Decision
Extensions report pending work, and the idle wait repeats until none does.
TGocciaEngineExtension and TGocciaRuntimeExtension gain a virtual
HasPendingWork, which returns False by default. TGocciaRuntimeCore returns
True when any of its extensions does. WaitForRuntimeIdle runs a round
(every extension's WaitForIdle, then the microtask and Atomics.waitAsync
drain) and repeats it while any extension reports work.
- The fetch extension reports a request in flight for its realm.
- The timers extension reports a real-mode timeout its host drain would run. Once the drain's loop limit has stopped it, it reports nothing, so a timer that keeps rescheduling itself still ends the run.
Every run path shares this one wait, so Execute needs no special case.
The contract for an extension is: return True only when WaitForIdle will
block on that work or make progress on it. A True that WaitForIdle does
nothing about spins the engine thread. An extension that does not override
the hook keeps the old single pass, so one that can gain work after a
microtask drain must implement it.
An entry still pending when the run is idle fails the run, on every path.
Nothing left in the run can settle it. Execute first reports the oldest
unhandled rejection the usual way (ADR 0124,
ADR 0125): a reaction that threw instead
of settling what the entry awaits is the real cause, and under urThrow it is
the run's error. Otherwise it raises
TypeError: await: Promise did not settle after microtask drain, which a
host-mode run already raised at the await itself. A rejected entry still raises its
own reason.
The idle wait waits out Atomics.waitAsync timeouts in real time.
It waits for the running engine's waiters that have a timeout or were already
notified. A waiter with no timeout can be settled only by Atomics.notify,
which nothing left in an idle run will call, so the wait does not block on it.
An engine nested on the same thread, such as a sandbox runScript child, does
not wait out its caller's waiters. Extension work comes first: before the wait
sleeps for a waiter, a job that started host work sends the loop back to the
extension passes. The fetch extension's wait settles a waiter that falls due
while a request is in flight on each poll, so neither the request nor the
waiter waits for the other. The wait sleeps 1 ms
between polls and checks the run deadline and the instruction budget each
time, as every other engine wait does, so a host stop ends it promptly.
Timers stay virtual (ADR 0113):
no real time passes for them.
Considered options
- Wait on the entry promise in
Executewith the synchronous await's helper. That fixes an awaited fetch, but not afetch().then()started after the first await, whose request still ends in flight. - Repeat the extension pass whenever the microtask drain did work. That misses work a job starts inside an extension's own drain: the fetch extension's idle wait drains microtasks itself when no request is in flight, so the engine's drain then finds nothing to do.
- Report a pending entry as success, as before. A run that stopped partway looked like a passing one.
Consequences
- A
--copy,--sandboxor embedderExecuteentry whose top-levelawaitcan never settle now exits non-zero instead of 0. A host-mode run already did. - A reaction that throws while the entry waits reports that error on those
paths. A host-mode run still reports the generic
TypeError, because its synchronous await fails before any rejection is reported. - An embedder's extension with host work of its own must implement
HasPendingWorkto have that work waited for after a late job starts it.