Skip to content

macro: report the user's error when a macro throws synchronously - #34087

Closed
robobun wants to merge 4 commits into
mainfrom
farm/0492ae2f/macro-sync-throw-error-message
Closed

robobun wants to merge 4 commits into
mainfrom
farm/0492ae2f/macro-sync-throw-error-message

Conversation

@robobun

@robobun robobun commented Jul 13, 2026 •

Copy link
Copy Markdown
Collaborator

Reproduction

// m.ts
export function syncThrow() { throw new Error("this is the real reason the build broke"); }
export async function asyncThrow() { throw new Error("this is the real reason the build broke"); }
// c.ts
import { syncThrow } from "./m.ts" with { type: "macro" };
console.log(syncThrow());

Before this change, bun build c.ts reports:

error: cannot coerce Exception (JSType(0)) to Bun's AST. Please return a simpler type
    at c.ts:2:13

The user's error message and location are completely lost. The byte-equivalent async function (asyncThrow) already reports correctly:

error: this is the real reason the build broke
      at asyncThrow (m.ts:2:48)
error: macro threw exception
    at c2.ts:2:13

Cause

In Run::run_async (src/js_parser_jsc/Macro.rs), when macro_callback.call() returns Err, the exception is taken off the VM and fed directly into the result-coercion switch as if it were the macro's return value. The JSC Exception cell matches none of the known tags and falls through to the "cannot coerce" fallback. The promise-rejection path (T::Promise in coerce) already reports the rejection via unhandled_rejection and returns MacroFailed.

Fix

When the sync call fails, take the exception, report it through uncaught_exception (matching the existing T::Error arm) and return MacroError::MacroFailed (matching the T::Promise rejection arm). A sync throw and an async throw now produce identical output.

After this change, bun build c.ts reports:

error: this is the real reason the build broke
      at syncThrow (m.ts:1:88)
error: macro threw exception
    at c.ts:2:13

Verification

test/bundler/transpiler/macro-test.test.ts gains four cases covering sync throw, async throw (regression guard), sync throw of a non-Error value, and sync throw under bun run. The three sync cases fail on 1.4.0 with "cannot coerce Exception (JSType(0))" and pass with this change; the whole file and test/regression/issue/03830.test.ts pass with bun bd test.


[review] gate passed · iteration 1 · 2 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/transpiler/macro-test.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (147fac1ff)

test/bundler/transpiler/macro-test.test.ts:
[macro] call escapeHTML
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call escape
[macro] call addStrings
[macro] call ad
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (90d7a72f5)

test/bundler/transpiler/macro-test.test.ts:
(pass) bun builtins can be used in macros [0.03ms]
(pass) latin1 string [0.01ms]
(pass) ascii string
(pass) type coercion [0.06ms]
(pass) escaping [0.15ms]
(pass) utf16 string [0.01ms]
(pass) import aliases [0.03ms]
(pass) default import [0.01ms]
(pass) namespace import [0.02ms]
(pass) ireturnapromise [0.06ms]
(pass) macro that throws reports the user's error message > sync throw via bun build [14.44ms]
(pass) macro that throws reports the user's error message > sync throw via bun run [11.16ms]
(pass) macro that throws reports the user's error message > async throw via bun build [12.95ms]
(pass) macro that throws reports the user's error message > sync throw of non-Error via bun build [13.44ms]
(pass) object argument with a sparse numeric key [11.59ms]

 15 pass
 0 fail
 85 expect() calls
Ran 15 tests across 1 file. [245.00ms]
__F:0:S:0
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/transpiler/macro-test.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (147fac1ff)

test/bundler/transpiler/macro-test.test.ts:
[macro] call escapeHTML
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call escape
[macro] call addStrings
[macro] call ad
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped) in 669ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[0/4] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: component rust-std is up to date

info: checking for self-update (current version: 1.29.0)
  nightly-2026-05-06-x86_64-unknown-linux-gnu unchanged - rustc 1.97.0-nightly (e95e73209 2026-05-05)

�[1m�[92m    Blocking�[0m waiting for file lock on build directory
�[1m�[92m   Compiling�[0m bun_js_parser_jsc v0.0.0 (/workspace/bun/src/js_parser_jsc)
�[1m�[92m   Compiling�[0m bun_install_jsc v0.0.0 (/workspace/bun/src/install_jsc)
�[1m�[92m   Compiling�[0m bun_runtime v0.0.0 (/worksp
... (truncated)
diff hotspot
src/js_parser_jsc/Macro.rs                 | 14 ++++++---
 test/bundler/transpiler/macro-test.test.ts | 49 +++++++++++++++++++++++++++++-
 2 files changed, 57 insertions(+), 6 deletions(-)

gate history · 2 passed · 0 rejected · iteration 1

evidence per changed file
file                                        reads  edits  tests
src/js_parser_jsc/Macro.rs                      3      4      0
test/bundler/transpiler/macro-test.test.ts      2      4      0

When a macro function threw synchronously, the raw JSC Exception cell was
handed to the result coercion switch, which has no arm for it and reported
"cannot coerce Exception (JSType(0)) to Bun's AST. Please return a simpler
type" with no mention of the user's error message or location. The
byte-equivalent async function (promise rejection) reported correctly via
unhandled_rejection.

Route the sync-call exception through uncaught_exception (matching the
existing T::Error arm) and return MacroFailed (matching the T::Promise
rejection arm), so a sync throw and an async throw now report identically.
@coderabbitai

coderabbitai Bot commented Jul 13, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7d0520dc-1595-4972-8729-c71e1be2a3ae

📥 Commits

Reviewing files that changed from the base of the PR and between c36b1fa and 147fac1.

📒 Files selected for processing (1)
  • test/bundler/transpiler/macro-test.test.ts

Walkthrough

Changes

Macro exception handling

Layer / File(s) Summary
Callback error propagation and regression coverage
src/js_parser_jsc/Macro.rs, test/bundler/transpiler/macro-test.test.ts
Macro callback errors are reported as unhandled exceptions and return MacroFailed; tests cover synchronous, asynchronous, and non-Error throws during bun build and bun run.

Suggested reviewers: nektro

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: synchronous macro throws now report the user's error instead of a coercion failure.
Description check ✅ Passed The description covers the PR's behavior change and includes verification results, though it uses different headings than the template.
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.

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 Biome (2.5.3)
test/bundler/transpiler/macro-test.test.ts

File contains syntax errors that prevent linting: Line 1: Expected a semicolon or an implicit semicolon after a statement, but found none; Line 1: Expected a semicolon or an implicit semicolon after a statement, but found none; Line 13: Expected a semicolon or an implicit semicolon after a statement, but found none; Line 13: Expected a semicolon or an implicit semicolon after a statement, but found none; Line 15: Expected a semicolon or an implicit semicolon after a statement, but found none; Line 15: Expected a semicolon or an implicit semicolon after a statement, but found none


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

@robobun

robobun commented Jul 13, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:17 PM PT - Jul 13th, 2026

✅ @autofix-ci[bot], your commit 147fac1fff5c57ff4b9d45261250d68ad78abfe7 passed in Build #72555! 🎉


🧪   To try this PR locally:

bunx bun-pr 34087

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

bun-34087 --bun

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

LGTM — sync-throw path now mirrors the established report_active_exception_as_unhandled pattern.

Checked that take_exception(e) + is_termination_exception() guard + uncaught_exception matches JSGlobalObject::report_active_exception_as_unhandled byte-for-byte, and that returning MacroFailed routes to the existing "macro threw exception" log entry via __bun_macro_context_call. Verified the T::Error and T::Promise rejection arms already use the same reporting/return shape, so behavior is now consistent across sync/async. Tests follow harness conventions (concurrent subprocess spawns, pipes drained via Promise.all, tempDir) and cover the variant matrix.

Extended reasoning...

Overview

Fixes error reporting when a bundler macro throws synchronously. Previously the JSC Exception cell was fed into the AST-coercion switch as if it were the macro's return value, producing an opaque "cannot coerce Exception (JSType(0))" message. Now the exception is taken off the VM and reported via uncaught_exception, matching how async rejections and returned Error objects were already handled. Two files touched: a ~10-line change in src/js_parser_jsc/Macro.rs:583-592 and four new test cases in test/bundler/transpiler/macro-test.test.ts.

Security risks

None. This is error-message reporting on an already-failing path; no new inputs are parsed, no new resources acquired, and the exception is only printed (not evaluated).

Level of scrutiny

Low-to-medium. The change is small and mechanically follows an existing in-tree helper — the new Err(e) arm is byte-equivalent to JSGlobalObject::report_active_exception_as_unhandled (src/jsc/JSGlobalObject.rs:1043), including the termination-exception guard. The MacroFailed return matches the T::Promise rejection arm and the ResolveMessage/BuildMessage arm in the same function, so downstream error-log handling in __bun_macro_context_call is unchanged. No lifetime, ownership, or GC-rooting changes.

Other factors

Test coverage is solid: four spawned-subprocess cases covering sync/async throw, non-Error throw, and both bun build and bun run entry points. Tests assert the user's message appears, the old "cannot coerce" message does not, and exitCode == 1. The PR description confirms the sync cases fail on 1.4.0 and pass with the change, and that 03830.test.ts still passes. No prior reviewer comments to address.

Printing a macro's stack trace caches an Arc<ParsedSourceMap> on the
bundler-worker macro VM. bun build never tears that VM down on the
failure-exit path (Global::exit is called directly), so the cache is
reachable only via the worker's TLS, which LSan does not root-scan. With
the ASAN runner's detect_leaks=1 inherited through bunEnv the child aborts
with 134 instead of exiting 1.

This is pre-existing (the async case, unchanged by this PR, leaks the same
way) and unrelated to the error-reporting fix being tested; follow the
pattern used by other tests that spawn bun build under ASAN.

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

🤖 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/js_parser_jsc/Macro.rs`:
- Around line 583-594: In the Err branch handling macro_callback.call within the
VM API-lock flow, replace the duplicated take_exception, termination check, and
uncaught_exception logic with the existing
JSGlobalObject::report_active_exception_as_unhandled helper, while preserving
the MacroError::MacroFailed return.

In `@test/bundler/transpiler/macro-test.test.ts`:
- Around line 154-166: Scope the ASAN_OPTIONS override in the Bun.spawn setup to
only the bun build invocation, leaving bun run executions with the original
bunEnv.ASAN_OPTIONS unchanged. Use the existing command value represented by cmd
to conditionally append detect_leaks=0 for the bundler-worker build cases.
🪄 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: ee59c968-7fe5-4216-945a-73bbddcfd55c

📥 Commits

Reviewing files that changed from the base of the PR and between eb0d38b and 90d7a72.

📒 Files selected for processing (2)
  • src/js_parser_jsc/Macro.rs
  • test/bundler/transpiler/macro-test.test.ts

Comment thread src/js_parser_jsc/Macro.rs
Comment thread test/bundler/transpiler/macro-test.test.ts

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

LGTM — small, focused fix that routes sync macro throws through the same reporting path async rejections already use.

What was reviewed:

  • The new Err branch uses report_active_exception_as_unhandled exactly as documented in JSGlobalObject.rs:1038-1041 and matches the pattern in event_loop.rs / ipc.rs.
  • MacroError::MacroFailed return matches the existing T::Promise rejection arm; __bun_macro_context_call skips its own log entry for MacroFailed so the caller's "macro threw exception" fallback still fires (asserted by the tests).
  • Both CodeRabbit review items (reuse helper; scope detect_leaks=0 to bun build only) are addressed and resolved; the LSan opt-out is a pre-existing bundler-worker teardown leak, not introduced here.
  • Tests follow harness conventions (tempDir, concurrent pipe drain, test.concurrent.each) and the evidence gate shows 3 cases fail on main / pass with fix.
Extended reasoning...

Overview

Two files: a ~9-line change in src/js_parser_jsc/Macro.rs (Run::run_async) and four new parameterized test cases in test/bundler/transpiler/macro-test.test.ts. Previously, when a macro callback threw synchronously, the exception JSValue was pulled off the VM and fed into the AST-coercion switch as if it were the macro's return value, producing the useless "cannot coerce Exception (JSType(0)) to Bun's AST" message. Now the Err arm reports the exception via JSGlobalObject::report_active_exception_as_unhandled and returns MacroError::MacroFailed, matching the async-rejection path so sync and async throws produce identical output.

Security risks

None. This is error-message reporting for build-time macros; no auth, crypto, permissions, or untrusted-input parsing is touched. The exception is handed to the existing centralized reporter; termination exceptions are already filtered out by that helper.

Level of scrutiny

Low-to-medium. The change is small, self-contained, and mechanically substitutes a broken pattern with the exact idiom the helper's own doc comment prescribes (JSGlobalObject.rs:1036-1051). The MacroFailed return value plugs into the existing caller flow (__bun_macro_context_call special-cases it so visit_expr prints the "macro threw exception" line — verified by the test asserting both the user's message and that string appear in stderr). No new state, no lifetime changes, no allocation.

Other factors

  • The PR's evidence gate confirms the three sync-throw tests fail on 1.4.0 with the old "cannot coerce" message and pass with the fix, in both ASAN-debug and release profiles; the async-throw case is a regression guard.
  • Both CodeRabbit review comments were addressed in c36b1fa and marked resolved. The detect_leaks=0 override is scoped only to bun build (where the pre-existing bundler-worker macro-VM teardown leak lives) and is well-commented; bun run retains full LSan coverage.
  • Tests follow the repo's harness conventions: tempDir with using, bunEnv/bunExe, concurrent stdout/stderr/exited drain, test.concurrent.each, output asserted before exit code, and a negative assertion (not.toContain("cannot coerce")) guarding the specific regression.
  • No CODEOWNERS-gated paths, no API surface change, no design decisions.

@robobun

robobun commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator Author

Closing in favor of #40059, which moves every macro onto one dedicated VM thread and decides this behavior as part of the rework.

#40059 states: "A macro that throws (or returns an Error) fails the build with the error and its stack at the call site (before: printed, call left in place)". The Run::run call path this PR patches is replaced by HostState::call and failure_from_value in src/js_parser_jsc/Macro.rs, which turn a thrown value into the build error text, stack included.

Verified on a debug build of the #40059 branch with this PR's four cases (sync throw, async throw, sync throw of a string, and sync throw under bun run): stderr carries this is the real reason the build broke with the stack, the exit code is 1, and cannot coerce does not appear. Only the macro threw exception prefix this PR asserts is gone, on purpose.

If #40059 does not land, this PR can be reopened.

@robobun robobun closed this Aug 22, 2026
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