Skip to content

[JSC] An error with no frame to format owns a lazy stack - #827

Closed
robobun wants to merge 1 commit into
mainfrom
robobun/8a48edf8/frameless-error-lazy-stack
Closed

robobun wants to merge 1 commit into
mainfrom
robobun/8a48edf8/frameless-error-lazy-stack

Conversation

@robobun

@robobun robobun commented Oct 16, 2026

Copy link
Copy Markdown
Collaborator

Draft until Bun's tests pass against this PR's preview build.

Problem

  • With a host that formats stacks (Bun), an ErrorInstance with no frame to format never owns stack. error.stack is undefined, and error.stack = value adds an enumerable key. In V8 every error owns a non-enumerable stack.
  • materializeErrorInfoIfNeeded() (ErrorInstance.cpp:530) defines stack only for frames or a stack string. An error has neither under Error.stackTraceLimit = 0, under a limit that is not a number, or when native code makes it with no JavaScript on the stack.

Fix

  • The first lookup of such an error installs a lazy stack: a non-enumerable custom value (installLazyStack()). A store replaces it and a delete removes it. Neither formats.
  • A read formats the stack: the header line, or undefined if the error captured no stack. Both as in V8.
  • The getter is the existing errorInstanceMaterializingStackGetter: a compiled lookup takes the getter from the Structure, and both properties have the same one.
  • Behaviour change: none without vm.onComputeErrorInfoJSValue(). The jsc shell sets none.

Background

  • A custom value holds a CustomGetterSetter and reads through its getter. To JavaScript it is a data property.
  • Weighed: an accessor-kind property loses the stack on Object.freeze(). Formatting on every lookup runs Error.prepareStackTrace on a store.

Downsides

  • Bun only: such an error reads a string where it read undefined, and Error.prepareStackTrace is called for it.
  • The first read allocates the stack string: 29 B per error for Error: m123. The first lookup allocates nothing.
  • A descriptor read, defineProperty(), freeze() and seal() format too: 11 of 26 lookup kinds, where V8 has 5.
Notes
  • Verified so far: a debug and ASAN build of Bun with this ErrorInstance.cpp linked in front of the pinned libJavaScriptCore.a. test/js/node/v8/capture-stack-trace.test.js passes there (22 new tests). The run against this PR's preview build is pending.
  • m_lazyStackInstalled is set by the first lookup, also when the error already owns a stack that native code put there with putDirect. A stack that is deleted does not come back, as in V8: delete error.stack; error.stack = 1 gives an ordinary enumerable property.
  • The read sets m_errorInfoMaterialized and drops the empty frame list. A host that attaches frames later (setStackFrames(), Bun's async stack attach) checks hasMaterializedErrorInfo() first, so a stack that was read does not change. Frames that were attached before the first read are formatted by the read: the getter calls materializeErrorInfoIfNeeded() first.
  • Error.prepareStackTrace gets an empty call-site array for such an error, on the read only.
  • If the formatter throws, the error keeps what the formatter returned, as an error with frames does.
  • The shared getter matters in a realm that has JavaScriptCore's own Error.captureStackTrace (a node:vm context in Bun). An error with a lazy stack and an error with a saved captured stack have the same Structure there. With a getter each, a compiled read of one called the getter of the other.
  • Every error with a lazy stack holds one CustomGetterSetter, which the VM keeps: 10,000 such errors add 1 cell and 1 Structure.
  • sizeof(ErrorInstance) stays 72. The new bit is in the existing bit-field.
  • Error.stackTraceLimit = NaN is unchanged: JSGlobalObject::stackTraceLimit() gives no limit, the error captures no stack, and its stack reads undefined (V8: the header).
  • Lookup kinds that call Error.prepareStackTrace, of 26 probed: V8 5, this PR 11, before 0, an error with frames 20.
  • No JSTests case: the jsc shell installs no stack formatter, so the new path does not run there. JSTests/stress/error-stack-trace-limit.js and error-stack-trace-limit-nan.js cover that nothing changes without one.
  • Promise.any: give the AggregateError a stack, message, and no spurious cause #340 has the empty-frame-list half of this inside a Promise.any change, with the stack formatted on any lookup. This PR replaces that half.

With a host that formats stacks (vm.onComputeErrorInfoJSValue(); Bun), an
ErrorInstance gets its stack property when materializeErrorInfoIfNeeded()
finds frames or a stack string to format. An error that has neither never
gets one. That is every error made while Error.stackTraceLimit is 0,
negative or not a number, and every error that native code makes while no
JavaScript is on the stack.

    Error.stackTraceLimit = 0;
    const error = new Error("m");
    error.stack;                 // undefined, "Error: m" in V8
    error.stack = "value";
    Object.keys(error);          // ["stack"], [] in V8

In V8 every error owns a non-enumerable stack. Its value is the header
line when a stack was captured with no frame in it, and undefined when no
stack was captured (the limit was not a number).

The first lookup of such an error now installs a lazy stack: a
non-enumerable custom value, ErrorInstance::installLazyStack(). Nothing is
formatted for it. JSObject::put replaces a custom value with the stored
value, and a delete removes it, without a call of the getter. So a store,
a delete, hasOwnProperty and the own keys run no code of the host or of
the user. A read formats the stack: the host's formatter gets an empty
frame list, so Error.prepareStackTrace gets no call site, and from then on
the error info is materialized. An error that captured no stack reads
undefined, and the host is not asked. m_lazyStackInstalled keeps a stack
that was deleted from coming back.

The getter is errorInstanceMaterializingStackGetter, the one of the stack
that Error.captureStackTrace() saves. Both properties are custom values
with the same Structure, and a compiled lookup takes the getter from the
Structure, so the two cannot have a getter each. The getter first
materializes what there is to materialize, as before. That also formats
frames that the host attached after the lazy stack was installed
(setStackFrames()).

Every error with a lazy stack holds the same CustomGetterSetter, which the
VM keeps.

Nothing changes without the host's formatter. The jsc shell does not set
one, so JSTests/stress/error-stack-trace-limit.js still reads undefined.

* Source/JavaScriptCore/runtime/Error.cpp:
(JSC::addErrorInfo):
* Source/JavaScriptCore/runtime/ErrorInstance.cpp:
(JSC::JSC_DEFINE_CUSTOM_GETTER):
(JSC::lazyStackGetterSetter):
(JSC::ErrorInstance::ErrorInstance):
(JSC::ErrorInstance::materializeErrorInfoIfNeeded):
(JSC::ErrorInstance::installLazyStack):
(JSC::ErrorInstance::materializeLazyStack):
* Source/JavaScriptCore/runtime/ErrorInstance.h:
* Source/JavaScriptCore/runtime/VM.cpp:
(JSC::VM::visitAggregateImpl):
* Source/JavaScriptCore/runtime/VM.h:
@github-actions

Copy link
Copy Markdown

Preview build of 7aa6826: autobuild-preview-pr-827-7aa68263

@robobun

robobun commented Oct 17, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: #829 covers this. It changes the same condition in materializeErrorInfoIfNeeded() for the same root cause, and a maintainer wrote it. I will post on #829 the measurements from this PR that still apply there.

@robobun robobun closed this Oct 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant