Skip to content

Bun.build: stop blocking the event loop on an async plugin setup() - #33271

Closed
robobun wants to merge 5 commits into
mainfrom
farm/1cedaf8f/bun-build-async-plugin-setup
Closed

robobun wants to merge 5 commits into
mainfrom
farm/1cedaf8f/bun-build-async-plugin-setup

Conversation

@robobun

@robobun robobun commented Jul 2, 2026 •

Copy link
Copy Markdown
Collaborator

Part of the #33261 audit (work item 3).

Repro

An async plugin setup() whose promise has not settled by the time setup() returns makes Bun.build spin the event loop at 100% CPU. When the thing that settles it is later on the same stack, that is a permanent hang:

let release;
const gate = new Promise(r => (release = r));
const p = Bun.build({
  entrypoints: ["./entry.js"],
  plugins: [{ name: "p", setup: async () => { await gate; } }],
});
release(); // unreachable: Bun.build is blocked three lines up
await p;

Hangs on 1.3.x, every time.

Cause

Config::from_js (src/runtime/api/JSBundler.rs) runs each plugin's setup() during synchronous config parsing and, when the runSetupFunction builtin hands back a promise that is still pending, calls waitForPromise, which runs the whole event loop until it settles. Bun.build is reachable from any JS, including an I/O completion callback, so this is the synchronous re-entry #33261 forbids: a nested epoll_wait/kevent inside a poll-dispatch callback overwrites the shared ready-poll batch and loses one-shot events. It is also the deterministic hang above, since the nested loop blocks the JS frame that would have settled the promise.

That one call was also the only implementation of the documented "the bundler waits until all onStart() callbacks have completed before continuing" guarantee: the builtin folds a pending async setup() together with the last plugin's pending onStart() promises into the single promise it hands back.

Fix

Chain instead of blocking. Bun.build already returns its own promise, so:

  • Config::from_js no longer blocks. When a step's promise is still pending it records where the chain stopped (SuspendedPluginSetup) and returns early. The chain has to advance one plugin at a time because runSetupFunction needs the previous plugin's settled onStart() array.
  • build() creates the result promise immediately and parks the parse state in a heap DeferredBuild owned by the pending promise's .then reactions (registered in PromiseFunctions, like every other native promise reaction). The resolve reaction runs the remaining plugins, re-suspending on each further pending promise, then the rest of the config parse, then schedules the bundle, moving the already-returned promise into the completion task. The reject reaction rejects it.
  • Everything after the plugin loop was already forbidden from running before the chain settled (plugins may mutate the config object), so it all moves into finish_from_js and defers with the chain.

The synchronous path (no plugins, or every setup() and onStart() settled before returning) is unchanged. The onStart()-gates-the-bundle guarantee is preserved: the bundle task is not created until the chain settles.

Behavior change

Only on the path that used to block. A rejecting async setup() (or onStart()) now rejects the returned promise instead of throwing synchronously from the Bun.build(...) call, and so does a config parse error raised after an async setup() settles. Synchronous setup() errors still throw synchronously.

Verification

test/bundler/bun-build-plugin-async-setup.test.ts.

USE_SYSTEM_BUN=1 bun test test/bundler/bun-build-plugin-async-setup.test.ts
  -> 4 pass, 2 fail (the gated subprocess hangs and is SIGKILLed after 15s with
     no output; the rejection test throws synchronously instead of rejecting)
bun bd test test/bundler/bun-build-plugin-async-setup.test.ts
  -> 6 pass

No regressions: bun-build-api.test.ts (48 pass), and bundler_plugin.test.ts + bundler_plugin_chain.test.ts + plugin-error-nested-throw.test.ts + plugin-sync-exception-fallback.test.ts (69 pass across the four).

The audit of the other non-top-level waitForPromise callers (the rest of work item 3) is on #33261. The remaining violation, bake_body.rs:378 via Bun.serve({ app: { plugins } }), needs a different shape (Bun.serve returns a Server, not a promise) and is left for a follow-up.


[review] gate passed · iteration 1 · 5 files touched

fails on main (without fix)
ASAN without fix: 3 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/bun-build-plugin-async-setup.test.ts
bun test v1.4.0 (112f49c3a)

test/bundler/bun-build-plugin-async-setup.test.ts:
(pass) Bun.build with an async plugin setup() > still waits for a setup() that suspends on I/O [262.56ms]
(pass) Bun.build with an async plugin setup() > still waits for pending onStart() promises before loading any file [257.32ms]
207 |         plugins: [
208 |           {
209 |             name: "explosive",
210 |             async setup() {
211 |               await Bun.sleep(10);
212 |               throw new Error("setup exploded");
                              ^
error: setup exploded
      at setup (/workspace/bun/test/bundler/bun-build-plugin-async-setup.test.ts:212:25)
      at <anonymous> (/workspace/bun/test/bundler/bun-build-plugin-async-setup.test.ts:205:11)
      at <anonymous> (/workspace/bun/test/bundler/bun-build-plugin-async-setup.test.ts:202:67)
(fail) Bun.build with an async plugin setup() > rejects the build promise when an async setup() rejects [86.02ms]
(pass) Bun.build with an async p
... (truncated)

release without fix: 3 FAILED
bun test v1.4.0-canary.1 (1498d7b77)

test/bundler/bun-build-plugin-async-setup.test.ts:
207 |         plugins: [
208 |           {
209 |             name: "explosive",
210 |             async setup() {
211 |               await Bun.sleep(10);
212 |               throw new Error("setup exploded");
                              ^
error: setup exploded
      at setup (/workspace/bun/test/bundler/bun-build-plugin-async-setup.test.ts:212:25)
      at <anonymous> (/workspace/bun/test/bundler/bun-build-plugin-async-setup.test.ts:205:11)
(fail) Bun.build with an async plugin setup() > rejects the build promise when an async setup() rejects [58.95ms]
(pass) Bun.build with an async plugin setup() > still waits for a setup() that suspends on I/O [239.56ms]
(pass) Bun.build with an async plugin setup() > still waits for pending onStart() promises before loading any file [208.77ms]
(pass) Bun.build with an async plugin setup() > runs the remaining plugins after an earlier async setup() settles [186.32ms]
(pass) Bun.build with an async plugin setup() > config mutated after an await in setup() is respected [114.98ms]
61 |       // well under a second.
62 |       timeout: 15_000,

... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/bun-build-plugin-async-setup.test.ts
bun test v1.4.0 (112f49c3a)

test/bundler/bun-build-plugin-async-setup.test.ts:
(pass) Bun.build with an async plugin setup() > still waits for a setup() that suspends on I/O [290.46ms]
(pass) Bun.build with an async plugin setup() > still waits for pending onStart() promises before loading any file [421.97ms]
(pass) Bun.build with an async plugin setup() > rejects the build promise when an async setup() rejects [39.50ms]
(pass) Bun.build with an async plugin setup() > runs the remaining plugins after an earlier async setup() settles [477.35ms]
(pass) Bun.build with an async plugin setup() > returns before a pending setup() promise settles [615.60ms]
(pass) Bun.build with an async plugin setup() > config mutated after an await in setup() is respected [488.17ms]
(pass) Bun.build with an async plugin setup() > lets a caller escape a never-settling setup() with Promise.race [592.43ms]

 7 pass
 0 fail
 11 expect() calls
Ran 7 tests across 1 file. [3.55s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     112f49c3a4
  features     baseline

22 deps, 108 codegen, 1171 objects in 2040ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1234] fetch picohttpparser
[picohttpparser] up to date
[2/1234] gen ErrorCode+*.h
[3/1234] fetch zlib
[zlib] up to date
[4/1234] gen bindgenv2
[5/1234] gen .bind.ts → GeneratedBindings.cpp
[6/1234] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[7/1234] fetch tinycc
[tinycc] up to date
[8/1234] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstants.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingConstants.cpp
[9/1234] gen JSBuffer.lut.h
Generating /workspace/bun/build/release/codegen/JSBuffer.lut.h from /workspace/bun/src/jsc/bindings/JSBuffer.cpp
[10/1234] subst deps/zlib/zlib.h
[11/1234] subst deps/zlib/zconf.h
[12/1234] subst deps/libjpeg-turbo/jconfigint.h
[13/1234] subst deps/libjpeg-turbo/jconfig.h
[14/1234] fetch nodejs (prebuilt)
[nodejs] up to date
[15/1234] gen ProcessBindingB
... (truncated)
diff hotspot
src/jsc/bindings/ZigGlobalObject.cpp              |   4 +
 src/jsc/bindings/ZigGlobalObject.h                |   4 +-
 src/jsc/bindings/headers.h                        |   3 +
 src/runtime/api/JSBundler.rs                      | 390 +++++++++++++++++-----
 test/bundler/bun-build-plugin-async-setup.test.ts | 219 ++++++++++++
 5 files changed, 540 insertions(+), 80 deletions(-)

gate history · 1 passed · 0 rejected · iteration 1

evidence per changed file
file                                               reads  edits  tests
src/jsc/bindings/ZigGlobalObject.cpp                   1      2      0
src/jsc/bindings/ZigGlobalObject.h                     1      1      0
src/jsc/bindings/headers.h                             1      1      0
src/runtime/api/JSBundler.rs                          10     12      0
test/bundler/bun-build-plugin-async-setup.test.ts      1      4      0

@github-actions github-actions Bot added the claude label Jul 2, 2026
@robobun

robobun commented Jul 2, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 10:30 PM PT - Jul 25th, 2026

❌ @robobun, your commit 112f49c has 2 failures in Build #81943 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33271

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

bun-33271 --bun

@coderabbitai

coderabbitai Bot commented Jul 2, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Adds two new JSC host promise handlers for JS bundler plugin setup, extends the plugin setup pipeline to suspend on pending promises, and updates Bun.build to resume or reject the deferred build later. Adds tests for async setup ordering, suspension, and failure handling.

Changes

Async plugin setup suspension

Layer / File(s) Summary
JSC promise handler registration for plugin setup callbacks
src/jsc/bindings/ZigGlobalObject.h, src/jsc/bindings/headers.h, src/jsc/bindings/ZigGlobalObject.cpp
Adds two PromiseFunctions enum values, bumps promiseFunctionsSize from 42 to 44, declares two new host functions, and maps them in promiseHandlerID.
Config::from_js suspension contract and plugin loop
src/runtime/api/JSBundler.rs
Introduces SetupStep and SuspendedPluginSetup, changes Config::from_js to return Option<Config> with a suspension out-parameter, and adds one-step plugin setup execution that stops on the first pending promise.
DeferredBuild and resume/reject callbacks
src/runtime/api/JSBundler.rs
Updates build() to construct a leaked DeferredBuild and attach .then handlers when setup suspends, and adds DeferredBuild::resume/fail plus host callbacks to continue or abort the deferred setup chain.
Async setup() behavior tests
test/bundler/bun-build-plugin-async-setup.test.ts
New test suite validating non-blocking return, suspended setup handling, onStart ordering, sequential plugin execution, post-await config mutations, and rejection propagation from thrown setup errors.

Sequence Diagram(s)

sequenceDiagram
  participant Build as build()
  participant ConfigFromJs as Config::from_js
  participant DeferredBuild
  participant SetupPromise as pending setup promise
  participant OnResolve as Bun__JSBundler__onResolvePluginSetup

  Build->>ConfigFromJs: from_js(..., &mut suspended)
  ConfigFromJs-->>Build: Ok(None) + SuspendedPluginSetup
  Build->>DeferredBuild: create, attach .then handlers
  Build-->>Build: return build promise immediately
  SetupPromise-->>OnResolve: resolved
  OnResolve->>DeferredBuild: resume(next_index)
  DeferredBuild->>ConfigFromJs: run_one_plugin_setup / finish_from_js
  DeferredBuild-->>DeferredBuild: schedule bundling completion
Loading

Related PRs: None identified.

Suggested labels: bundler, javascript, async

Suggested reviewers: None identified.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: Bun.build no longer blocks on pending async plugin setup.
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.
Description check ✅ Passed The PR description clearly explains the change, rationale, behavior impact, and verification, even though it does not use the exact template headings.

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

@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Using minify: true with Build API and kravetsone/esbuild-plugin-autoload results in deadlocks #14539 - Deadlock when using minify: true with an esbuild plugin that has an async setup() — the plugin's async directory scanning needs the event loop, but waitForPromise blocks it, causing a futex deadlock. This PR's switch to promise chaining directly resolves that.

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #14539

🤖 Generated with Claude Code

@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: 3

🤖 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/runtime/api/JSBundler.rs`:
- Around line 459-476: The SuspendedPluginSetup state keeps pending as a bare
JSValue, which leaves the promise unrooted during the gap before .then() is
attached in the JSBundler setup flow. Change SuspendedPluginSetup to store
pending as a jsc::Strong, initialize it before allocating
DeferredBuild/JSPromiseStrong state, and use pending.get() when wiring the
.then(...) callback so the promise stays GC-rooted across the suspend/resume
path.

In `@test/bundler/bun-build-plugin-async-setup.test.ts`:
- Around line 4-15: Trim the long comment in the test to only durable,
non-obvious guidance: keep the reference to Bun.build(plugin) async setup
behavior and the invariant that setup() must not synchronously block config
parsing, while removing the bug history, event-loop mechanism details, and
issue-link narrative. Update the comment near Bun.build and setup() so it stays
within 3 lines and leave the rest of the context in the PR description.
- Around line 16-190: The tests under Bun.build with an async plugin setup() are
all isolated and should run concurrently, but they are currently defined with a
plain describe/test structure. Update the suite to use describe.concurrent or
mark each test as test.concurrent so the tempDir-backed file writes and the
Bun.spawn subprocess do not serialize unnecessarily. Keep the existing test
bodies and unique scenarios (like the gated build, sleepy setup, starter
onStart, and explosive rejection) unchanged while switching the test
declarations to concurrent form.
🪄 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: a83dac9e-f170-417d-87be-caa7559cad8a

📥 Commits

Reviewing files that changed from the base of the PR and between 1498d7b and d89ca29.

📒 Files selected for processing (5)
  • src/jsc/bindings/ZigGlobalObject.cpp
  • src/jsc/bindings/ZigGlobalObject.h
  • src/jsc/bindings/headers.h
  • src/runtime/api/JSBundler.rs
  • test/bundler/bun-build-plugin-async-setup.test.ts

Comment thread src/runtime/api/JSBundler.rs
Comment thread test/bundler/bun-build-plugin-async-setup.test.ts Outdated
Comment thread test/bundler/bun-build-plugin-async-setup.test.ts Outdated

@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 bugs, but this restructures Bun.build's plugin-setup path around a heap-leaked DeferredBuild with manual lifetime, GC rooting across async boundaries, and a documented behavior change (async setup() rejection now rejects the returned promise instead of throwing synchronously) — worth a human pass on the ownership transfers and the CodeRabbit rooting note before merging.

Extended reasoning...

Overview

This PR removes the waitForPromise call from Config::from_js in src/runtime/api/JSBundler.rs, which previously spun the event loop synchronously while waiting for an async plugin setup() to settle. It replaces that with a suspend/resume model: from_js now returns Ok(None) plus a SuspendedPluginSetup when a step yields a pending promise, and build() boxes that state into a leaked DeferredBuild whose .then reactions (registered as two new PromiseFunctions in ZigGlobalObject.h/.cpp/headers.h) continue the plugin chain, finish the config parse, and schedule the bundle. A new test file exercises the non-blocking return, chained plugins, onStart ordering, post-await config mutation, and rejection.

Security risks

None identified — no auth/crypto/permissions/parsing-of-untrusted-input surface is touched. The change is confined to bundler config parsing and promise plumbing.

Level of scrutiny

High. Bun.build is a core, widely-used API and the change introduces:

  • Manual heap ownership via bun_core::heap::into_raw/take of DeferredBuild, reclaimed by exactly one of two promise reactions (or re-leaked on re-suspension). Any path where neither reaction runs, or both run, is a leak/UAF.
  • JS values held across an async boundary: config_js, plugins_array, and result are jsc::Strong, but SuspendedPluginSetup::pending is a bare JSValue (CodeRabbit flagged this). It's consumed within the same synchronous build() frame before .then() attaches, so it's likely fine, but that's exactly the kind of GC-window reasoning a maintainer should confirm.
  • Ownership transfer of the Plugin registry between the scopeguard, SuspendedPluginSetup, DeferredBuild, and the completion task, with disarm points on each path.
  • A user-visible behavior change: rejecting async setup() (or a config-parse error after an async step) now rejects the build promise rather than throwing synchronously from Bun.build(...).

Other factors

The PR description is thorough, tests look well-targeted (including a subprocess hang guard verified against USE_SYSTEM_BUN=1), and no regressions are reported across the existing plugin suites. CodeRabbit left one substantive inline (pending rooting) and two style nits that are unaddressed. Given the memory/GC sensitivity and the API behavior change, this exceeds the bar for auto-approval.

@robobun

robobun commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator Author

Checked #14539 before adding a Fixes line: not this bug. esbuild-plugin-autoload's setup() is synchronous and only registers onLoad callbacks, so it never reaches the waitForPromise this PR removes (that call only fires when runSetupFunction itself returns a pending promise, i.e. an async setup() or pending onStart() promises). #14539's deadlock is in the async onLoad dispatch path, which is a different code path. Leaving it open.

@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.

No issues found, but this reworks Bun.build's plugin setup control flow with manual heap lifecycle (into_raw/take) for DeferredBuild, GC rooting across suspend/resume, and a documented behavior change (async setup() rejection now rejects instead of throwing) — worth a human look.

Extended reasoning...

Overview

This PR eliminates a waitForPromise call in Config::from_js that made Bun.build synchronously spin the event loop when a plugin's async setup() returned a still-pending promise. The fix converts the plugin setup chain into a proper promise continuation: when a step is pending, config parsing suspends into a heap-allocated DeferredBuild, Bun.build returns its result promise immediately, and the remaining plugins plus finish_from_js run from the .then reaction. The C++ side (ZigGlobalObject.{h,cpp}, headers.h) is mechanical — two new PromiseFunctions enum entries and host-function declarations. The substantive change is ~300 net lines in src/runtime/api/JSBundler.rs introducing SetupStep, SuspendedPluginSetup, DeferredBuild, run_one_plugin_setup, finish_from_js, and two host callbacks.

Security risks

None identified. No auth, crypto, permissions, or untrusted-input parsing surfaces are touched.

Level of scrutiny

This deserves careful human review rather than auto-approval:

  • Manual memory lifecycle: DeferredBuild is leaked via heap::into_raw and reclaimed via heap::take in exactly one of two promise reactions, then re-leaked if the chain suspends again. The invariant that exactly one reaction fires per .then is load-bearing for both UAF and leak avoidance. The Plugin registry ownership also transfers between the scopeguard, SuspendedPluginSetup, DeferredBuild, and finally the completion task or Plugin::destroy on failure — several handoff points to verify.
  • GC rooting across async boundaries: config_js, plugins_array, the pending promise, and the result promise are all held across event-loop turns via jsc::Strong/JSPromiseStrong. CodeRabbit already caught one unrooted JSValue (fixed in 78a2c43), which suggests this area benefits from a second pair of eyes.
  • Behavior change: On the previously-blocking path, a rejecting async setup() (or a config parse error surfaced after an async setup settles) now rejects the returned promise instead of throwing synchronously from the Bun.build(...) call site. This is documented in the PR description but is a user-visible semantic shift.
  • Control-flow refactor of a core public API: from_js is split into from_js + run_one_plugin_setup + finish_from_js, with the plugin loop now callable both synchronously and from a promise reaction. The is_last computation changed from i == length.saturating_sub(1) to index + 1 == length, and iteration switched from array_iterator to indexed get_index — both look correct but are the kind of refactor detail worth confirming.

Other factors

Test coverage is good (6 new tests covering the hang repro via subprocess with SIGKILL guard, I/O-suspended setup, onStart ordering, multi-plugin resume, post-await config mutation, and rejection propagation), and the PR description reports the relevant existing plugin test suites still pass. All prior bot review comments (GC rooting, comment length, test concurrency) are marked addressed in 78a2c43. The bug-hunting pass found nothing. Still, the combination of manual heap ownership, GC-rooting subtlety, and a public-API behavior change puts this outside the auto-approve threshold.

robobun and others added 4 commits July 26, 2026 01:39
Config::from_js ran each plugin's setup() during synchronous config
parsing and, when runSetupFunction returned a promise that was still
pending, called waitForPromise to spin the event loop until it settled.
That re-enters the event loop from whatever callback called Bun.build
(the bug class tracked in #33261) and also deadlocks deterministically
when the code that would settle the promise is later on the same stack.

Bun.build already returns a promise, and the runSetupFunction builtin
already folds a pending async setup() together with any pending
onStart() promises into the one promise it hands back to Rust. So:
split Config::from_js at the plugin loop, and when a step's promise is
still pending, park the parse state in a heap DeferredBuild and finish
the chain, the rest of the config parse, and the bundle scheduling from
that promise's .then continuation.

The synchronous path (no plugins, or every setup() and onStart()
settled before returning) is unchanged. The only observable difference
is on the path that previously blocked: a config-parse error raised
after an async setup() settles now rejects Bun.build's promise instead
of throwing synchronously, and a rejecting async setup() does the same.
SuspendedPluginSetup held the still-pending promise runSetupFunction
returned as a bare JSValue across the gap between Config::from_js
recording the suspension and build() attaching the .then reactions.
Hold it in a jsc::Strong instead so the rooting is explicit rather than
relying on the JS settling chain and the conservative stack scan; this
is also what ServePlugins does for the analogous promise.

Also make the new test suite concurrent and trim its header comment.
@robobun
robobun force-pushed the farm/1cedaf8f/bun-build-async-plugin-setup branch from 78a2c43 to 14ca1fc Compare July 26, 2026 01:50
@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (was 594 commits behind, applied cleanly) and added a test for the Promise.race([Bun.build(cfg), timeout]) escape pattern: a never-settling setup() must not prevent the caller from racing a timeout against the returned promise.

Local verification after rebase:

USE_SYSTEM_BUN=1 bun test test/bundler/bun-build-plugin-async-setup.test.ts
  -> 4 pass, 3 fail (two subprocess fixtures SIGKILLed at 15s with no output;
     the async-rejection test throws synchronously instead of rejecting)
bun bd test test/bundler/bun-build-plugin-async-setup.test.ts
  -> 7 pass

Regression suites unchanged: bun-build-api.test.ts 49 pass, bundler_plugin.test.ts 52 pass, bundler_plugin_chain.test.ts + plugin-error-nested-throw.test.ts + plugin-sync-exception-fallback.test.ts 17 pass.

Comment thread src/runtime/api/JSBundler.rs Outdated
Comment thread src/runtime/api/JSBundler.rs Outdated
Comment thread src/runtime/api/JSBundler.rs Outdated
Comment thread src/runtime/api/JSBundler.rs Outdated
Comment thread src/runtime/api/JSBundler.rs Outdated
Comment thread src/runtime/api/JSBundler.rs Outdated
Comment thread src/runtime/api/JSBundler.rs Outdated
Comment thread src/runtime/api/JSBundler.rs Outdated
Comment thread src/runtime/api/JSBundler.rs Outdated
Comment thread src/runtime/api/JSBundler.rs Outdated
Comment thread src/runtime/api/JSBundler.rs Outdated
Comment thread src/runtime/api/JSBundler.rs Outdated
Comment thread src/runtime/api/JSBundler.rs Outdated
Comment thread src/runtime/api/JSBundler.rs Outdated
Comment thread src/runtime/api/JSBundler.rs Outdated
Comment thread src/runtime/api/JSBundler.rs Outdated
Comment thread src/runtime/api/JSBundler.rs Outdated
Comment thread src/runtime/api/JSBundler.rs Outdated
Comment thread src/runtime/api/JSBundler.rs Outdated
Comment thread src/runtime/api/JSBundler.rs Outdated
Comment thread src/runtime/api/JSBundler.rs Outdated
Comment thread src/runtime/api/JSBundler.rs Outdated
Comment thread src/runtime/api/JSBundler.rs Outdated
Comment thread src/runtime/api/JSBundler.rs Outdated
Comment thread src/runtime/api/JSBundler.rs Outdated
Comment thread src/runtime/api/JSBundler.rs Outdated
comment-cop flags every added multi-line comment on claude-labeled PRs.
The flagged blocks were ordinary doc comments, not workaround
justifications, but they were also more verbose than they needed to be:
several restated the same #33261 invariant. Each block is now a single
line; the SAFETY comments on unsafe blocks are exempt and unchanged.
@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

Trimmed every flagged doc comment to one line in 112f49c (comment-cop fires on any added 2+-line comment on claude-labeled PRs, SAFETY: blocks exempt). The flagged blocks were ordinary API docs, not workaround justifications, but several of them did restate the same invariant more than once; the content is now in the PR description and the type/function names. All 26 threads resolved.

@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, but this reworks Bun.build's plugin-setup control flow around a heap-leaked DeferredBuild that owns a raw *mut Plugin across suspension points, adds two native promise reactions, and changes user-visible behavior (async setup() rejection now rejects the returned promise instead of throwing synchronously). Worth a human look at the ownership/GC contracts.

What was reviewed:

  • *mut Plugin ownership across all terminal paths (sync scopeguard, suspended → DeferredBuild, resume success/fail/re-suspend, reject reaction) — every path either moves it to the completion task or calls Plugin::destroy.
  • GC-rooting of the pending promise: SuspendedPluginSetup.pending: Strong stays alive through Box::new(DeferredBuild) and drops after .then attaches; the re-suspend path in on_deferred_build_resolve has pending on-stack with no allocation before .then.
  • Config::from_js has a single caller (build()), so the signature change is contained; is_last = index + 1 == length matches the old i == length.saturating_sub(1).
  • promiseFunctionsSize bumped 42→44 to match the two new enum variants.
Extended reasoning...

Overview

This PR removes the waitForPromise call from Config::from_js in src/runtime/api/JSBundler.rs and replaces it with a promise-chaining continuation. When a plugin setup() returns a still-pending promise, from_js now records where it stopped in a SuspendedPluginSetup, build() heap-allocates a DeferredBuild owning the partial state, and the pending promise's .then reactions (two new PromiseFunctions entries wired through ZigGlobalObject.{cpp,h} and headers.h) resume the chain one plugin at a time. The post-plugin config parsing was extracted into finish_from_js, and the per-plugin loop body into run_one_plugin_setup, both shared between the sync and deferred paths. A new 7-test file exercises the gated-hang repro, chained plugins, onStart() ordering, config mutation after await, Promise.race escape, and async rejection.

Security risks

None identified. No new untrusted-input parsing; the plugin array and config object were already read from user JS by the same accessors. No auth/crypto/permission surface.

Level of scrutiny

High. This is core Bun.build runtime code with manual memory ownership: a Box<DeferredBuild> is leaked via heap::into_raw and reclaimed in exactly one of two native .then reactions (or re-leaked on further suspension), and it holds a raw *mut Plugin whose destruction must happen on every failure path. It also holds three jsc::Strongs and a JSPromiseStrong that must not leak or be dropped early. The PR description explicitly calls out a user-visible behavior change (async setup() rejection → promise rejection instead of synchronous throw), and this is part of a larger event-loop-reentry audit (#33261). REVIEW.md's memory-safety section — "every acquisition paired with release at the acquisition site", "exactly one named owner, released exactly once" — applies directly here, and a maintainer should confirm the ownership graph.

Other factors

The bug-hunting system found nothing. All prior review threads (CodeRabbit's GC-rooting concern, comment-cop's doc-comment length flags, describe.concurrent) are resolved and the fixes are visible in the diff. Tests look solid: subprocess fixtures for the hang cases with SIGKILL guards, and the author verified fail-on-system-bun / pass-on-debug plus the four adjacent regression suites. The is_last computation, scopeguard disarm placement, and single-caller signature change all check out. But the combination of heap-leaked FFI context, raw plugin pointer ownership across async boundaries, new C++ enum wiring, and an intentional API behavior change puts this well outside the "simple/mechanical" bar for auto-approval.

@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

CI on 112f49c (build 81943) is red on infra, not the diff:

  • 85 test lanes passed, 0 failed. bun-build-plugin-async-setup.test.ts appears in no annotation; JSBundler / DeferredBuild appear in no log.
  • 5 build-bun lanes passed including linux-x64-asan (the lane that exercises the DeferredBuild heap lifecycle).
  • The 8 failed build-bun lanes are all step-failed-outside-runner = "sibling build-cpp errored, nothing to link", with the sibling expired or errored [0s] before an agent picked it up. Every build-cpp that ran passed.
  • binary-size flags +544 KB, but its baseline is canary #79916 and this branch is rebased onto #81770. That delta is main's growth since the last successful canary, not this diff (+540/-80 lines). Main's last five builds (81770/81444/81275/81265/80215) are failed/canceled, so the baseline cannot update.

Happy to re-roll once the fleet recovers, but doing it now would discard 85 green test lanes for the same starved queue.

@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as part of a cleanup of stale pull requests. This PR has had no new commits since 2026-07-26, it conflicts with main, and its last CI run failed. This is not a judgment on the fix itself. If the problem still reproduces on a current build, reopen this PR after a rebase or open a new one against main.

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.

1 participant