Skip to content

Do not fail on a warning: gate Bun.Transpiler, FileSystemRouter and Worker on LogJsc::errors_to_js - #45063

Open
robobun wants to merge 11 commits into
mainfrom
robobun/e6efdb77/transpiler-warning-is-not-a-failure
Open

robobun wants to merge 11 commits into
mainfrom
robobun/e6efdb77/transpiler-warning-is-not-a-failure

Conversation

@robobun

@robobun robobun commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

Supersedes #42433 and #42430. Contains #45045: merge that one first.

Problem

  • Bun.Transpiler fails when its log holds only a warning (Handling of key and spread in JSX not aligned with SWC and esbuild  #7328, 2023). The four methods and the constructor throw a BuildMessage with level: "warn": Treating "-->" as the start of a legacy HTML single-line comment.
  • 13 sites test "the log holds a message", not "the log holds an error": 11 in src/runtime/api/JSTranspiler.rs, filesystem_router.rs:285, and flush_logs in web_worker.rs. The last two fail on a tsconfig.json warning.
  • logLevel: "error" reached transform and transformSync only.

Fix

  • New LogJsc::errors_to_js returns the value to throw, or None when the errors counter is zero. The 13 sites call it.
  • Log::child() gives each scratch log on the Bun.Transpiler path the level of logLevel.
  • Verified: transpiler.test.js (53 of 81 new tests fail on main), filesystem_router.test.ts, worker-entry-point.test.ts, worker_threads.test.ts.
  • Self-reviewed: 32 concerns raised, 28 addressed, four left as two follow-ups (Notes).

Background

  • A Log (src/ast/lib.rs) counts errors and warnings. Its level drops a message below it.
  • Log::to_js makes one BuildMessage, or an AggregateError for several. A warning beside an error stays in it.
  • Considered Bun.Transpiler: do not throw on a parser warning #42433 alone: it leaves the constructor, logLevel, FileSystemRouter and Worker. A default logLevel of "error": "warn" would still throw.

Downsides

  • A warning from a call that succeeds now goes nowhere.
  • FileSystemRouter and Worker still fail on an error-kind tsconfig.json that is not theirs.
  • Clean input: no cost found. mimalloc calls per call are unchanged (26/24/10/31). .text is 1,536 B smaller.
Notes

Two decisions for a maintainer

  1. Errors-only at every logLevel (this PR, and what esbuild's transform API does), or a default logLevel of "error" (suggested in the review of fix: jsx key after spread #7464)? With the second one logLevel: "warn" still throws on a warning, and an error beside a warning becomes a single BuildMessage.
  2. Where a warning goes when the call succeeds. This PR drops it, as FileSystemRouter.reload() already does. esbuild returns result.warnings. A way to return warnings as data is new API and is not in this PR.

History

What changes, by API

  • transformSync, transform, scan, scanImports: 9 parser warnings x 4 methods. Main throws in 30 of 36 cells (12 of 36 at logLevel: "error"). This PR: 0 of 36 at every level. A JSON duplicate key through the json loader: main throws 3 of 3, this PR 0 of 3.
  • Constructor: 12 tsconfig / macro options that log only a warning. Main throws 12 of 12 at every level. This PR constructs 12 of 12 and writes nothing to stderr.
  • new FileSystemRouter(): the first construct over a tsconfig.json with a warning threw and the second returned. Now both return.
  • Worker: a warning-only tsconfig.json beside the entry fired error twice on a Web Worker while the worker ran. A node:worker_threads parent with no error listener ended with exit code 1. Now no event fires.
  • An error beside a warning: the thrown AggregateError holds both at the default level and at "warn", as before. At "error" the thrown value is the single error for all four methods and for the constructor. Before, scan, scanImports and the constructor kept the warning.
  • transform(): when the transform fails and the log holds only a warning, the promise rejects with the failure (ParseError), not with the warning.

Limits

  • With logLevel = "error" in bunfig.toml, a worker now keeps a tsconfig.json warning that main dropped. The log of a worker VM is at Warn whatever bunfig.toml says, and Log::child() follows its parent. The warning fires no event alone. Beside an error the event carries an AggregateError. logger: make Log::default() follow the configured log level (Workers ignored bunfig logLevel) #37908 makes the worker log follow the level of the process, which removes the case.
  • FileSystemRouter and Worker fail on what the resolver logs the first time it reads a directory. This PR removes the warning half. An error-kind tsconfig.json (for example {"compilerOptions": {) still fails the first new FileSystemRouter() and still fires error on a worker that runs. A separate route log and a scoped log for the entry resolution would remove that half too.
  • flush_logs tested msgs.is_empty(). It now tests the errors counter, which Log::add_msg does not change. A message that is put in a worker's log that way no longer fires a late error event.
  • "key" prop ignored (<div key />) is a warning here and an error in esbuild. That input now transpiles with no message.
  • An invalid define key and an external with two * log an error, and the constructor still succeeds. Not changed.

Follow-ups (not in this PR)

Other open PRs that touch this

Measurements

Release builds of the merge base and of this PR at aadaa7d, linux x64. After that commit one pointer store moved from Transpiler::set_log to the Bun.Transpiler constructor (#45045). The counts below do not depend on it. The byte counts can differ by a few bytes.

  • mimalloc calls per call (breakpoint hit counts on the nine allocation entry points, JIT off), transformSync / scan / scanImports / transform:
    • clean input: 26.00 / 24.02 / 10.01 / 31.01. Main: 26.00 / 24.02 / 10.01 / 32.05 (transform varies by about 1 run to run).
    • one warning, default level: 38.00 / 36.02 / 22.01 / 43.83 and the call returns. Main: 41.02 / 40.02 / 28.02 / 49.05 and the call throws.
    • one warning, logLevel: "error": 26.00 / 24.02 / 10.01 / 30.73. Main: 26.00 / 40.02 / 28.02 / 31.03.
    • construct with a valid tsconfig: 973.06 on both. With a warning-only tsconfig: 979.06 (default), 970.07 ("error"). Main throws after 44.
  • BuildMessage cells after 1000 warning-only calls (bun:jsc heapStats): 0 per method and 0 for the constructor. Main: 1000 each.
  • Binary: .text 65,821,413 B, main 65,822,949 B (-1,536 B). errors_to_js has no symbol: it is inlined.
  • Instructions: perf has no counters in the test VM and valgrind is not installed, so there is no dynamic count. Static: transformSync has 40 instructions between the parse call and the print call (main 44), and the gate is cmp [log.errors], 0 and a branch.
  • Wall clock, 10 interleaved runs, ns per call, this PR vs main: transformSync 12865 vs 13212 (spread 772), scan 13002 vs 13047 (588), scanImports 9129 vs 8926 (1244), construct 53845 vs 54295 (23983). Every delta is inside the spread.
  • Syscalls (debugger syscall catchpoints, strace is not installed): 0 per call for clean transformSync, scan, scanImports and for a construction, on both builds. transform: 5.9 per call, main 5.5.
  • Option read order of the constructor (a Proxy that records reads): 19 of 19 positions unchanged. An invalid logLevel beside a warning tsconfig throws the logLevel TypeError on both.
  • Debug build with ASAN: 0 reports over 1800 constructions with a warning tsconfig and define errors.

Commits

One commit per cause, so that one can be backed out: options.log (#45045), the four methods, logLevel, FileSystemRouter, Worker, constructor.


no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/util/filesystem_router.test.ts

set_log re-pointed three of the four raw pointers to the Log and left
options.log unchanged. The Bun.Transpiler constructor builds its Log on
the stack, moves it into a Box and calls set_log. configure_defines then
logged through options.log into the moved-from stack slot.

When the Log already held a message, the stale copy shared the Vec
buffer with the live Log and freed it on growth:

    new Bun.Transpiler({ external: ["a*b*c"],
      define: { "0x": "1", "1x": "1", "2x": "1", "3x": "1" } })

is a use-after-free in Log::clear_and_free when the transpiler is
collected. A define value that is not JSON lost its parser message for
the same reason.
transformSync, transform, scan and scanImports threw the log whenever it
held a message, so a parser warning (a legacy HTML comment, a JSX key
after a spread, a write to a getter-only private name) became the thrown
value and the call returned nothing.

LogJsc::errors_to_js is the one conversion from a Log to a value to
throw. It returns None when the errors counter is zero. The four methods
call it. A failure that logs nothing keeps its generic error.

Carries the change of #42433 and adds the failure-path selectors.
The level of the `logLevel` option reached the per-call log of transform
and transformSync only. scan, scanImports, the scan pass of the bundler
cache and the JSON cache of the resolver made a log at the level of the
process, and the `tsconfig` and `macro` options were parsed before the
option was read. At logLevel "error" a warning was still recorded there
and was thrown beside an error.

Log::child() makes a log for messages that are appended to a parent
later. It takes the level of the parent. The eight scratch logs on the
Bun.Transpiler path use it. Config::from_js reads the options in the
same order as before and parses the `tsconfig` and `macro` strings after
`logLevel` is applied.

Carries the change of #42430.
The constructor threw its log when it held any message. The log also
receives what the resolver logs the first time it reads the routes
directory, so a warning in a tsconfig.json there failed the first
`new FileSystemRouter()` and the second one returned. The constructor
now asks LogJsc::errors_to_js.
flush_logs sent the VM log to the parent as the worker's 'error' event
whenever the log held a message. A warning that the resolver logs while
the worker thread resolves its entry (a tsconfig.json beside it, for
example) fired 'error' twice although the worker ran. A
node:worker_threads parent with no 'error' listener ended with exit
code 1.

flush_logs now asks LogJsc::errors_to_js, so it reports the log only
when its errors counter is above zero.
… throw

The constructor threw its log when the `tsconfig` or `macro` option
logged any message, so an option with only a warning (an unsupported
`moduleSuffixes`, an invalid `jsxFactory`, a duplicate JSON key) could
not construct a transpiler. `bun run` and `bun build` accept a
tsconfig.json with the same content.

The three constructor sites ask LogJsc::errors_to_js. A warning stays in
the log of the transpiler: when a later step fails, for example a
`define` value that is not JSON, the thrown AggregateError holds it
beside the error.
@robobun

robobun commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 9:06 AM PT - Oct 12th, 2026

✅ @robobun, your commit 377f826f32ff426585a083f5db34741b216074b5 passed in Build #124859! 🎉


🧪   To try this PR locally:

bunx bun-pr 45063

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

bun-45063 --bun

@robobun

robobun commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review. This branch contains #45045.

How it was reproduced:

const t = new Bun.Transpiler({ loader: "jsx" });
for (const fn of ["transformSync", "transform", "scan", "scanImports"]) {
  try {
    await t[fn]('export default <div {...obj} key="k" />;');
    console.log(fn, "ok");
  } catch (e) {
    console.log(fn, "throws", e.name, e.level, e.message);
  }
}
  • bun 1.4.3, bun 1.3.14 and a release build of main: the four calls throw a BuildMessage with level: "warn". A line that starts with --> does the same.
  • With this change: the four calls return.
  • Also on main: new Bun.Transpiler({ tsconfig: { compilerOptions: { moduleSuffixes: [".ios"] } } }) throws the warning, the first new FileSystemRouter() over a tsconfig.json with a warning throws, and a node:worker_threads parent with no error listener exits 1 when a warning-only tsconfig.json is beside the worker entry. With this change none of them fails.

@coderabbitai

coderabbitai Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Essentials
  • Run ID: 041fe542-b909-41b2-bb54-80cec7eb5df1

📥 Commits

Reviewing files that changed from the base of the PR and between aadaa7d and 377f826.


📒 Files selected for processing (4)
  • src/ast/lib.rs
  • src/jsc/lib.rs
  • src/jsc/web_worker.rs
  • src/runtime/api/JSTranspiler.rs

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 7 remain after this review.



Walkthrough

Transpiler operations now allow warning-only diagnostics without throwing. Log levels apply to configuration parsing, and error conversion is shared by transpiler, worker, and filesystem-router paths.

Changes

Diagnostic reporting

Layer / File(s) Summary
Log inheritance and diagnostic conversion
src/ast/lib.rs, src/jsc/lib.rs, src/bundler/cache.rs, src/resolver/tsconfig_json.rs
Added child logs that retain the parent log level and optional JavaScript conversion for logs with errors. Bundler and resolver parsing now use child logs.
Transpiler diagnostic handling
packages/bun-types/bun.d.ts, src/runtime/api/JSTranspiler.rs, test/bundler/transpiler/transpiler.test.js
Transpiler configuration parsing now follows log-level processing. Operations use error diagnostics for thrown values. Tests cover warning-only operations, warning/error aggregation, and log-level precedence.
Worker and route error reporting
src/jsc/web_worker.rs, src/runtime/api/filesystem_router.rs, test/js/node/worker_threads/worker_threads.test.ts, test/js/web/workers/worker-entry-point.test.ts, test/js/bun/util/filesystem_router.test.ts
Worker log flushing and filesystem-router construction now use error diagnostics to decide whether to dispatch or throw. Tests cover warning-only configurations and errors.

Suggested reviewers: dylan-conway

Priority: ⬆️ High

Merge Risk: ⚪ Minimal · up to 377f8

No actionable issue is established from the available evidence; the change is mergeable after normal checks.

Pre-merge checks | Passed 4
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check Passed The title clearly identifies the main change: warning-only diagnostics no longer fail Bun.Transpiler, FileSystemRouter, or Worker because the code gates failures through LogJsc::errors_to_js.
Description check Passed The description explains the problem, the fix, affected APIs, limitations, follow-ups, and verification results. It does not use the exact template headings, but it provides the required content in eq…

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline findings, I also checked the hoisted errors_to_js gates in scan, transformSync and scanImports (src/runtime/api/JSTranspiler.rs): moving the gate above the None-result / Err(scan_result) branches is behavior-preserving, since the old code threw the same to_js value from both arms whenever the log held messages. The async then arm keeps the prior priority of log messages over self.err, and the transpose() still routes an Err from building the error value into reject_with_async_stack as before.

Extended reasoning...

The change replaces thirteen warnings + errors > 0 gates across Bun.Transpiler, FileSystemRouter and the worker VM log flush with a new LogJsc::errors_to_js, adds Log::child() for level-inheriting scratch logs, re-points options.log in Transpiler::set_log, and defers tsconfig/macro parsing in Config::from_js until after logLevel is read. It touches no auth, crypto or injection surface. Four confirmed findings are posted inline, so a human look is already signalled; this note only records the gate reordering and the async rejection path that were traced and ruled out.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟣 src/jsc/web_worker.rs — The parent VM's transpiler log pointers can point at a moved-from stack slot during preload resolution, so a preload resolve error is written to dead stack memory and the message reported to the user is built from an empty log. src/jsc/web_worker.rs:312 calls set_log(&raw mut temp_log); line 316 then moves temp_log by value into scopeguard::guard, so transpiler.log, resolver.log, linker.log and now options.log (src/bundler/transpiler.rs:72) keep the old address. Fix: pin the log before publishing its address, e.g. build the guard first and call set_log(&raw mut *restore), so every alias points at the slot that lives.

    Why this was flagged

    Trigger: new Worker(url, { preload: ["./x"] }) or node:worker_threads with a preload that fails to resolve, on a build where the move at src/jsc/web_worker.rs:316 is not elided. At web_worker.rs:307 temp_log is a stack local; :312 publishes its address into the parent transpiler through set_log (which after this PR also writes options.log, transpiler.rs:72); :316 moves the value into the scopeguard and :321 rebinds temp_log to the guard's slot. resolve_entry_point_specifier (web_worker.rs:1332) calls transpiler.resolve_entry_point, which logs through self.log/resolver.log — the stale address — while web_worker.rs:1338 reads log.to_js from the guard's slot, which is empty, so to_js returns UNDEFINED (src/jsc/lib.rs:1395) and the error text becomes undefined. The diff adds a fourth alias to the same stale address. Remedy: create the guard first and pass &raw mut *restore to set_log, or heap-allocate the temp log.

    Verification: pre-existing. Trigger: new Worker(entry, { preload: ["./missing.js"] }) on any build where the move at web_worker.rs:316 is not elided. Nothing re-calls set_log with the new address. The code at 307-321 is in the base unchanged, so the base fails the same way by the same route. No test in the suite exercises a non-resolving preload.

Comment on lines +963 to 968
if let Some(value) = config
.log
.errors_to_js(global, format_args!("Failed to create transpiler"))?
{
return Err(global.throw_value(value));
}

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.

🟡 (optional) Users who pass a tsconfig or macro option that is only partly valid now get a Transpiler that silently ignores that part, with no signal anywhere. JSTranspiler.rs:963-968 continues when config.log holds only warnings, and nothing later prints or exposes them. So jsxFactory: "1abc", an invalid paths pattern or a non-package macro key are dropped together with their warning; the base threw it. Fix: give a surviving warning an outlet at the configured level, for the constructor and the four methods alike, e.g. print config.log warnings to stderr as bun build does or return them. The PR calls this an accepted downside; sized, it turns a loud misconfiguration into silent wrong output.

Why this was flagged

A caller constructs new Bun.Transpiler({ tsconfig: { compilerOptions: { jsxFactory: "1abc" } } }) or { macro: { "./rel": { x: "y" } } }. In Config::from_js the parse at JSTranspiler.rs:604-610 / 616-622 logs only a warning and the option is left unapplied (factory stays the default, the remap is skipped). The gate at JSTranspiler.rs:963-968 calls errors_to_js, which returns None because errors == 0 (src/jsc/lib.rs:1416), so construction continues. The warnings sit in config.log until Drop runs clear_and_free at JSTranspiler.rs:1074; no code path prints them and no API returns them. The new test at test/bundler/transpiler/transpiler.test.js:2884-2916 asserts stderr is "" for all twelve warning-only configs. On the base branch the same constructor threw Failed to create transpiler carrying the warning, so the user learned the option was ignored. After this change Bun.Transpiler is the only transpile API where a warning has no outlet at any logLevel.

Verification: Triggering condition: any new Bun.Transpiler({...}) whose tsconfig or macro option logs only a warning. The constructor gate at JSTranspiler.rs:963-968 calls errors_to_js, which returns Ok(None) when !self.has_errors(), so construction proceeds and nothing reads, prints or exposes config.log afterwards. On the base, these inputs threw a BuildMessage carrying the warning.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This is decision 2 in the Notes and it is open for a maintainer. An earlier version of this change printed the warnings of the tsconfig and macro options to stderr at construction (one write, none at logLevel: "error"). I removed it for three reasons. No other JS API of bun prints a log. It would be the only stderr promise in bun-types. A later change that returns warnings as data would have to keep the print or break it. If a maintainer wants an outlet now, the print is about ten lines in the constructor. I leave this thread open for that decision.

Comment thread src/resolver/tsconfig_json.rs
Comment thread src/bundler/transpiler.rs Outdated
set_log also runs when WebWorker::create restores a saved
transpiler.log. The module loader swaps transpiler.log, linker.log and
resolver.log to a per-call log with direct field writes and does not
touch options.log. With the write in set_log, a worker created while a
per-call log is swapped in would leave options.log of the VM transpiler
on that log after it is freed.

The constructor is the one caller that moves its Log after
Transpiler::init, so it sets options.log next to its set_log call.
set_log is as it was.

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline finding, I also checked the new transpiler.options.log = &raw mut config.log write in JSTranspiler.rs: it is taken after config is moved into the Box, so it points at the heap-stable field that shares the transpiler's owner, and moving it out of set_log and into the constructor keeps the module-loader log swaps in jsc_hooks.rs / AsyncModule.rs from touching options.log. The TransformTask::then rewrite settles the promise on every arm (errors, bare err, warnings-only resolve), so no path leaves it pending.

Extended reasoning...

The branch changes a user-visible default (Bun.Transpiler, FileSystemRouter and Worker no longer fail on warning-only logs) and the author lists two maintainer decisions, so a human should weigh in on the API change itself. The raw-pointer lifetime and the promise-settlement paths were examined and ruled out as defects.

Still open from earlier reviews (1):

  • Unresolved: 1 minor or pre-existing.

Comment thread src/runtime/api/filesystem_router.rs
@robobun

robobun commented Oct 14, 2026

Copy link
Copy Markdown
Collaborator Author

#45203 changes when a worker reports its VM log. spin() calls flush_logs only on the paths that end the worker, and a worker that runs drops its log (warnings and errors).

This branch has not been deployed

No deployments
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