Skip to content

[JSC] Microtask drain scopes - #427

Draft
dylan-conway wants to merge 14 commits into
mainfrom
claude/scoped-event-loop-runs
Draft

dylan-conway wants to merge 14 commits into
mainfrom
claude/scoped-event-loop-runs

Conversation

@dylan-conway

@dylan-conway dylan-conway commented Aug 14, 2026

Copy link
Copy Markdown
Member

Adds the JavaScriptCore support Bun needs to turn its event loop from inside a synchronous frame while admitting only the work that frame caused ("domain runs": the root program is a run, and a blocked frame — a waitForPromise-style wait — nests another inside it). Everything is under USE(BUN_JSC_ADDITIONS) and inert unless the embedder opens a drain scope; QueuedTask, enqueue and drainImpl are untouched.

MicrotaskQueue::DrainScope

  • beginDrainScope(admitLoaderJobs) moves every task already queued into the new scope's deferred deque — they belong to the code the frame interrupted — so until endDrainScope() a checkpoint runs only what has been queued since, i.e. by the scope's own code, transitively. endDrainScope() prepends the deferred tasks back onto the front of the queue in their original order, so FIFO across the scope is preserved. Scopes nest as a stack: an inner scope sets aside the outer scope's pending tasks the same way.
  • admitLoaderJobs: module-loader pipeline jobs are keyed by loader state the scope shares with the outer program (a scope awaiting an import() of a module whose fetch has already settled depends on jobs queued before it began), so a scope that may import leaves them in the queue (isDrainScopeLoaderJob: the loader-internal microtasks other than AsyncModuleExecutionResume, which resumes user module code, and PromiseFulfillWithoutHandlerJob, a plain settlement).
  • While any scope is open, VM::drainMicrotasks() still performs the microtask checkpoint but skips didExhaustMicrotaskQueue() / finalizeSynchronousJSExecution(): a checkpoint inside a nested wait is not the end of the outer frame's job, so unhandled-rejection notification and WeakRef target release wait for the frame's own checkpoint. hasOpenDrainScope() exposes the state.
  • MarkedMicrotaskDeque::prepend / takeLast (marking cursor reset conservatively); beginMarking(), visitAggregate(), clear() and clearForGlobalObject() cover the open scopes' deferred deques, so set-aside tasks stay visited and are cleared with their global.

Cost with no scope open: one branch in VM::drainMicrotasks(). No per-task state, no per-enqueue or per-drain work.

Add MicrotaskQueue::performDomainDrain / VM::drainMicrotasksInDomain: drain
only the microtasks whose captured async context names a given scheduling
domain, deferring every other task that reaches the front and putting it
back in order when the drain ends. VM::setMicrotaskDrainDomain lets the
embedder make drainMicrotasks() domain-scoped while a scoped event-loop run
is active. Pass-through jobs that settle a promise without invoking a
handler are domain-neutral so await chains and Promise combinators keep
flowing inside a drain.

All under USE(BUN_JSC_ADDITIONS); no behavior change unless a domain is set.
Take the async context in arguments[3] and install it for the call
(instead of forwarding a fourth argument), so native-queued jobs keep
AsyncLocalStorage values and carry a scheduling domain.
Loader reactions are keyed by loader state rather than by the importer's
async context, so a domain drain cannot attribute them; run them where
they surface so import() inside a scoped run does not stall.
AsyncModuleExecutionResume keeps its captured context and is attributed.
@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown

Preview Builds

Commit Release Date
1d7a3d12 autobuild-preview-pr-427-1d7a3d12 2026-08-22 10:55:43 UTC
53b9f9c7 autobuild-preview-pr-427-53b9f9c7 2026-08-18 22:45:22 UTC
1e39c1d0 autobuild-preview-pr-427-1e39c1d0 2026-08-18 00:13:47 UTC
2e10301d autobuild-preview-pr-427-2e10301d 2026-08-15 00:44:03 UTC
94eb06d9 autobuild-preview-pr-427-94eb06d9 2026-08-14 13:55:57 UTC
2ed39ebd autobuild-preview-pr-427-2ed39ebd 2026-08-14 08:16:58 UTC
023c9058 autobuild-preview-pr-427-023c9058 2026-08-14 06:22:39 UTC
d9be9f07 autobuild-preview-pr-427-d9be9f07 2026-08-14 05:35:39 UTC
89788247 autobuild-preview-pr-427-89788247 2026-08-14 04:59:50 UTC

- domainOfContext reads through tryGetIndexQuickly: task arguments are
  arbitrary user values (holey or slow-put arrays), and a hole at index 0
  must not be dereferenced.
- beginMarking() resets the marking cursor of every active drain's
  deferred deque, so a collection that starts while a drain is on the
  stack rescans what an earlier one saw.
- Count executed tasks where they are dequeued to run.
- drainMicrotasksInDomain no longer notifies unhandled rejections; the
  outer frame is mid-job and may still attach handlers.
- Module-loader jobs are neutral only when the drain admits them
  (setMicrotaskDrainDomain(sentinel, domain, admitsLoaderJobs)).
- @enqueueJob forwards two arguments and queues under the current
  async context.
…er run

Rework the domain-drain support around what the embedder actually needs:

- Domains are ordered. A run is named by a monotonic counter's value when
  it began, and a task belongs to it iff it was queued since then. The
  drain admits tasks with domain() >= its own and defers older ones.
- A task's domain is stamped once, in MicrotaskQueue::enqueue, while a
  drain is active: the domain named by the async context it captured, if
  any, else the active drain's (it is being queued by code the drain is
  running). QueuedTask grows a uint32 within its existing 48-byte budget.
  This attributes JSC-internal continuations that carry no context
  (AsyncGeneratorDriverResume on the sync-iterator rejection path,
  streaming Wasm reactions) correctly, and removes the per-visit argument
  scan and the pass-through whitelist from the drain loop; only the
  module-loader carve-out (admitsLoaderJobs) remains, since those jobs'
  promises are keyed by loader state shared with the outer program.
- Drains are pushed/popped by the embedder for the run's lifetime
  (beginDomainDrain / endDomainDrain) instead of per checkpoint, so the
  deferred backlog is moved out once and back once per run. VM keeps no
  drain state of its own; drainMicrotasks() diverts when the default
  queue has an active drain.
- drainImpl reads the active drain once per call rather than per task,
  so the cost with no drain active is one branch per drain call.
- clearForGlobalObject also filters active drains' deferred deques;
  deferred tasks are prepended back while still reachable from the
  visitor; Bun-only includes are guarded.
@dylan-conway dylan-conway changed the title [JSC] Domain-scoped microtask drains [JSC] Domain drains for the microtask queue Aug 14, 2026
Replace the domain-drain machinery with the one thing the embedder needs:
MicrotaskQueue::beginDrainScope(admitLoaderJobs) sets aside every task
already queued (optionally keeping module-loader pipeline jobs), so that
until endDrainScope() a checkpoint runs only what has been queued since;
endDrainScope() prepends the set-aside tasks back in order. Scopes nest.
While a scope is open VM::drainMicrotasks() performs the checkpoint but
skips unhandled-rejection notification and WeakRef finalization, since it
is not the end of the outer frame's job.

Queue order already encodes when a task was queued, so no per-task stamp,
sentinel, argument scan or drainImpl change is needed; QueuedTask and the
drain loop are untouched. The set-aside deque is covered by clear(),
clearForGlobalObject(), beginMarking() and visitAggregate().
@dylan-conway dylan-conway changed the title [JSC] Domain drains for the microtask queue [JSC] Microtask drain scopes Aug 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant