Repository navigation
Do not fail on a warning: gate Bun.Transpiler, FileSystemRouter and Worker on LogJsc::errors_to_js - #45063
Do not fail on a warning: gate Bun.Transpiler, FileSystemRouter and Worker on LogJsc::errors_to_js#45063robobun wants to merge 11 commits into
Conversation
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.
|
Updated 9:06 AM PT - Oct 12th, 2026
✅ @robobun, your commit 377f826f32ff426585a083f5db34741b216074b5 passed in 🧪 To try this PR locally: bunx bun-pr 45063That installs a local version of the PR into your bun-45063 --bun |
|
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);
}
}
|
There was a problem hiding this comment.
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 callsset_log(&raw mut temp_log); line 316 then movestemp_logby value intoscopeguard::guard, sotranspiler.log,resolver.log,linker.logand nowoptions.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 callset_log(&raw mut *restore), so every alias points at the slot that lives.Why this was flagged
Trigger:
new Worker(url, { preload: ["./x"] })ornode:worker_threadswith 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:307temp_logis a stack local; :312 publishes its address into the parent transpiler throughset_log(which after this PR also writesoptions.log, transpiler.rs:72); :316 moves the value into the scopeguard and :321 rebindstemp_logto the guard's slot.resolve_entry_point_specifier(web_worker.rs:1332) callstranspiler.resolve_entry_point, which logs throughself.log/resolver.log— the stale address — while web_worker.rs:1338 readslog.to_jsfrom the guard's slot, which is empty, soto_jsreturns UNDEFINED (src/jsc/lib.rs:1395) and the error text becomesundefined. The diff adds a fourth alias to the same stale address. Remedy: create the guard first and pass&raw mut *restoretoset_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-callsset_logwith 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.
| if let Some(value) = config | ||
| .log | ||
| .errors_to_js(global, format_args!("Failed to create transpiler"))? | ||
| { | ||
| return Err(global.throw_value(value)); | ||
| } |
There was a problem hiding this comment.
🟡 (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.
There was a problem hiding this comment.
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.
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.
…log-reseats-options-log
…bun/e6efdb77/transpiler-warning-is-not-a-failure
There was a problem hiding this comment.
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.
|
#45203 changes when a worker reports its VM log.
|
Supersedes #42433 and #42430. Contains #45045: merge that one first.
Problem
Bun.Transpilerfails 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 aBuildMessagewithlevel: "warn":Treating "-->" as the start of a legacy HTML single-line comment.src/runtime/api/JSTranspiler.rs,filesystem_router.rs:285, andflush_logsinweb_worker.rs. The last two fail on a tsconfig.json warning.logLevel: "error"reachedtransformandtransformSynconly.Fix
LogJsc::errors_to_jsreturns the value to throw, orNonewhen theerrorscounter is zero. The 13 sites call it.Log::child()gives each scratch log on theBun.Transpilerpath the level oflogLevel.transpiler.test.js(53 of 81 new tests fail on main),filesystem_router.test.ts,worker-entry-point.test.ts,worker_threads.test.ts.Background
Log(src/ast/lib.rs) countserrorsandwarnings. Itsleveldrops a message below it.Log::to_jsmakes oneBuildMessage, or anAggregateErrorfor several. A warning beside an error stays in it.logLevel, FileSystemRouter and Worker. A defaultlogLevelof"error":"warn"would still throw.Downsides
.textis 1,536 B smaller.Notes
Two decisions for a maintainer
logLevel(this PR, and what esbuild's transform API does), or a defaultlogLevelof"error"(suggested in the review of fix: jsx key after spread #7464)? With the second onelogLevel: "warn"still throws on a warning, and an error beside a warning becomes a singleBuildMessage.FileSystemRouter.reload()already does. esbuild returnsresult.warnings. A way to return warnings as data is new API and is not in this PR.History
scanImports. fix: jsx key after spread #7464 closed it with thelogLeveloption, which reachedtransformandtransformSynconly. In that review (fix: jsx key after spread #7464 (comment)) the reviewer wrote that a warning should not throw and gave theerrors > 0diff. The option landed with the default"warn"to keep the tests of that time.bun buildandBun.build.errors_to_jsapplies it to these APIs.Bun.Transpiler(2022). No comment or commit gives a reason for it.What changes, by API
transformSync,transform,scan,scanImports: 9 parser warnings x 4 methods. Main throws in 30 of 36 cells (12 of 36 atlogLevel: "error"). This PR: 0 of 36 at every level. A JSON duplicate key through thejsonloader: main throws 3 of 3, this PR 0 of 3.tsconfig/macrooptions 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.errortwice on a Web Worker while the worker ran. Anode:worker_threadsparent with noerrorlistener ended with exit code 1. Now no event fires.AggregateErrorholds 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,scanImportsand 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
logLevel = "error"in bunfig.toml, a worker now keeps a tsconfig.json warning that main dropped. The log of a worker VM is atWarnwhatever bunfig.toml says, andLog::child()follows its parent. The warning fires no event alone. Beside an error the event carries anAggregateError. 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.{"compilerOptions": {) still fails the firstnew FileSystemRouter()and still fireserroron a worker that runs. A separate route log and a scoped log for the entry resolution would remove that half too.flush_logstestedmsgs.is_empty(). It now tests theerrorscounter, whichLog::add_msgdoes not change. A message that is put in a worker's log that way no longer fires a lateerrorevent."key" prop ignored(<div key />) is a warning here and an error in esbuild. That input now transpiles with no message.definekey and anexternalwith two*log an error, and the constructor still succeeds. Not changed.Follow-ups (not in this PR)
disallowed-methodsentry forLogJsc::to_js, so that a new gate cannot bypasserrors_to_js. FileSystemRouter: surface route-load errors from reload() like the constructor does #34614 (addslog.errors + log.warnings > 0toFileSystemRouter.reload()) and Bun.Transpiler: add unstable_parse() to expose the internal AST #36745 (unstable_parse, twowarnings + errorsgates) would add the old check again.transform()beside a warning with an empty error log. No input reaches that state in a deterministic way. Thecssloader test covers the same arm.Other open PRs that touch this
options.logafter it moves itsLog. Its commits are in this branch. Without it, a warning-onlytsconfigfollowed bydefineerrors frees the log buffer twice.Transpiler::set_log. Fix a segfault in new Bun.Transpiler(): re-point options.log after the constructor moves its Log #45045 explains why the write is in the constructor.WebWorker::createand its temporary log (apreloadthat does not resolve). Separate change.autoImportJSX. The JSX rows of the new tests passautoImportJSX: false.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_logto theBun.Transpilerconstructor (#45045). The counts below do not depend on it. The byte counts can differ by a few bytes.logLevel: "error": 26.00 / 24.02 / 10.01 / 30.73. Main: 26.00 / 40.02 / 28.02 / 31.03.tsconfig: 973.06 on both. With a warning-onlytsconfig: 979.06 (default), 970.07 ("error"). Main throws after 44.BuildMessagecells after 1000 warning-only calls (bun:jscheapStats): 0 per method and 0 for the constructor. Main: 1000 each..text65,821,413 B, main 65,822,949 B (-1,536 B).errors_to_jshas no symbol: it is inlined.perfhas no counters in the test VM and valgrind is not installed, so there is no dynamic count. Static:transformSynchas 40 instructions between the parse call and the print call (main 44), and the gate iscmp [log.errors], 0and a branch.transformSync,scan,scanImportsand for a construction, on both builds.transform: 5.9 per call, main 5.5.logLevelbeside a warningtsconfigthrows thelogLevelTypeError on both.tsconfiganddefineerrors.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