Conversation
MarkedMicrotaskDeque kept its tasks in a WTF::Deque<QueuedTask>. A Deque doubles one Vector buffer, and a Vector buffer holds at most 2^31 - 1 bytes, so the step from 2^25 to 2^26 tasks is not a valid capacity and Deque::expandCapacity() calls CRASH(). Script reaches that count with 1.3 GB of queue: 2^25 reactions on one promise, or 2^25 queueMicrotask() calls in Bun. enqueue() has no failure channel, and most of its callers (settling a promise, await) cannot throw. The queue now stores its tasks in a singly linked list of 16 KB segments. No allocation grows with the queue, so the only limit is memory. A task never moves. A segment is freed when its last task is dequeued, and a queue that drains starts over at the first slot of the segment it keeps, so a shallow queue stays in the same cache lines. The old queue never gave its buffer back: after two bursts of 16 million tasks it kept two 640 MB buffers, one in each of the deques that performMicrotaskCheckpoint() swaps. The marking cursor is unchanged. It is still the count of tasks at the front that the collector has visited in this cycle, dequeue() still decrements it, and visitAggregate() skips that many tasks, now by walking segments. * JSTests/stress/microtask-queue-segments.js: Added. Order, segment boundaries, and collections while the queue is the only reference to its payloads. * JSTests/stress/microtask-queue-more-than-2-25-tasks.js: Added. Aborts without the change. It needs about 3 GB, so it is skipped when memory is limited. * Source/JavaScriptCore/runtime/MicrotaskQueue.cpp: * Source/JavaScriptCore/runtime/MicrotaskQueue.h: * Source/JavaScriptCore/runtime/MicrotaskQueueInlines.h:
|
Preview build of b321195: |
This was referenced Sep 16, 2026
Collaborator
Author
|
Closing: #674 fixes the abort with a much smaller change (it lifts the Vector byte limit for |
This was referenced Sep 16, 2026
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.
Problem
panic(main thread): abort() called, exit 134, also insidetry/catch. 2^25queueMicrotask(f)calls do it in Bun, and 2^25p.then(f)calls do it injsc. Node accepts both. This is the JSC row of Script-sized containers that still abort the process when they cannot grow: the microtask queue and the web streams request queues bun#42648.MarkedMicrotaskDequekeeps its tasks in aWTF::Deque, which doubles one Vector buffer.isValidCapacityForVector(wtf/Vector.h:212) limits a buffer to 2^31 - 1 bytes, so the step to 2^26 tasks callsCRASH()inDeque::expandCapacity().Fix
enqueue()is not an option. 33 call sites are inJSPromise.cpp, invoidpaths (settle,await) that cannot throw or undo a half-queued settle.visitAggregate()skips that many tasks by walking segments.microtask-queue-segments.jsandmicrotask-queue-more-than-2-25-tasks.js(aborts before), and 3,655 runs of the promise and microtask stress tests. 13 timing patterns are within noise. A burst of 16M tasks is 33% faster at half the peak RSS.Background
QueuedTaskis the record of one microtask (a promise reaction or aqueueMicrotask()callback): 40 bytes in Bun.MicrotaskQueueowns two of these deques and swaps them at the end of each checkpoint.m_markedBeforecounts the tasks at the front that this cycle has visited.Notes
Repros. Bun 1.4.3 canary (
09bb54630, linux x64): exit 134 after 1.6 s, peak RSS 1.5 GB. Node v26.3.0 queues and drains the same count.jsc(this isJSTests/stress/microtask-queue-more-than-2-25-tasks.js):The same abort is reachable where one call queues many tasks:
resolve()on a promise with 2^25 reactions, and aWritableStreamwith 2^25 pendingwriter.write()promises that errors (writableStreamFinishErroringrejects them in one loop).Why not a fallible
Deque::tryAppend. oven-sh/bun#42648 lists it as a follow-up, and it is right for the web streams queues that oven-sh/bun#42649 and oven-sh/bun#42659 fixed, because those callers throw aRangeErrorat the call. The microtask queue is different: settling a promise fans out one task per reaction fromtriggerPromiseReactions,Promise.allandPromise.racequeue one per element, and aWritableStreamthat errors rejects every pending write in one loop. None of these can stop halfway. JSC does the same for every other allocation on that path (the reaction cells, the result promises): it succeeds or the process ends.Why not a bigger ring. V8 uses one ring buffer that doubles with no cap. That would fix the abort with the smallest diff, but it keeps two costs the Deque already had. Growth from 2^25 to 2^26 tasks needs a new 2.7 GB buffer while the old 1.3 GB one is still live. The buffer is never given back: the measurements below show 2.2 GB retained after two bursts of 16 million tasks.
Time.
jscrelease builds (the flags of thebun-webkit-linux-amd64lane, no LTO) of this branch and of its base, 11 alternating runs each, minimum wall time in ms. The machine is shared, and run-to-run noise is about 3%.awaitloop, 20M awaits (queue depth 1)awaitloopsp.then(f), then drainthenchain of 500,000, 20 timesPromise.allof 1,000, 10,000 timesA standalone copy of both containers, with mimalloc as in Bun, times the queue alone (ns per enqueue plus dequeue, best of 7, 100M tasks):
mallocand onefreeper 409 tasks: about 80 ns per pair for mimalloc when thousands of blocks are live, which is 0.2 ns per task. That is why the segment is 16 KB and not 4 KB.Memory. Three bursts of 16 million
p.then(f), each drained and followed byfullGC(). RSS in MB:The base keeps both Deque buffers (2^24 tasks each). This PR gives the segments back to the allocator, and the next burst reuses them.
Tests.
microtask-queue-segments.jschecks FIFO order at depths around the segment boundaries for both task sizes (409 and 511 per segment), keeps the queue at a constant depth while it walks through segments with full and eden collections in between, and collects a queue of 20,000 tasks whose payloads only the queue references. A build that visits only the head segment fails it 3 of 3.forEachTaskAfterasserts that it visits exactlysize() - toSkiptasks, so every collection checks the segment walk.microtask-queue-more-than-2-25-tasks.jsneeds about 3 GB, so it has//@ skip if $memoryLimited, which CI passes. Locally: base exits 134 after 2.8 s, this branch passes in 3.1 s.#427 (drain scopes) adds
prepend()andtakeLast()on top of the Deque. On top of this PR,endDrainScope()can do the same without them: move what is inm_queueto the end ofdeferredwithdequeue()andenqueue(), thenm_queue.swap(deferred). Both deques are visited the whole time.Bun PR that pins the preview build of this branch and adds the Bun test: to follow.