Skip to content

event_loop: make DeferredTaskQueue::run tolerate re-entrant map mutation - #32703

Merged
Jarred-Sumner merged 10 commits into
mainfrom
farm/c4842cba/deferred-task-queue-reentrant
Jul 30, 2026
Merged

Jarred-Sumner merged 10 commits into
mainfrom
farm/c4842cba/deferred-task-queue-reentrant

Conversation

@robobun

@robobun robobun commented Jun 25, 2026

Copy link
Copy Markdown
Collaborator

Panic

panic: index out of bounds: the len is 1 but the index is 1
<DeferredTaskQueue>::run            src/event_loop/DeferredTaskQueue.rs
drain_microtasks_with_global        src/jsc/event_loop.rs:336

Cause

DeferredTaskQueue::run walks self.map by index, caching the length in last and invoking each entry's on_auto_flush callback. last was only re-synced when a callback returned false (remove). When a callback mutated the map and returned true, the loop did i += 1 with a stale last, so the next self.map.keys()[i] indexed past the new length.

H2FrameParser::on_auto_flush hits this every time it runs with another auto-flusher behind it: it calls flush -> uncork -> unregister_auto_flush, removing its own entry mid-iteration, then returns true.

Reproduction

Register two auto-flushers in one microtask so they share one run() pass: an h2 client request (corks the H2FrameParser, registers it at index 0) and a small write on a Bun.serve direct-stream controller (buffers and registers HTTPServerWritable at index 1). When run() processes the h2 entry it removes itself and returns true; the next index is one past the shrunk map.

// inside one microtask, with an already-connected h2 client and an
// open Bun.serve direct-stream controller:
client.request({ ":path": "/" });  // H2FrameParser registers (index 0)
controller.write("x");               // HTTPServerWritable registers (index 1)
// microtasks drain -> DeferredTaskQueue::run -> panic

Added as test/js/node/http2/node-http2-deferred-task-queue.fixture.js; panics 10/10 on 1.4.0.

Fix

run() now re-reads self.map.len() on every iteration, and after a callback returns true it only advances i if key is still at position i (otherwise it re-processes whatever got swapped in). A remaining counter initialized to the entry count at the start of the pass bounds the loop so entries appended mid-run are left for the next run() and a callback that removes-and-reposts can't spin.

unregister_task is also switched from remove() (ordered, O(n) with index rebuild) to swap_remove() (O(1)), matching the original Zig behaviour and the iteration scheme above.

H2FrameParser::on_auto_flush calls flush -> uncork -> unregister_auto_flush,
which removes its own entry from the DeferredTaskQueue map while run() is
still iterating, and then returns true. run() cached the map length in
'last' and only re-synced it when a callback returned false, so the next
iteration indexed past the new length and panicked with
'index out of bounds: the len is 1 but the index is 1'.

run() now re-reads map.len() on each iteration, bounds the pass to the
initial entry count so entries appended mid-run are left for the next
pass, and after the callback re-checks whether the current key is still
at the same position before advancing. unregister_task() now uses
swap_remove (O(1), matches the original Zig) instead of ordered remove.

The test registers two auto-flushers in one microtask (an h2 client
request and a small Bun.serve direct-stream write) so both share one
run() pass; the h2 entry removes itself mid-iteration.
@robobun

robobun commented Jun 25, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:58 PM PT - Jul 10th, 2026

❌ @robobun, your commit 0a4c96b has 2 failures in Build #71574 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 32703

That installs a local version of the PR into your bun-32703 executable, so you can run:

bun-32703 --bun

@coderabbitai

coderabbitai Bot commented Jun 25, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@robobun, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 10 minutes and 18 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9587d7d3-cf05-4911-8cf2-facc48fa36ab

📥 Commits

Reviewing files that changed from the base of the PR and between 2e741b3 and 17d2a84.

📒 Files selected for processing (2)
  • src/event_loop/DeferredTaskQueue.rs
  • test/js/node/http2/node-http2-streams-rehash.test.ts

Walkthrough

DeferredTaskQueue::unregister_task and DeferredTaskQueue::run now use swap-based removals, bound each drain pass, skip tombstones, and re-check slot ownership after callbacks mutate the map. The HTTP/2 test harness adds timeout scaling and a new fixture-backed regression case.

Changes

Deferred task queue re-entrancy

Layer / File(s) Summary
Queue draining and removal
src/event_loop/DeferredTaskQueue.rs
unregister_task switches to swap_remove(&ctx), and run updates its drain loop to use an initial remaining count, remove missing slots with swap_remove_at(i), and re-check the current slot after callback execution.
HTTP/2 regression fixture and test
test/js/node/http2/node-http2-streams-rehash.test.ts, test/js/node/http2/node-http2-deferred-task-queue.fixture.js
The test harness adds ASAN/debug-based timeout scaling, keeps the options-getters check under the new timeout form, and adds a fixture-driven regression test that spawns the deferred-task-queue scenario and asserts stdout is OK with exit code 0.
🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description explains the bug, cause, repro, and fix, but it does not follow the required template headings. Rewrite it with the required 'What does this PR do?' and 'How did you verify your code works?' sections, including the verification details.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the main change: making DeferredTaskQueue::run handle re-entrant map mutation.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/event_loop/DeferredTaskQueue.rs`:
- Around line 76-80: The DeferredTaskQueue::pop_front_until pass is letting tail
swaps expose newly posted tasks in the same iteration, which breaks the intended
append-later behavior. Update the loop in DeferredTaskQueue::pop_front_until so
it only processes the original frontier for this pass, either by tracking the
initial length/frontier separately from the live map or by avoiding
swap_remove_at for in-pass removals. Make sure the callback path that can post
new tasks (the logic around the callback and the subsequent removal/requeue
handling) never causes a fresh tail entry to be run before the next pass.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: b51629f5-df37-4c04-89bc-1083a90ec850

📥 Commits

Reviewing files that changed from the base of the PR and between 5621c5d and 1a2eed8.

📒 Files selected for processing (3)
  • src/event_loop/DeferredTaskQueue.rs
  • test/js/node/http2/node-http2-deferred-task-queue.fixture.js
  • test/js/node/http2/node-http2-streams-rehash.test.ts

Comment thread src/event_loop/DeferredTaskQueue.rs
The counter is a livelock bound, not a strict guarantee that newly
appended entries never run in the current pass (a swap-remove can pull
one forward). Any entry skipped in one pass runs on the next; each
callback flushes an independent buffer, so order within a pass has no
correctness effect.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't find any bugs and the fix looks sound, but this touches the core event-loop drain path with subtle re-entrant iteration semantics, so it's worth a human pass.

Extended reasoning...

Overview

This PR fixes an index-out-of-bounds panic in DeferredTaskQueue::run (src/event_loop/DeferredTaskQueue.rs) when an on_auto_flush callback mutates the map mid-iteration and returns true. The loop now re-reads len() each iteration, re-checks slot ownership before advancing, and bounds the pass with a remaining counter. unregister_task is switched from ordered remove() to O(1) swap_remove(). A regression fixture and test are added.

Security risks

None identified. The change is purely internal iteration logic over an in-process map of auto-flusher pointers; no input parsing, auth, or external-data handling is affected.

Level of scrutiny

High. DeferredTaskQueue::run executes after every microtask drain across the entire runtime, so a regression here would be broad. The new logic handles several re-entrant mutation cases (self-removal, removal of other entries, append-during-callback, swap-into-current-slot), and while my trace-through of those cases checks out, the reasoning is subtle enough that a maintainer who owns this code should confirm the invariants — particularly the interaction between the !keep swap-remove-by-key path and a callback that has already moved/removed its own entry, and the intentional change of unregister_task from order-preserving to swap semantics.

Other factors

  • The bug hunter found no issues; CodeRabbit's one finding was discussed and withdrawn (the swap-forward behavior is pre-existing and order is not a correctness contract here).
  • API usage verified: ArrayHashMap::swap_remove(&K) -> bool and swap_remove_at(usize) both exist in src/collections/array_hash_map.rs.
  • A reproducer test is included and the PR description states it panics 10/10 on 1.4.0.
  • CI shows build failures on aarch64-android and freebsd-x64 build-rust jobs, plus Windows aarch64 agent-creation failures; worth confirming whether those are infra-only before merge.

@robobun

robobun commented Jun 25, 2026 •

Copy link
Copy Markdown
Collaborator Author

CI status as of build 71574 (sha 0a4c96b, branch merged with current main).

The new test, "DeferredTaskQueue::run tolerates an on_auto_flush callback that unregisters itself and returns true", passes on every lane; it has not appeared in a failure annotation on any of this PR's builds.

Two error-level failures, neither related to this change:

  • test/js/node/test/parallel/test-tls-psk-alpn-callback-exception-handling.js (debian x64-asan): a 9-byte LeakSanitizer leak in a TLS PSK allocation. This diff has no allocations, and TLS PSK handshake code has no path to EventLoop::deferred_tasks. This branch carries the newest main (095eb31); other PR builds are on older bases, so this is the first build to exercise that main's x64-asan lane.
  • test/js/bun/http/proxy-stress-concurrent.test.ts (darwin 14 x64): 1 out of 1200 concurrent https-proxy requests failed in two stress modes. This test was touched twice on main since the last merge (ed4a810, c464531) and is being actively worked on in the ciro/proxy-stress-macos-portrange branch (build 71566). fetch() through an HTTPS proxy does not register in EventLoop::deferred_tasks.

test/bundler/bundler_defer.test.ts, the Windows-only flake that failed on the four earlier builds, passed on this one.

Verification on the merged tree with the debug build: with src/event_loop/DeferredTaskQueue.rs reverted to main the test file fails on exactly the new test with the reported panic (panic_bounds_check in DeferredTaskQueue::run); with the fix restored, all 4 tests in the file pass. The diff is ready for review.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't find any bugs and the iteration logic looks sound, but this rewrites the drain loop in core event-loop code (DeferredTaskQueue::run, called on every microtask drain) with subtle re-entrancy/swap-remove reasoning, so it's worth a human pass.

Extended reasoning...

Overview

This PR fixes an index-out-of-bounds panic in DeferredTaskQueue::run (src/event_loop/DeferredTaskQueue.rs) that occurs when an on_auto_flush callback mutates the map mid-iteration and returns true. The fix re-reads self.map.len() each iteration, re-checks slot ownership after each callback, bounds the pass with a remaining counter, and switches unregister_task from ordered remove() to O(1) swap_remove(). A new fixture + test reproduces the panic via H2FrameParser + HTTPServerWritable sharing one drain pass.

Security risks

None. No untrusted input parsing, auth, crypto, or permissions. The change is internal iteration mechanics over a map of native function pointers that are only populated by Bun's own auto-flusher registrations.

Level of scrutiny

High. DeferredTaskQueue::run runs after every microtask drain in every Bun process — it is a hot, critical event-loop path. The fix involves non-trivial reasoning about re-entrant map mutation during index-based iteration: swap-remove pulling tail entries forward, the remaining livelock bound vs. a strict frontier, and the slot-ownership re-check after the callback. The unregister_task change from ordered remove to swap-remove is also a semantic change (restoring Zig parity per the description, but still a behavior change). I traced the branches and they look correct for the documented contract — entries that get skipped stay in the map for the next pass, and the !keep branch's remove-by-key correctly no-ops if the callback already self-unregistered. But this is exactly the kind of code where a second pair of eyes on the edge cases is valuable.

Other factors

  • The bug-hunting system found no issues.
  • CodeRabbit raised a frontier-invariant concern that was convincingly rebutted (swap-forward existed before, intra-pass order is not a correctness requirement here, skipped entries run next pass) and withdrawn; the comment was reworded in 800ac36 to clarify remaining is a livelock bound, not a strict frontier.
  • A targeted regression test is added and passes on all CI lanes; the 3 CI failures (napi.test.ts, bundler_defer.test.ts, node-tls-connect.test.ts on Windows) are documented as pre-existing/unrelated and also failing on other PR builds.
  • No CODEOWNERS entry covers this path.

… debug builds

node-http2-streams-rehash.test.ts has a pre-existing test that times out
under the debug+ASAN build, which masked this file's other tests. The
DeferredTaskQueue test is about the event loop, not h2 stream rehashing,
so give it its own file named after the fixture.

The spawned fixture takes ~130ms in release but ~8s under debug+ASAN
(crash reporting on the failing path dominates), so the default 5s
per-test budget killed the child before it could exit. Scale the timeout
the same way node-http2.test.js does.
Comment thread test/js/node/http2/node-http2-deferred-task-queue.test.ts Outdated
isASAN is detected via the isASANEnabled() runtime probe in harness.ts,
not by binary name, so it is true under bun-debug. The previous wording
claimed the opposite. The multiplier itself is unchanged.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for fixing the comment — no issues found in the logic, but since this rewrites the iteration in DeferredTaskQueue::run (which sits on the microtask drain path and has subtle re-entrancy semantics), I'd like a human to sign off.

Extended reasoning...

Overview

This PR fixes an index-out-of-bounds panic in DeferredTaskQueue::run (src/event_loop/DeferredTaskQueue.rs) when an on_auto_flush callback re-entrantly mutates the map and returns true. The loop now re-reads self.map.len() every iteration, only advances i if the key is still at slot i after the callback, and bounds the pass with a remaining counter to prevent livelock. unregister_task is switched from ordered remove() to O(1) swap_remove(). A regression fixture (node-http2-deferred-task-queue.fixture.js) and test reproduce the panic 10/10 on 1.4.0.

Security risks

None. No untrusted input parsing, auth, crypto, or permissions are touched; this is internal scheduling logic operating on already-registered native callback pointers.

Level of scrutiny

High. DeferredTaskQueue::run is invoked from drain_microtasks_with_global and runs after every microtask drain in every Bun process. The re-entrancy reasoning — swap-remove pulling tail entries forward, the keys().get(i) == Some(&key) check deciding whether to advance, removing by key vs. index on the !keep path, and the remaining livelock bound — is subtle enough that a maintainer familiar with the auto-flush callback contract should confirm the invariants. I walked through the cases (callback removes self + returns true, removes a different entry, appends a new entry, removes self then re-posts) and they look correct, but this is not a mechanical change.

Other factors

  • The CodeRabbit concern about tail-swap pulling fresh entries forward was discussed and withdrawn; the author's response (pre-existing behavior, per-entry flush order is not semantically meaningful, remaining is a livelock bound not a strict frontier) is reflected in the in-code comment.
  • My prior nit about the inaccurate isASAN rationale in the test comment was addressed in e5cf4e9.
  • I verified ArrayHashMap::swap_remove(&K) -> bool and swap_remove_at(usize) exist with the expected signatures.
  • CI on build 64706 was green for the new test across all lanes; the author's failure analysis attributes the remaining failures to unrelated flakes/pre-existing main breakage.

…est.ts

Adding a new test file changes which files share a CI shard: the runner
assigns tests by index modulo max-shards over the test list
(scripts/runner.node.mjs), so one inserted filename shifts every later
file to a different shard on every lane. That reshuffle put
node-tls-connect.test.ts (which talks to the live bun.sh) next to new
neighbors and it started failing on 13 lanes in build 66237, while build
64706 (same src, no extra file) was green. Keeping the test in the
existing file restores that layout.

The reason it was split out in the first place was that the neighboring
options-getter test times out at the default 5s budget under debug+ASAN.
It is not hung: it passes in 5.9s given a budget. Give it and the new
test the same debug/ASAN-scaled timeout that node-http2.test.js uses.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/node/http2/node-http2-streams-rehash.test.ts`:
- Around line 5-10: The rationale comment near the ASAN_MULTIPLIER declaration
is too long and must be shortened to 3 lines or less. Condense the explanation
in the test file by keeping only the essential reason for the multiplier choice,
or move the extra background into the PR description while leaving a brief
comment next to ASAN_MULTIPLIER.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 3f44ae7f-fb9f-408f-90eb-271d97465277

📥 Commits

Reviewing files that changed from the base of the PR and between 800ac36 and 2e741b3.

📒 Files selected for processing (1)
  • test/js/node/http2/node-http2-streams-rehash.test.ts

Comment thread test/js/node/http2/node-http2-streams-rehash.test.ts Outdated
Comment thread src/event_loop/DeferredTaskQueue.rs Outdated
The inline comment had grown past the three line limit. Keep the
mechanics at the call site and put the contract (re-entrant mutation,
the livelock bound, why intra-pass order does not matter) in the file
level doc block.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both earlier nits are addressed and I found nothing further, but this rewrites the iteration of a core event-loop hot path under re-entrant mutation (with an unsafe callback dispatch and a remove → swap_remove semantics change), so it's worth a human sign-off.

Extended reasoning...

Overview

This PR fixes an index-out-of-bounds panic in DeferredTaskQueue::run (src/event_loop/DeferredTaskQueue.rs), which runs after every microtask drain. The previous loop cached self.map.len() in last and only refreshed it on the keep == false path; H2FrameParser::on_auto_flush removes its own entry mid-iteration and returns true, so with a second flusher behind it the next index read past the shrunk map. The fix re-reads len() every iteration, re-checks whether key is still at slot i before advancing, and bounds the pass with a remaining counter initialized to the entry count at start. unregister_task is also switched from ordered remove() to O(1) swap_remove(), matching the original Zig and the new iteration scheme. A new fixture (node-http2-deferred-task-queue.fixture.js) reproduces the panic 10/10 on 1.4.0, and the test is added to node-http2-streams-rehash.test.ts with an ASAN/debug timeout multiplier.

Security risks

None identified. No new inputs are parsed, no auth/crypto/permissions surface is touched, and the unsafe block (invoking the registered extern "C" callback with its own pointer) is unchanged in contract from the previous code — only the surrounding iteration was rewritten.

Level of scrutiny

High. DeferredTaskQueue::run sits directly in the microtask-drain path (drain_microtasks_with_global), so every Bun process exercises it constantly. The new loop has to be correct under several re-entrant cases (callback removes itself, removes another entry before/after i, appends a new entry, returns false after self-removal), and the remove → swap_remove change in unregister_task alters how indices shift for all callers, not just run(). I traced the cases and believe the logic is sound — the keys().get(i) == Some(&key) re-check plus the i < self.map.len() loop guard cover the self-removal and shrink cases, and the file doc now explicitly states that intra-pass order and the strict frontier are non-requirements — but this is exactly the kind of subtle, hot-path concurrency-adjacent change a maintainer should eyeball rather than take on bot approval.

Other factors

All prior review threads are resolved: CodeRabbit's swap-forward concern was rebutted (pre-existing behavior, order-insensitive flushers, remaining is a livelock bound not a frontier) and withdrawn; my two comment-length/accuracy nits were addressed in ab84bbd and 17d2a84. The author verified locally that the new test panics with the fix reverted and passes with it applied, and reports it green on every CI lane. The remaining CI red (bundler_defer.test.ts on Windows 2019, x64-musl build failures) is unrelated per the author's CI-status comment. The bug-hunting system found nothing in this run.

@robobun

robobun commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator Author

Independent confirmation of this one from the Sentry side, plus a second reproduction that may be worth folding in.

This is Sentry BUN-3NC8, still firing on current builds of main (16 events in 24h at the time I looked, latest sample macOS aarch64, a standalone executable with spawn + fetch tags). Decoded stack matches the panic in the description exactly, down to the len is 1 but the index is 1.

Second repro: http2 session + an unread Bun.spawn stdin pipe

The HTTPServerWritable in this PR's fixture is not the only way to land a second auto-flusher behind the H2 entry. A FileSink works too, and it is the shape the Sentry event's tags point at. Writing more than a pipe buffer's worth to a child's stdin leaves the sink with pending data, so FileSink::on_write registers it; do that in the same microtask as client.request() and the map holds [H2FrameParser, FileSink]. uncork() removes slot 0 and returns true, so the walk indexes slot 1 of a 1-entry map.

const http2 = require("node:http2");

const CHUNK = Buffer.alloc(1024 * 1024, 0x78);
const child = Bun.spawn({
  cmd: [process.execPath, "-e", "setTimeout(() => {}, 30000)"],
  stdin: "pipe",
  stdout: "ignore",
  stderr: "ignore",
});

const server = http2.createServer();
server.on("error", () => {});
server.on("stream", stream => {
  stream.respond({ ":status": 200 });
  stream.end("hi");
});

server.listen(0, "127.0.0.1", () => {
  const client = http2.connect("http://127.0.0.1:" + server.address().port);
  client.on("error", () => {});
  client.on("connect", () => {
    const req = client.request({ ":path": "/", ":method": "GET" }); // H2FrameParser registers (index 0)
    req.on("error", () => {});
    req.resume();
    req.on("end", () => {
      console.log("OK");
      child.kill();
      server.close();
      process.exit(0);
    });
    child.stdin.write(CHUNK); // FileSink registers (index 1)
  });
});

Results, both debug builds:

  • 1498d7b77 (main): panics every run, index out of bounds: the len is 1 but the index is 1 at DeferredTaskQueue::run, via drain_microtasks_with_global -> drain_microtasks -> EventLoop::exit.
  • 17d2a84dc6 (this branch): prints OK, exits 0. Also holds with two trailing sinks, which walks past two entries after the session removed itself.

I arrived at the same root cause and an equivalent fix before finding this PR, so I am not opening a competing one. The remaining livelock bound and the "only advance i if key is still at slot i" re-check both look right to me. Happy to contribute the FileSink variant above as an extra case in the fixture if you want the non-Bun.serve path covered too.

@robobun

robobun commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator Author

Still reproducing as of today. This is the #6 most frequent Windows flaky test across the last 40 PR builds (10 hits on test/js/web/fetch/fetch-backpressure.test.ts, all code 3 crashes with this exact panic).

Fresh symbolication against the 91675d0 baseline PDB (bun.exe and bun-profile.exe are byte-identical, so the profile PDB works for both):

panic_bounds_check
DeferredTaskQueue::run                         src/event_loop/DeferredTaskQueue.rs:84
EventLoop::drain_microtasks_with_global        src/jsc/event_loop.rs:342
VirtualMachine::drain_microtasks
on_node_http_request_with_upgrade_ctx<true,true>   src/runtime/server/mod.rs:1246
uWS::HttpContext<1>::onData
...
uv__process_poll_req

The fixture in this PR crashes 3/3 on today's main on both Linux x64 (1498d7b) and Windows x64-baseline (91675d0), so it is still a deterministic fail-before.

The only CI failure on build 66425 is test/bundler/bundler_defer.test.ts on Windows, which is unrelated (no path from the bundler to EventLoop::deferred_tasks).

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't find any bugs and both of my earlier nits are addressed, but this rewrites the re-entrant iteration logic in DeferredTaskQueue::run — the post-microtask-drain hot path — so a maintainer familiar with the auto-flusher call graph should sign off on the intra-pass ordering and livelock-bound reasoning.

Extended reasoning...

Overview

This PR fixes an index-out-of-bounds panic in src/event_loop/DeferredTaskQueue.rs by making run() tolerate callbacks that re-entrantly mutate the map (the concrete trigger is H2FrameParser::on_auto_flush unregistering itself mid-iteration and returning true). The loop now re-reads len() each iteration, re-checks slot ownership after each callback, and bounds the pass with a remaining counter. unregister_task also switches from ordered remove() to O(1) swap_remove(). A new fixture (node-http2-deferred-task-queue.fixture.js) plus a test in node-http2-streams-rehash.test.ts deterministically reproduce the panic on main, and the test file gains debug/ASAN timeout scaling.

Security risks

None identified. There is no untrusted-input parsing, auth, crypto, or filesystem path handling here — the change is purely internal iteration bookkeeping over an in-process map of native auto-flusher pointers.

Level of scrutiny

High. DeferredTaskQueue::run executes after every microtask drain, so it is one of the hottest and most latency-sensitive paths in the runtime. The re-entrancy contract is subtle: the CodeRabbit thread already surfaced a swap-remove-pulls-fresh-entry-forward edge case that took a four-point argument to dismiss, and the correctness of the "only advance i if key is still at slot i" / "remove by key on !keep" pair depends on the documented claim that intra-pass ordering has no correctness effect and that skipped entries safely run next pass. I traced the cases and believe the logic is sound, and I confirmed ArrayHashMap::swap_remove(&K) -> bool and swap_remove_at(usize) both exist with the expected signatures — but this is exactly the class of change where a maintainer who owns the H2/FileSink/HTTPServerWritable auto-flush surfaces should confirm the invariants rather than a bot.

Other factors

All prior review threads are resolved (CodeRabbit's frontier concern withdrawn; my two comment-length/accuracy nits fixed in e5cf4e9, ab84bbd, 17d2a84). CI on build 66425 is green except for an unrelated Windows bundler_defer.test.ts flake. The author verified fail-before/pass-after on the merged tree, and there is an independent second reproduction (h2 + Bun.spawn stdin FileSink) plus a live Sentry issue (BUN-3NC8) and #6-ranked Windows flaky-test correlation, so the fix is well-motivated and well-tested — it just warrants human eyes on the event-loop iteration change itself.

@Jarred-Sumner
Jarred-Sumner merged commit 11c3832 into main Jul 30, 2026
76 of 78 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/c4842cba/deferred-task-queue-reentrant branch July 30, 2026 02:40
hughescr added a commit to hughescr/bun that referenced this pull request Jul 31, 2026
* upstream/main: (422 commits)
  install: drop packages held only by optional-peer resolution slots from bun.lock (oven-sh#35681)
  Update mimalloc to the upstream dev3 (v3.4.3) sync (oven-sh#36431)
  compile(pe): ftruncate the Windows --compile output after writing (oven-sh#36430)
  Strong: back bun_jsc::Strong with StrongRootBlock; free AbortSignal.timeout at wrapper GC (oven-sh#35849)
  test(harness): replace toRun matcher with async bunRun + toSpawn (oven-sh#36424)
  test: measure memory via harness rss() instead of process.memoryUsage.rss() (oven-sh#36429)
  Deflake a few tests
  no-orphans(windows): allow CREATE_BREAKAWAY_FROM_JOB and set DIE_ON_UNHANDLED_EXCEPTION on the Job (oven-sh#36414)
  GarbageCollectionController: replace per-tick heap sampler with idle timer only (oven-sh#35356)
  exe_format(pe): write a valid OptionalHeader.CheckSum for --compile output (oven-sh#36383)
  FileSink: flush buffered bytes when process.exit() runs in the same tick as write() (oven-sh#36250)
  test(http): speed up and de-flake serve-async-stream-client-abort.test.ts (oven-sh#35919)
  test(20144): stop racing child startup against the 1s SIGKILL guard (oven-sh#34166)
  test(no-orphans): skip fast-exit perl daemon test on macOS (oven-sh#36413)
  fs: return negative BigIntStats *Ns for pre-epoch timestamps (oven-sh#36187)
  event_loop: make DeferredTaskQueue::run tolerate re-entrant map mutation (oven-sh#32703)
  dotenv: stop panicking on nested `${...}` inside `${VAR:-default}` (oven-sh#36199)
  fetch: make the idle timer an absolute deadline for the response header block (oven-sh#36145)
  bundler: don't panic on unterminated naming template placeholders (oven-sh#36325)
  Buffer#indexOf/lastIndexOf: rare-byte SIMD filter with a Two-Way O(n+m) fallback (oven-sh#36420)
  ...

# Conflicts:
#	src/jsc/bindings/BunDebugger.cpp
hughescr added a commit to hughescr/bun that referenced this pull request Jul 31, 2026
* upstream/main: (422 commits)
  install: drop packages held only by optional-peer resolution slots from bun.lock (oven-sh#35681)
  Update mimalloc to the upstream dev3 (v3.4.3) sync (oven-sh#36431)
  compile(pe): ftruncate the Windows --compile output after writing (oven-sh#36430)
  Strong: back bun_jsc::Strong with StrongRootBlock; free AbortSignal.timeout at wrapper GC (oven-sh#35849)
  test(harness): replace toRun matcher with async bunRun + toSpawn (oven-sh#36424)
  test: measure memory via harness rss() instead of process.memoryUsage.rss() (oven-sh#36429)
  Deflake a few tests
  no-orphans(windows): allow CREATE_BREAKAWAY_FROM_JOB and set DIE_ON_UNHANDLED_EXCEPTION on the Job (oven-sh#36414)
  GarbageCollectionController: replace per-tick heap sampler with idle timer only (oven-sh#35356)
  exe_format(pe): write a valid OptionalHeader.CheckSum for --compile output (oven-sh#36383)
  FileSink: flush buffered bytes when process.exit() runs in the same tick as write() (oven-sh#36250)
  test(http): speed up and de-flake serve-async-stream-client-abort.test.ts (oven-sh#35919)
  test(20144): stop racing child startup against the 1s SIGKILL guard (oven-sh#34166)
  test(no-orphans): skip fast-exit perl daemon test on macOS (oven-sh#36413)
  fs: return negative BigIntStats *Ns for pre-epoch timestamps (oven-sh#36187)
  event_loop: make DeferredTaskQueue::run tolerate re-entrant map mutation (oven-sh#32703)
  dotenv: stop panicking on nested `${...}` inside `${VAR:-default}` (oven-sh#36199)
  fetch: make the idle timer an absolute deadline for the response header block (oven-sh#36145)
  bundler: don't panic on unterminated naming template placeholders (oven-sh#36325)
  Buffer#indexOf/lastIndexOf: rare-byte SIMD filter with a Two-Way O(n+m) fallback (oven-sh#36420)
  ...
hughescr added a commit to hughescr/bun that referenced this pull request Jul 31, 2026
* upstream/main: (422 commits)
  install: drop packages held only by optional-peer resolution slots from bun.lock (oven-sh#35681)
  Update mimalloc to the upstream dev3 (v3.4.3) sync (oven-sh#36431)
  compile(pe): ftruncate the Windows --compile output after writing (oven-sh#36430)
  Strong: back bun_jsc::Strong with StrongRootBlock; free AbortSignal.timeout at wrapper GC (oven-sh#35849)
  test(harness): replace toRun matcher with async bunRun + toSpawn (oven-sh#36424)
  test: measure memory via harness rss() instead of process.memoryUsage.rss() (oven-sh#36429)
  Deflake a few tests
  no-orphans(windows): allow CREATE_BREAKAWAY_FROM_JOB and set DIE_ON_UNHANDLED_EXCEPTION on the Job (oven-sh#36414)
  GarbageCollectionController: replace per-tick heap sampler with idle timer only (oven-sh#35356)
  exe_format(pe): write a valid OptionalHeader.CheckSum for --compile output (oven-sh#36383)
  FileSink: flush buffered bytes when process.exit() runs in the same tick as write() (oven-sh#36250)
  test(http): speed up and de-flake serve-async-stream-client-abort.test.ts (oven-sh#35919)
  test(20144): stop racing child startup against the 1s SIGKILL guard (oven-sh#34166)
  test(no-orphans): skip fast-exit perl daemon test on macOS (oven-sh#36413)
  fs: return negative BigIntStats *Ns for pre-epoch timestamps (oven-sh#36187)
  event_loop: make DeferredTaskQueue::run tolerate re-entrant map mutation (oven-sh#32703)
  dotenv: stop panicking on nested `${...}` inside `${VAR:-default}` (oven-sh#36199)
  fetch: make the idle timer an absolute deadline for the response header block (oven-sh#36145)
  bundler: don't panic on unterminated naming template placeholders (oven-sh#36325)
  Buffer#indexOf/lastIndexOf: rare-byte SIMD filter with a Two-Way O(n+m) fallback (oven-sh#36420)
  ...

# Conflicts:
#	src/js/internal/debugger.ts
hughescr added a commit to hughescr/bun that referenced this pull request Jul 31, 2026
* upstream/main: (422 commits)
  install: drop packages held only by optional-peer resolution slots from bun.lock (oven-sh#35681)
  Update mimalloc to the upstream dev3 (v3.4.3) sync (oven-sh#36431)
  compile(pe): ftruncate the Windows --compile output after writing (oven-sh#36430)
  Strong: back bun_jsc::Strong with StrongRootBlock; free AbortSignal.timeout at wrapper GC (oven-sh#35849)
  test(harness): replace toRun matcher with async bunRun + toSpawn (oven-sh#36424)
  test: measure memory via harness rss() instead of process.memoryUsage.rss() (oven-sh#36429)
  Deflake a few tests
  no-orphans(windows): allow CREATE_BREAKAWAY_FROM_JOB and set DIE_ON_UNHANDLED_EXCEPTION on the Job (oven-sh#36414)
  GarbageCollectionController: replace per-tick heap sampler with idle timer only (oven-sh#35356)
  exe_format(pe): write a valid OptionalHeader.CheckSum for --compile output (oven-sh#36383)
  FileSink: flush buffered bytes when process.exit() runs in the same tick as write() (oven-sh#36250)
  test(http): speed up and de-flake serve-async-stream-client-abort.test.ts (oven-sh#35919)
  test(20144): stop racing child startup against the 1s SIGKILL guard (oven-sh#34166)
  test(no-orphans): skip fast-exit perl daemon test on macOS (oven-sh#36413)
  fs: return negative BigIntStats *Ns for pre-epoch timestamps (oven-sh#36187)
  event_loop: make DeferredTaskQueue::run tolerate re-entrant map mutation (oven-sh#32703)
  dotenv: stop panicking on nested `${...}` inside `${VAR:-default}` (oven-sh#36199)
  fetch: make the idle timer an absolute deadline for the response header block (oven-sh#36145)
  bundler: don't panic on unterminated naming template placeholders (oven-sh#36325)
  Buffer#indexOf/lastIndexOf: rare-byte SIMD filter with a Two-Way O(n+m) fallback (oven-sh#36420)
  ...
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants