Error.captureStackTrace: a non-Error target's stack starts with its name and message, read when the stack is

DY
2mo ago
+315-37

What does this PR do?

Error.captureStackTrace(target) wrote a stack whose first line was always Error when target is not an Error:

function HttpError(message) {
  Error.call(this);
  Error.captureStackTrace(this, this.constructor);
  this.message = message;
  this.name = "HttpError";
}
HttpError.prototype = Object.create(Error.prototype);
new HttpError("Request failed").stack.split("\n")[0]; // "Error"   Node: "HttpError: Request failed"

new console.Console(process.stdout).trace("hello %s", "world"); // "Error\n    at ..."   Node: "Trace: hello world\n    at ..."

V8 makes the first line Error.prototype.toString() of the target, whatever kind of object it is (name defaults to Error, message to nothing, both read with Get, so a getter or a name on the prototype counts), and it reads them when .stack is first read, not when the stack is captured. That timing is what makes the function-style class above work: it captures before it assigns name and message.

For such a target Bun formatted the whole stack inside captureStackTrace, with a header only an ErrorInstance could supply. Now, when no Error.prepareStackTrace is installed:

  • the frames are formatted at capture, as before, and kept on the target under a private name;
  • stack is the lazy accessor that Error targets already get (errorInstanceLazyStackCustomGetter). For a target that is not an Error it builds the first line from the target's name and message when the stack is first read, joins it to the frames, and replaces itself with that string, so the stack is computed once and is assignable before that.

A custom accessor is given the receiver, so the getter looks along the receiver's prototype chain for the object that has the frames and builds the first line from that one: Object.create(target).stack and new Widget().stack after Error.captureStackTrace(Widget.prototype) read as in Node. When stack stops being the accessor it is as sealed or frozen as its object (for Error targets too), so reading the stack of a frozen target does not unfreeze it.

Nothing of the target's runs inside captureStackTrace, so a getter that reads state the constructor sets later, or one that throws, behaves as in Node: it runs, or throws, at the read of .stack.

After its first read stack is a data property, as it already was for Error targets, so that repeat reads stay an ordinary cached property load; V8 keeps an accessor. What follows from that and differs from Node: once a frozen target's stack has been read it cannot be assigned, and assigning stack through an object that inherits from the target makes an own property there rather than writing to the target. Attributes given to the property before the first read (Object.defineProperty, Object.seal, Object.freeze) are kept.

With an Error.prepareStackTrace installed nothing changes: the callback still runs inside captureStackTrace, where the call sites are (V8 calls it at first read; not addressed here). Which prepareStackTrace applies (Bun's global, or a node:vm context's) was decided inline in computeErrorInfoToJSValueWithoutSkipping; it is now one function, installedPrepareStackTrace, used there and by captureStackTrace. Error targets are untouched.

Cost

Release build against 1.4.2, same machine, medians of alternating runs (noise on this machine is about 5%):

1.4.2this PR
plain target: captureStackTrace only1504 ns1511 ns
plain target: capture + first .stack read1788 ns2149 ns
function-style error class: construct + first read1663 ns2129 ns
plain target: repeat .stack read3.7 ns3.8 ns
Error target: capture + first read2136 ns2284 ns
new Error().stack2230 ns2323 ns

Only the first read of a captured non-Error target costs more, by about 0.4 µs: it now reads the target's name and message (1.4.2 wrote the constant Error), joins them to the frames, and swaps the accessor for a data property so that repeat reads stay a cached load. Node 26 on the same loops: 6.3 µs for capture + first read, 36 ns per repeat read.

How did you verify your code works?

test/js/node/v8/capture-stack-trace.test.js:

  • fifteen targets whose expected first lines are what Node 26 prints for the same code: plain objects with and without name / message, an empty name, message: null, numbers, a function, a name on the prototype, getters, name and message assigned after the capture, a function-style class that assigns them after capturing, one whose message getter reads state set after capturing, a name getter that runs a collection, and a real TypeError;
  • a name getter that throws and a Symbol name throw from the read of .stack, not from captureStackTrace; the stack is computed once; it can be assigned before it is read;
  • a stack read through an object that inherits from the target (first line from the target; undefined for an unrelated receiver), and a frozen or sealed target, plain or Error, stays so after the read;
  • console.trace() on a Console instance starts with Trace: and its message.

Both fail on 1.4.2 (12 of the 14 Node-checked rows read Error) and pass with this change; the whole file passes on a debug+ASAN build with BUN_JSC_validateExceptionChecks=1. Run side by side with Node 26 over a separate table of fifteen targets, every first line and every throw now agrees, apart from the two engines' wording of "cannot convert a Symbol".