Skip to content

fix(promise): capture stacks on an Error for Bun - #4524

Open
official-burak wants to merge 1 commit into
sidorares:masterfrom
official-burak:fix/capture-stack-error-holder
Open

fix(promise): capture stacks on an Error for Bun#4524
official-burak wants to merge 1 commit into
sidorares:masterfrom
official-burak:fix/capture-stack-error-holder

Conversation

@official-burak

@official-burak official-burak commented Sep 2, 2026

Copy link
Copy Markdown

Thanks for the report and the pointer at capture_local_err.js. You were right: the promise wrappers were capturing the async stack on a plain object, and Bun's prepareStackTrace rejects that with "First argument must be an Error object".

This captures on a real Error instead, then still copies the frames onto the original MySQL error so codes and messages stay intact. I added a unit test that installs the same prepareStackTrace check Bun uses.

Fixes #4480

Bun's Error.prepareStackTrace rejects a plain object holder, which
throws while rewriting promise wrapper stacks. Capture on a real Error
so Node and Bun share the same path.

Fixes sidorares#4480
@codecov

codecov Bot commented Sep 2, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 92.53%. Comparing base (83bda80) to head (7f6d3df).

Additional details and impacted files
@@           Coverage Diff           @@
##           master    #4524   +/-   ##
=======================================
  Coverage   92.53%   92.53%           
=======================================
  Files          93       93           
  Lines       15995    15997    +2     
  Branches     2308     2308           
=======================================
+ Hits        14801    14803    +2     
  Misses       1194     1194           
Flag Coverage Δ
compression-0 92.15% <100.00%> (+<0.01%) ⬆️
compression-1 92.51% <100.00%> (+<0.01%) ⬆️
static-parser-0 91.36% <100.00%> (+<0.01%) ⬆️
static-parser-1 91.47% <100.00%> (+<0.01%) ⬆️
tls-0 92.10% <100.00%> (+<0.01%) ⬆️
tls-1 92.53% <100.00%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@sidorares

Copy link
Copy Markdown
Owner

I can't reproduce original issue:

% bun repl
Welcome to Bun v1.4.0
Type .copy [code] to copy to clipboard. .help for more info.

❯ const holder = {}
{}
❯ Error.captureStackTrace(holder)
undefined
❯ holder.stack
"Error\n    at <anonymous> ([repl]:4:35)\n    at [repl]:6:3"
❯

@official-burak

Copy link
Copy Markdown
Author

You're right, a bare Error.captureStackTrace({}) is fine on Bun 1.4.0. The throw only shows up after a delegating Error.prepareStackTrace is installed (Babel and source-map-support do this). mysql2's promise wrappers still capture on {} in lib/promise/capture_local_err.js, so that path hits it.

// Bun 1.4.0. mysql2 3.24.3 / master. No MySQL needed.

const old = Error.prepareStackTrace
Error.prepareStackTrace = (err, trace) => old(err, trace)

const holder = {}
Error.captureStackTrace(holder)
console.log(holder.stack)

On Bun that throws TypeError: First argument must be an Error object. Swap {} for new Error() and it prints a stack, which is what this PR does.

@peppe1337

Copy link
Copy Markdown

@sidorares — here is the reproduction, driven through mysql2/promise itself rather than
through a REPL snippet. Caveat first, not last: Bun 1.4.0 (same version as your REPL) and
Node v22.22.3, Linux. master = af6aa0e, PR head = 7f6d3df. My Bun install is not
equivalent to CI's bun-version: latest — see the last section, where that bites.

Why the REPL could not show it

Error.captureStackTrace({}) on Bun 1.4.0 is fine as long as nothing has assigned
Error.prepareStackTrace
. I measured your exact REPL sequence: no throw, and my own
formatter is called zero times. The throw needs a user-assigned formatter — the kind
installed by tooling such as Babel or source-map-support (I measured the pattern, not
those tools). That is the missing ingredient, and it is why the snippet in the issue
reproduces and yours does not.

The trigger is narrower than "Bun requires an Error"

The inline comment in the diff says a plain object "works in V8, but throws there". Measured,
that is not quite the split:

typeof Error.prepareStackTrace (untouched) native default called with ({}, frames) with (null, frames)
Node v22.22.3 functionErrorPrepareStackTrace, length 2 returns "Error" throws TypeError: Cannot use 'in' operator to search for 'Symbol(kIsNodeError)' in null
Bun 1.4.0 functionErrorPrepareStackTrace, length 2 throws TypeError: First argument must be an Error object throws the same

Both runtimes ship a non-undefined default. Neither is V8's undefined. The difference is
only what that default tolerates. So the failing pattern is specifically the delegating
idiom, const old = Error.prepareStackTrace; Error.prepareStackTrace = (e, t) => old(e, t),
which hands the plain object to the native default.

Counter-check, because that claim is falsifiable: a non-delegating formatter
(Error.prepareStackTrace = () => 'CONST') on Bun with a plain-object holder does not
throw. Same runtime, same plain object, no throw — the delegation is the trigger, not the
plain object on its own.

The throw is eager, and that changes the blast radius

I expected the throw at the .stack read, since prepareStackTrace is a lazy formatter. It
is not. On Bun the throw comes out of Error.captureStackTrace() itself:

at ErrorPrepareStackTrace (native)
at <anonymous> (…)
at captureStackTrace (native)

captureStackHolder is called at the top of query() / execute() / connect()
(lib/promise/connection.js:34,54,153,…), before any I/O. So this is not "error stacks
come out wrong on Bun" — on master with a delegating formatter, mysql2/promise on Bun
cannot open a connection at all, and a perfectly healthy query never gets sent.

The matrix

2×2×2×2: runtime × holder × formatter × outcome. connerror = port 1, nothing listening.
success = a real handshake + SELECT 1 against a local fake server built from
mysql.createServer (no MySQL needed). Every run is its own process, and each result carries
the md5 of the lib/promise/capture_local_err.js it actually loaded
(master 13058e10…, PR fef94e44…, both matching git show).

runtime holder prepareStackTrace success path connerror path
Bun 1.4.0 {} master none ok [{"x":1}] ECONNREFUSED
Bun 1.4.0 {} master delegating TypeError: First argument must be an Error object — thrown out of createConnection, connection never established TypeError: First argument must be an Error object, code = null
Bun 1.4.0 new Error() PR none ok ECONNREFUSED
Bun 1.4.0 new Error() PR delegating ok [{"x":1}] ECONNREFUSED, code intact
Node 22 {} master none / delegating ok / ok ECONNREFUSED / ECONNREFUSED
Node 22 new Error() PR none / delegating ok / ok ECONNREFUSED / ECONNREFUSED

2 of 16 cells break, both of them Bun × master × delegating. The PR fixes both, and no
Node cell changes.
On the error path master does not merely mangle the stack — it
replaces the MySQL error, so err.code arrives as null instead of ECONNREFUSED.

Scope

lib/promise/capture_local_err.js:6 is the only Error.captureStackTrace call site in
lib/, and captureStackHolder has 16 call sites across connection.js, pool.js,
pool_cluster.js and prepared_statement_info.js. The one-line change covers all of them.
trace defaults to on (connection_config.js:125, this.trace = options.trace !== false),
so this is the default configuration, not an opt-in.

No behaviour change on Node that I can see: the rewritten stack is frame-for-frame identical
before and after the PR (8 frames in my harness either way), and err.code survives.

One thing the PR does not change, offered only as an observation

Even with the fix, the captured stack is much shallower on Bun than on Node. Three nested
user frames, Error.stackTraceLimit === 10 on both:

raw new Error() captureStackTrace(h) captureStackTrace(h, fn)
Node 22 7 frames 7 6
Bun 1.4.0 2 frames 2 1

Bun's frames also carry no function names (at <anonymous> (file:line)), so the async-stack
feature this file exists for yields considerably less on Bun regardless of the holder type.
I have not established the cause, and it is orthogonal to this PR — flagging it only so
it does not get read into the diff.

The new test — one caveat and one suggestion

I could not get test/unit/test-capture-local-err.test.mts to pass under Bun here. It fails
on strict.ok(stack.includes('test-capture-local-err.test.mts')), because the frame Bun
reports under poku does not carry the original filename.

I am explicitly not claiming this breaks CI, and the evidence is against that reading.
The 8 Bun latest checks on 7f6d3df are green. And on this machine, ten unrelated
test/unit files already fail on plain master — seven under parsers/
(big-numbers-strings-{text,binary}-sanitization,
support-big-numbers-{text,binary}-sanitization, timezone-{text,binary}-sanitization,
test-text-parser) and three under pool-cluster/ (test-connection-order,
test-connection-rr, test-query). The same ten
fail under Node v22.22.3 as well, so that is my environment, not the runtime. Rebuilt without
poku — same holder, same captureHere, plain .cjs — the assertion holds on Bun too, so the
product code is fine and this is a runner/transpile interaction.

The suggestion is small and independent of my environment: an assertion on the test file's
own name appearing in a stack frame is fragile under any runner that transpiles .mts. The
first test's strict.ok(holder instanceof Error) is what actually pins the fix.

Worth noting about that first test, though: on Node it never exercises the reported
failure. It goes red on master at the instanceof line, before its own
prepareStackTrace is ever called — a shape assertion. On Bun it does fire the real
throw. So the regression coverage is real, but only on the Bun job.

What I did not measure

No real MySQL server — the success path is mysql.createServer from this repo, so the
handshake and result set are the library's own, not a live server's. No Deno. No Bun other
than 1.4.0. No source-map-support or Babel themselves, only the delegating formatter they
install. Harness and raw JSON per run are reproducible from the description above.

@sidorares

Copy link
Copy Markdown
Owner

Thanks for the repro @peppe1337 , I'll take a look
Note that original reason for captureStackHolder helper vs more obvious "new Error()" is performance - "new Error()" is relatively slow and for async stacks we must call it upfront, not just in the error scenario

@peppe1337

Copy link
Copy Markdown

@sidorares — I measured the performance point, since it decides this PR and nobody had a number for it.

Setup, and the limits first. Node v22.22.3, Bun 1.4.0, 2-core Linux box, Error.stackTraceLimit at its default 10. Baseline master 2387daf8d, PR head 7f6d3df. My stacks are shallow — capture cost grows with depth, so every absolute µs below is a lower bound. No real MySQL server: the end-to-end numbers use mysql.createServer from this repo as the peer. No Deno, no other Bun version, no long-run GC behaviour.


1. new Error() is not the expensive part

Median ns per call, 21 rounds × 20 000 iterations, variants interleaved (this box carries other load; a block measurement would report drift):

Node 22 ns/call
new Error() alone, no capture 4 350
Error.captureStackTrace({}) — what the helper does today 4 880
control: holder with no capture at all 14

new Error() on its own is not more expensive than the capture the helper already performs — median 11 % below it, distributions overlapping. The 14 ns control is there so the ~5 µs figures are believable at all: the harness measures the callee, not itself.

2. What the PR actually costs is a double capture

Node 22, ns/call none delegating prepareStackTrace
{} (master) 4 880 4 872
new Error() (this PR) 9 973 (2.04×) 9 746 (2.00×)

4 350 + 4 880 = 9 230 ≈ 9 973 measured. The costs are additive: new Error() collects frames, then Error.captureStackTrace collects them again. So your instinct that this PR costs something is right, but the cause isn't that new Error() is slow — it's that the frames get gathered twice.

3. That part is removable, if you want it

function captureStackHolder(constructorOpt) {
  const limit = Error.stackTraceLimit;
  Error.stackTraceLimit = 0;          // construction must not collect frames itself
  const holder = new Error();
  Error.stackTraceLimit = limit;
  Error.captureStackTrace(holder, constructorOpt);
  return holder;
}

5 808 ns = 1.19× instead of 2.04× — that removes 82 % of the added cost.

I checked it is a real drop-in and not just a faster one: with line/column numbers normalised, the resulting frame sequence is identical to master's on node/none and node/delegating, and constructorOpt still trims the frame it is supposed to trim. On bun/delegating it behaves like the PR (master throws there — that's #4480). On Bun I would not read much into the frame comparison: my test context there was too shallow to produce visible frames, so the equality is trivially true. Where Bun frames are visible — driving the repo's own captureStackHolder + applyCapturedStack against a real error — master, the PR and this variant all yield 2 frames and all of them add a frame rather than dropping any.

Honest caveats, because it is a global mutation: nothing between the two assignments can throw, so the limit cannot leak — but it is a process-global write, and it buys nothing on Bun (654 vs 655 ns without a custom prepareStackTrace, 675 vs 688 with one). It's Node-only value for two extra property writes and a line of subtlety. Your call whether that trade is worth making.

4. On Bun this PR is a speed-up, not a cost

This is the part I did not see coming:

Bun 1.4.0, ns/call none delegating
{} (master) 2 050 throws
new Error() (this PR) 655 (0.32×) 688

On Bun, Error.captureStackTrace on a plain object is 3.1× more expensive than on a real Error. End-to-end against the fake server, 16 paired rounds × 3 × 1 000 queries, comparing each variant only against the plain run of the same round: −6.5 µs/query on a 95.4 µs baseline (sign test +2/−14, p = 0.004).

5. What I cannot tell you: the end-to-end number on Node

Same harness, same 16 paired rounds: PR +10.5 µs/query on a 165.3 µs baseline, +12/−4, p = 0.077.

Do not use that number. I also ran a red test — a variant with Error.captureStackTrace removed entirely — and on Node it came out at −1.8 µs, +7/−9, p = 0.804. A harness that cannot detect the complete removal of the thing under test cannot be trusted to measure its doubling. The +10.5 µs happens to agree in direction and magnitude with the 5.1 µs from the microbenchmark, but that is agreement, not evidence. On Bun the same red test does resolve (−9.8 µs, p = 0.021), which is why I quote the Bun end-to-end figure and not the Node one.

(An earlier version of this harness reported ~41 ms/query on Node. That was delayed-ACK — the fake server had no setNoDelay. On a 10-query probe, adding it on both sides took the median from 41 ms to 0.8 ms; the 165 µs baseline above is that same path after warm-up and with the fake server's packet-sequence resync damped.)

6. Two smaller things you may want

Object.create(Error.prototype) does not work. It throws the same TypeError: First argument must be an Error object on Bun — the check is on the internal state, not the prototype. A genuine Error instance really is required, so the PR's approach is the only one available; only its execution was costly.

Frequency, measured with a counter in the source file (a patch on module.exports cannot work here — the callers destructure at require time, and mine silently reported 0 until I noticed): 1 call per query(), 1 per connection setup, 0 with trace: false. 16 is the number of call sites, not calls per query.


Whether 5 µs per query matters is your judgement, not mine — I don't know your users' stack depths, and on deep async stacks all of these numbers grow. What I think the measurements do settle: the objection is real on Node but is caused by the double capture rather than by new Error(), it is 82 % removable with the snippet above, and on Bun the change is a net win either way.

Raw data, harnesses and the pre-registered predictions (6 of 8 held, one partly, one failed — the end-to-end prediction) are reproducible from the description above; happy to post the scripts if useful.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bun Error.prepareStackTrace throw's error "Caused by: First argument must be an Error object failed"

4 participants