[JSC] Microtask drain scopes - #427
Draft
dylan-conway wants to merge 14 commits into
Draft
dylan-conway wants to merge 14 commits into
dylan-conway wants to merge 14 commits into
Conversation
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.
Preview Builds
|
- 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.
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().
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 underUSE(BUN_JSC_ADDITIONS)and inert unless the embedder opens a drain scope;QueuedTask,enqueueanddrainImplare untouched.MicrotaskQueue::DrainScopebeginDrainScope(admitLoaderJobs)moves every task already queued into the new scope'sdeferreddeque — they belong to the code the frame interrupted — so untilendDrainScope()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 animport()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 thanAsyncModuleExecutionResume, which resumes user module code, andPromiseFulfillWithoutHandlerJob, a plain settlement).VM::drainMicrotasks()still performs the microtask checkpoint but skipsdidExhaustMicrotaskQueue()/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()andclearForGlobalObject()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.