SystemError: give async fs/dns/etc. errors a .stack string when there are no JS frames - #35515
SystemError: give async fs/dns/etc. errors a .stack string when there are no JS frames#35515robobun wants to merge 3 commits into
Conversation
Async node:fs errors (callback form and fs.promises consumed via .then())
are constructed from native code at the top of the event loop, where there
are no JS frames on the stack. createError() then captures an empty stack
trace, and ErrorInstance::materializeErrorInfoIfNeeded never installs a
.stack own property for an empty trace. The result is err.stack ===
undefined, which breaks loggers that print ${err.stack} and any
stack-based error reporter. Node.js returns at least "Error: <message>".
Install Bun's existing lazy stack getter on the ErrorInstance when the
captured trace is empty. That getter formats whatever stackTrace() holds
at access time, so:
- zero frames -> "Error: <message>" (matches Node's header-only form)
- frames later attached by Bun__attachAsyncStackFromPromise -> full trace
- Error.prepareStackTrace is honored with an empty call-sites array
The sync path is unchanged (always has JS frames on the stack).
|
Warning Review limit reached
Next review available in: 10 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (2)
Comment |
|
Updated 1:40 AM PT - Jul 25th, 2026
❌ @robobun, your commit 32d79c6 has some failures in 🧪 To try this PR locally: bunx bun-pr 35515That installs a local version of the PR into your bun-35515 --bun |
There was a problem hiding this comment.
I didn't find any bugs, but this touches JSC error/stack materialization internals and makes a layering choice (patching SystemError__toErrorInstance rather than the WebKit-side materializeErrorInfoIfNeeded root the description names), so a maintainer familiar with the stack-trace machinery should sign off.
What was reviewed:
- The new
putDirectCustomAccessorcall matches the existing pattern inFormatStackTraceForJS.cpp:806(Error.captureStackTrace) — same accessor, same attributes. - Interaction with
Bun__attachAsyncStackFromPromise: installing the custom accessor does not sethasMaterializedErrorInfo, so later-attached async frames still land and the lazy getter picks them up on first read. - Tests: subprocess isolation for the
prepareStackTracecase, pipes drained concurrently, stderr/stdout asserted before exit code.
Extended reasoning...
Overview
The PR adds ~15 lines to SystemError__toErrorInstance in src/jsc/bindings/bindings.cpp: after createError() returns, if the resulting ErrorInstance has a null or empty stackTrace(), it installs the existing m_lazyStackCustomGetterSetter on .stack. This ensures async fs/dns/socket errors created from native code with no JS frames on the stack expose err.stack as a string (header-only or with later-attached async frames) instead of undefined. ~80 lines of new tests in test/js/node/fs/fs.test.ts cover 9 callback-form fs functions, fs.promises via await and via .then(), createReadStream's 'error' event, and Error.prepareStackTrace.
Security risks
None. This only affects the shape of the .stack property on error objects; no new inputs are parsed and no privileged operations are gated.
Level of scrutiny
Medium-high. The C++ change is small and reuses an established pattern verbatim (identical putDirectCustomAccessor call to errorConstructorFuncCaptureStackTrace at FormatStackTraceForJS.cpp:806). But it lives in the intersection of JSC's error-info materialization state machine, custom accessors, and Bun__attachAsyncStackFromPromise — an area where ordering (materialize vs. setStackFrames vs. lazy-getter) has caused ASSERTs before (see the comment at FormatStackTraceForJS.cpp:783-786). I traced the interaction: putDirectCustomAccessor does not flip m_errorInfoMaterialized, so Bun__attachAsyncStackFromPromise's guard at AsyncStackTrace.cpp:163 still passes and later-attached frames still work; the getter then reads whatever stackTrace() holds at access time. That looks correct, but a maintainer who owns this code should confirm.
Other factors
- Layering: the PR description explicitly notes the root cause is in JSC's
materializeErrorInfoIfNeeded(which also affectsError.stackTraceLimit = 0; new Error().stack) and defers that to a separate WebKit change. Patching onlySystemError__toErrorInstanceis a deliberate scoping choice; per REVIEW.md's "fix at the layer that owns the invariant" guidance, a human should agree that this narrower fix is the right call for now. - Whole-class coverage: only
SystemError__toErrorInstanceis patched. Other native error-creation paths that can run with no JS frames (e.g. the ErrorCode machinery) may exhibit the sameundefinedstack — a maintainer can decide whether those belong in this PR. - Tests follow harness conventions (
bunEnv,bunExe(), concurrent pipe drain, subprocess isolation for the globalprepareStackTracemutation) and assert specific error codes plus the header regex rather than just truthiness.
There was a problem hiding this comment.
I didn't find any issues, but this touches native JSC error-instance construction in SystemError__toErrorInstance, which every async fs/dns/socket error flows through — worth a quick human glance before landing.
What was reviewed:
- The new branch reuses
m_lazyStackCustomGetterSetterexactly aserrorConstructorFuncCaptureStackTracealready does (FormatStackTraceForJS.cpp:806); no exception check needed sinceputDirectCustomAccessorandstackTrace()are non-throwing. - Confirmed the getter handles
stackTrace() == nullptrand empty vectors, and self-replaces with a data property on first read, so repeated.stackaccess and latersetStackFramesfromBun__attachAsyncStackFromPromiseboth work. - Tests cover the variant matrix (9 callback fns, promises via
awaitand.then(), stream'error',prepareStackTracein an isolated subprocess) and follow harness conventions.
Extended reasoning...
Overview
This PR fixes err.stack === undefined on async node:fs (and by extension dns/socket) errors created from native code when no JS frames are on the stack. It adds an 8-line branch to SystemError__toErrorInstance in src/jsc/bindings/bindings.cpp that installs the existing m_lazyStackCustomGetterSetter on .stack when the freshly-created ErrorInstance has an empty/null stack trace, plus ~80 lines of tests in test/js/node/fs/fs.test.ts.
Security risks
None. This is error-message formatting; no untrusted-input parsing, auth, or crypto is involved.
Level of scrutiny
Moderate. The C++ change is tiny and directly copies an established pattern — the identical putDirectCustomAccessor(... m_lazyStackCustomGetterSetter ...) call already exists at FormatStackTraceForJS.cpp:806 for Error.captureStackTrace. The new code is guarded by !trace || trace->isEmpty(), so the sync path (which always has JS frames) is untouched. errorInstanceLazyStackCustomGetter explicitly handles the null-trace case by building an empty Vector<StackFrame> and calling computeErrorInfoToJSValue, then replaces the accessor with a plain data property, so there is no re-entrancy or repeated-computation concern. None of the calls in the new block can throw, so no RETURN_IF_EXCEPTION is needed.
That said, SystemError__toErrorInstance is the constructor for essentially every syscall-derived error object in the runtime, and the lazy getter runs Error.prepareStackTrace (user code) at .stack access time rather than at construction. That is the same timing Node uses and the same behavior captureStackTrace already has in Bun, but it is a behavior change on a very widely-hit path, so a maintainer should confirm this is the layer they want the fix at (vs. the JSC-side materializeErrorInfoIfNeeded change the PR description mentions as future work).
Other factors
Test coverage is thorough per REVIEW.md's variant-matrix guidance: callback form for 9 fs functions, fs.promises via both await and .then(), createReadStream 'error' event, and Error.prepareStackTrace interaction isolated in a subprocess with all pipes drained via Promise.all. The nonexistent-path fixture is under tmpdir() and never created, so ENOENT is deterministic. The comment-cop bot's lint request was addressed in commit 32d79c6. CI build #80117 is still in flight, so there is no green signal yet.
|
CI status: the diff is green. The new The red on builds #79991 and #80117 is unrelated infrastructure and known flakes:
Ready for review. |
|
Superseded by #38074, which installs the same lazy |
What
Async
node:fserrors (callback form andfs.promisesconsumed via.then()/.catch()) haderr.stack === undefined. Every sync form of the same failing call had a normal stack, and Node gives every fs error a.stackstring. Loggers that print${err.stack}orconsole.error(err.stack)output the literal"undefined"for the most common error object in a Node app.Why
SystemError__toErrorInstanceconstructs the error from native code at the top of the event loop (the threadpool completion callback), where there are no JS frames on the stack.createError()captures an emptym_stackTrace, andErrorInstance::materializeErrorInfoIfNeedednever installs a.stackown property when the trace is empty, so readingerr.stackfalls through toundefined.Bun__attachAsyncStackFromPromisetries to recover async frames from the promise's await chain, but the callback-form wrappers (fs.open(path, cb)insrc/js/node/fs.ts) attach the user's callback via.then(), which yields noJSAsyncFunctionGeneratorto walk, so it bails with zero frames too.Fix
In
SystemError__toErrorInstance, when the freshly-createdErrorInstance's stack trace is empty, install the existingm_lazyStackCustomGetterSetteron.stack. That getter (errorInstanceLazyStackCustomGetter) formats whateverstackTrace()holds at access time:"Error: <message>"(matches Node's header-only form for async fs errors)Bun__attachAsyncStackFromPromise(awaitcase): full async traceError.prepareStackTraceis called with an empty call-sites array, matching NodeThe sync path is unaffected (always has JS frames; the new branch is not taken). Same for any other
SystemErrorcreated off the event loop with no JS on the stack (dns, sockets).Verification
before / after
Before:
After:
Node:
New tests in
test/js/node/fs/fs.test.tscover the callback form for 9 fs functions,fs.promises.*via bothawaitand.then(),createReadStream's'error'event, andError.prepareStackTraceinteraction. All fail on the released binary and pass with this change.The underlying
materializeErrorInfoIfNeededbehavior (empty trace => no.stack) lives in JavaScriptCore and also affectsError.stackTraceLimit = 0; new Error("x").stack; that is a separate WebKit-side change and is not addressed here.no test proof · iteration 1 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/node/fs/fs.test.ts