Error.captureStackTrace: a non-Error target's stack starts with its name and message, read when the stack is
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;
stackis the lazy accessor thatErrortargets already get (errorInstanceLazyStackCustomGetter). For a target that is not anErrorit builds the first line from the target'snameandmessagewhen 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.2 | this PR | |
|---|---|---|
plain target: captureStackTrace only | 1504 ns | 1511 ns |
plain target: capture + first .stack read | 1788 ns | 2149 ns |
| function-style error class: construct + first read | 1663 ns | 2129 ns |
plain target: repeat .stack read | 3.7 ns | 3.8 ns |
Error target: capture + first read | 2136 ns | 2284 ns |
new Error().stack | 2230 ns | 2323 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 emptyname,message: null, numbers, a function, anameon the prototype, getters,nameandmessageassigned after the capture, a function-style class that assigns them after capturing, one whosemessagegetter reads state set after capturing, anamegetter that runs a collection, and a realTypeError; - a
namegetter that throws and a Symbolnamethrow from the read of.stack, not fromcaptureStackTrace; 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;
undefinedfor an unrelated receiver), and a frozen or sealed target, plain orError, stays so after the read; console.trace()on aConsoleinstance starts withTrace: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".