Skip to content

Don't panic when export default interface starts an expression or labeled statement#31263

Merged
Jarred-Sumner merged 3 commits into
mainfrom
farm/ac618056/fix-export-default-interface-panic
May 23, 2026
Merged

Don't panic when export default interface starts an expression or labeled statement#31263
Jarred-Sumner merged 3 commits into
mainfrom
farm/ac618056/fix-export-default-interface-panic

Conversation

@robobun

@robobun robobun commented May 23, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes a transpiler/parser panic found by fuzzing (panic: Internal error: unexpected export default stmt data):

bun -e 'new Bun.Transpiler({loader:"tsx", target:"bun"}).transformSync("export default interface=2")'
panic: Internal error: unexpected export default stmt data
Crashed while printing input.js

The same crash is reachable with the plain js/jsx loaders (loader: "js" panics on the released canary too), and through other shapes like export default interface: 2 (labeled statement) and export default interface => 1 (arrow) on every loader.

Cause

In parse_stmt.rs, the export default handling enters a statement path whenever the next token is function, class, or the contextual keyword interface, then wraps whatever statement comes back in S.ExportDefault { value: Stmt(...) } unless it is a bare type annotation.

Since #31239, interface=2 correctly comes back from the nested parse_stmt call as a plain expression statement (the TS contextual-keyword path only applies when the expression is exactly the bare identifier), and interface: 2 comes back as a labeled statement. Neither is one of the two statement kinds (S.Function / S.Class) that the visit pass and printer support inside S.ExportDefault, so the printer hits its Internal error: unexpected export default stmt data panic.

Fix

In the export default statement path of parse_stmt.rs, any nested statement that isn't a function declaration, class declaration, or erased TypeScript declaration now reports a syntax error at the interface token instead of being wrapped into an S.ExportDefault the later passes can't handle:

1 | export default interface=2
                   ^
error: Unexpected "interface"

This covers the expression forms (interface = 2, interface + 1), the arrow form (interface => 1, where interface becomes a parameter binding that no later reserved-word check would flag), and the labeled-statement form (interface: 0). All of these are invalid inputs — esbuild and tsc reject every one of them (esbuild itself crashes with panic: Internal error on the labeled/arrow shapes), so no valid code changes behavior.

Real export default interface Foo {} declarations, export default function/class, and the async/abstract paths are unchanged.

How did you verify your code works?

New test export default interface that is not an interface declaration does not crash in test/bundler/transpiler/transpiler.test.js covering the fuzz repro (tsx), the ts and js loaders, the expression/arrow/labeled variants, and the still-working interface-declaration forms.

  • Without the fix, the new test crashes the test process with panic: Internal error: unexpected export default stmt data.
  • bun bd test test/bundler/transpiler/transpiler.test.js — 127 pass, 0 fail
  • bun bd test test/bundler/transpiler/scope-mismatch-panic.test.ts test/bundler/esbuild/ts.test.ts — 65 pass, 0 fail
  • cargo check -p bun_js_parser, cargo clippy -p bun_js_parser, cargo fmt --check pass

…n or label

"export default interface=2" parsed the inner statement as an expression
statement and wrapped it in S.ExportDefault, an AST shape the printer
rejects with "Internal error: unexpected export default stmt data".
Handle an SExpr result by keeping the statement (matching esbuild) and
report a syntax error for a labeled statement instead of panicking.
@coderabbitai

coderabbitai Bot commented May 23, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, we couldn't start this review because you've used your available PR reviews for now.

Your plan currently allows 2 reviews/hour. Refill in 12 minutes and 52 seconds.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

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

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than trial, open-source, and free plans. In all cases, review capacity refills continuously over time.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: be0563db-ff02-44cf-9964-5bf2dd4dd979

📥 Commits

Reviewing files that changed from the base of the PR and between 3aa8a0e and 1199f5d.

📒 Files selected for processing (2)
  • src/js_parser/parse/parse_stmt.rs
  • test/bundler/transpiler/transpiler.test.js

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

@robobun

robobun commented May 23, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 5:31 AM PT - May 23rd, 2026

@robobun, your commit 1199f5d72f499645a0ca0f5a6f4742832011fa8a passed in Build #57221! 🎉


🧪   To try this PR locally:

bunx bun-pr 31263

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

bun-31263 --bun

Comment thread src/js_parser/parse/parse_stmt.rs Outdated
@robobun

robobun commented May 23, 2026

Copy link
Copy Markdown
Collaborator Author

The parser fuzzer hit a second signature from this same root cause, this time through Bun.build (bundler path) instead of Bun.Transpiler:

panic: internal error: entered unreachable code: Unexpected unbound symbol!
// e.ts: export default interface=7
await Bun.build({ entrypoints: ["./e.ts"], target: "browser", throw: false })

In the bundler, the S.ExportDefault { value: Stmt(SExpr) } shape means the interface identifier inside the dropped expression is never visited, so its ref is still a source-contents-slice ref when the linker runs and NumberRenamer::name_for_symbol hits the unreachable!("Unexpected unbound symbol!") at src/js_printer/renamer.rs:903.

Verified on origin/main (30c0071):

  • without this diff: the Bun.build repro above panics in the renamer as described;
  • with this diff applied: Bun.build returns success: false with BuildMessage: "interface" is a reserved word and cannot be used in strict mode, and the labeled form (export default interface: 7) returns BuildMessage: Unexpected "interface" — no panic in either case.

So this PR resolves both fuzz signatures; no separate change is needed for the bundler path.

(Side note for anyone adding a Bun.build-path test here: use an absolute/on-disk entrypoint. The fuzzer's exact invocation — files: with a relative ./e.ts entrypoint — trips an unrelated debug-only assertion, assertion failed: crate::is_absolute(self.text) in BundleV2::enqueue_entry_item, before the parser ever runs in a debug build.)

…er export default interface

An expression statement result relied on the visit pass flagging
"interface" as a strict-mode reserved word, but that check only covers
identifier expressions, not arrow parameter bindings, so
"export default interface => 1" would have silently dropped the export.
Route every non-function/class/interface result through the same
Unexpected "interface" error instead.

@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 — the follow-up in f680f99 routes every non-declaration result through the syntax-error arm as suggested, and the new tests cover the arrow-param shape.

Extended reasoning...

Overview

This PR changes one match arm in src/js_parser/parse/parse_stmt.rs (the export defaultfunction/class/interface statement path) and adds a regression test in test/bundler/transpiler/transpiler.test.js. The previous _ => {} fall-through, which built an S.ExportDefault { value: Stmt(...) } that the printer/visit pass cannot handle, now emits Unexpected "interface" and returns a SyntaxError. This branch is only reachable when the token after export default is the contextual keyword interface and the nested parse did not yield STypeScript/SFunction/SClass — i.e., exclusively spec-invalid inputs that previously panicked.

Security risks

None. This is parser error-recovery on already-invalid source; no auth, crypto, FS, or network surface is touched. The new code path strictly narrows behavior from "panic" to "logged syntax error", and range_of_identifier is bounds-checked so the error formatting can't read out of range.

Level of scrutiny

Low–medium. The diff is ~20 lines in a single match arm plus tests. The only valid-code paths through this branch (export default function, export default class, export default interface Foo {}) are matched by the explicit arms above and remain unchanged — confirmed by the exp("export default interface Foo {}", "") assertions in the new test. Every input that reaches the new _ arm uses interface as an identifier in a strict-mode module, which is invalid regardless, so erroring here cannot reject any program that should compile.

Other factors

I previously flagged (inline, on the first revision) that returning SExpr as-is would silently drop export default interface => 1 because the strict-mode reserved-word check doesn't cover arrow-param bindings. The author addressed this in f680f99 by collapsing SExpr into the catch-all error arm, and the test now asserts that exact shape throws on both ts and js loaders. The bug-hunting system found nothing on the updated revision, full transpiler/TS test suites pass per the PR description, and there is no CODEOWNERS entry for src/js_parser/.

@robobun

robobun commented May 23, 2026

Copy link
Copy Markdown
Collaborator Author

CI triage for build 57181: the only failing job was package-binary-size, reporting ~+11 MB on every Linux target and ±a few KB everywhere else. That delta is not from this change — the canary baseline (main) just picked up #31200 (zstd-compressed libicudata), which shrank the Linux binaries by ~11 MB, and this branch predated it, so the comparison was against the newer, smaller baseline. The test lanes were green (two unrelated Windows flakes passed on retry).

Updated the branch with main so the size comparison is apples-to-apples; this also folds in #31257, which touches the adjacent export default abstract path in the same function. No changes to the parser diff itself.

@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 — the follow-up in f680f99 routes every non-function/class/interface-declaration shape through the Unexpected "interface" error, closing the arrow-param hole I flagged.

Extended reasoning...

Overview

This PR changes one match arm in src/js_parser/parse/parse_stmt.rs (the export defaultfunction/class/interface branch of t_export) and adds a regression test in test/bundler/transpiler/transpiler.test.js. Previously the catch-all _ => {} arm let any non-SFunction/SClass/STypeScript statement fall through and get wrapped in S::ExportDefault { value: Stmt(...) }, which the printer/renamer cannot handle and which panics. The new arm reports Unexpected "interface" at stmt.loc and returns Err(SyntaxError) instead.

The branch is only entered when the token after default is function, class, or the contextual identifier interface. parse_fn_stmt only yields SFunction/STypeScript and parse_class_stmt only yields SClass, so the new error arm is reachable solely from the interface case, and stmt.loc there is always the interface token — range_of_identifier produces the right span. Because the arm returns Err, parsing aborts before the visit pass, so the Label scope recorded by the interface: 0 shape never needs to be reconciled.

Security risks

None. This is a parser error-reporting change on already-invalid input; it narrows behavior from "panic" to "syntax error" and cannot affect codegen for valid programs.

Level of scrutiny

Low–medium. The diff is ~20 lines in a single match arm plus a test. The only valid-code path through this match (export default interface Foo {}STypeScript) is unchanged and is asserted by the new test. The author addressed my earlier inline comment exactly as suggested, and the test now explicitly covers the arrow form (interface => 1) on both ts and js loaders.

Other factors

  • My prior inline comment is resolved; the SExpr early-return that silently dropped the export default is gone.
  • Transpiler suite passes per the PR description; cargo check/clippy/fmt clean.
  • The Buildkite "binary size" failure (+11 MB on Linux targets only, ~0 on every other platform) is clearly a baseline/infra artifact unrelated to this 20-line change and should not block.

@Jarred-Sumner Jarred-Sumner merged commit ffd67d8 into main May 23, 2026
45 of 55 checks passed
@Jarred-Sumner Jarred-Sumner deleted the farm/ac618056/fix-export-default-interface-panic branch May 23, 2026 07:00
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