0123 - A nested engine run gets its own microtask scope
Date: 2026-10-01
Area: runtime, sandbox
Context
The microtask queue is one object per thread, and its jobs had no owner.
Engines nest on a thread: a sandbox runScript child executes synchronously
inside its caller's statement. The child's idle drain therefore ran the
caller's pending promise callbacks in the middle of that statement, with their
output captured as the child's stdout, and a child that failed cleared the
queue and silently deleted them (#1293).
ES2026 §9.5 (sec-jobs) invokes a job only when there is no running execution
context in its agent and that agent's execution context stack is empty, and
requires every enqueued job to be invoked eventually. A child engine is a
separate agent; its caller's stack is not empty while the child runs.
Decision
A nested Execute runs in a scope of the queue. TGocciaMicrotaskQueue
keeps a stack of scopes. EnterScope hides the current jobs and starts on an
empty queue; LeaveScope discards what the scope left behind and puts the
hidden jobs back in their original order. Every drain, HasPending check and
ClearQueue sees only the current scope, so no call site that pumps the queue
(await, the fetch and timer pumps, the idle drain) needed a change.
Only a run nested in a different engine is scoped. The thread records
which engine's run (Execute, ExecuteProgram, RunModule) is innermost. An
Execute that starts while a different engine holds that place enters a
scope. The outermost one keeps the thread's own scope and clears it on exit,
exactly as before: hosts queue jobs ahead of the entry script — the runner
evaluates a --globals module before Execute — and those jobs have to drain
with it. An engine re-entered from its own native callback is the same agent
and shares the scope it is already running in.
A job belongs to the scope of the code that asked for it. A promise
reaction belongs to the scope that registered it, which approximates the
spec's rule that a reaction job is scheduled in its handler's realm. A FinalizationRegistry
cleanup job belongs to the scope that created the registry. The job that calls
a thenable's then has no registrant, so it belongs to the scope the promise
being resolved was created in. Completion pumps and the collector serve the
whole thread, so a nested engine's drain can settle its caller's fetch or
Atomics.waitAsync promise and can collect its caller's registered targets;
these jobs are enqueued into the owning scope rather than the current one.
A job whose scope has ended is enqueued into the current scope. Scope identifiers are never reused, so an ended scope is always recognised as ended rather than taken for a later one.
A ShadowRealm shares its creator's scope. It is a second realm of the same
agent and never calls Execute.
Considered options
- Tag each job with its owning engine or realm and filter in
DrainQueueandClearQueue. Mirrors the fetch manager, which tags requests with a realm. Rejected: a ShadowRealm has its own realm but shares its creator's jobs, so a realm tag splits one agent, and a single list needs a head index per owner to stay O(1). - Give a nested engine its own queue object and swap the thread instance. Equivalent for jobs enqueued by running code, but the pumps settle promises on behalf of other engines, and addressing another engine's queue by pointer ties job ownership to that object's lifetime. Scopes inside one queue are addressed by identifier and fall back safely.
- Scope every
Execute. Simpler to state, and the first implementation. Rejected: jobs queued beforeExecutewere never drained, which broke a--globalsmodule's pending callbacks in interpreted mode. - Route every job of a promise to the scope the promise was created in. One field instead of two. Rejected: a nested engine that registers a callback on a promise handed over from the enclosing engine would have it run after the nested engine is gone.
- Defer another engine's fetch completions instead of settling them. Would
keep even a
thengetter from running during the child. Rejected for this change: it reverses the settlement behavior #1258 established and tested.
Consequences
runScript,{ sandbox: true }children and shellgocciareturn before any of the caller's pending jobs run, and a failing child leaves them queued.- Only
Executeisolates. A nestedExecuteProgramorRunModuledrains the scope it was called in. - Reading a thenable's
thenproperty still happens where the promise is resolved. A caller'sthengetter on a fetch response can therefore run during a nested drain; the job that callsthendoes not. - A nested run cannot make progress on work that depends on a job of the
enclosing engine; such a promise is still pending when the nested
Executereturns. - A nested engine that resolves a promise of the enclosing engine with one of
its own thenables leaves the
thencall to the enclosing engine. - Each promise, promise reaction and
FinalizationRegistrycarries one scope identifier.