Skip to content

fs: add stack traces to async callback errors#61720

Open
DukeDeSouth wants to merge 1 commit intonodejs:mainfrom
DukeDeSouth:fix/fs-async-error-stack-traces
Open

fs: add stack traces to async callback errors#61720
DukeDeSouth wants to merge 1 commit intonodejs:mainfrom
DukeDeSouth:fix/fs-async-error-stack-traces

Conversation

@DukeDeSouth
Copy link

Problem

Async fs callback errors have no JavaScript stack traces — only the error message (#30944, open since 2019, 29 reactions). This makes debugging fs operations very difficult:

fs.readFile('nonexistent', (err) => {
  console.log(err.stack);
});
// Error: ENOENT: no such file or directory, open 'nonexistent'
// (no stack frames!)

The root cause: errors are created in C++ (FSReqAfterScope::RejectUVException) when the JavaScript call stack is empty. The sync API and promises API don't have this problem — sync errors are created in JS context, and the promises API already calls ErrorCaptureStackTrace in handleErrorFromBinding.

Solution

Two-tier approach that enriches async callback errors with stack traces:

Tier 1 (always-on, default): enrichFsError() is called in makeCallback() on the error path. Zero overhead on success (~2-5ns instanceof check), captures internal Node.js frames on error.

Tier 2 (opt-in via NODE_FS_ASYNC_STACK_TRACES=1): Captures the JavaScript call-site stack at operation initiation time, providing full stack traces showing WHERE in user code the operation was called. ~1us overhead per async fs call when enabled.

Before:

Error: ENOENT: no such file or directory, open 'config.json'

After (default):

Error: ENOENT: no such file or directory, open 'config.json'
    at FSReqCallback.readFileAfterOpen [as oncomplete] (node:fs:292:13)

After (NODE_FS_ASYNC_STACK_TRACES=1):

Error: ENOENT: no such file or directory, open 'config.json'
    at loadConfig (/app/server.js:42:6)
    at main (/app/server.js:98:3)

Changes

  • lib/internal/fs/utils.js: Add enrichFsError() and shouldCaptureCallSiteStack() — shared error enrichment utilities
  • lib/fs.js: Modify makeCallback() and makeStatsCallback() to enrich errors; add call-site capture to readFile()
  • lib/internal/fs/read/context.js: Add error enrichment to ReadFileContext path
  • test/parallel/test-fs-error-stack-trace.js: Comprehensive tests for all async fs operations

Design Rationale

  • Why makeCallback and not C++? Pure JavaScript change, same pattern as existing handleErrorFromBinding (sync) and promises API
  • Why opt-in for full traces? Error.captureStackTrace costs ~1us per call. For fs.stat (~2.5us), this adds 40% overhead. Opt-in avoids surprising perf regressions.
  • Why env var instead of CLI flag? No C++ changes needed. Standard pattern (NODE_FS_ASYNC_STACK_TRACES=1 node app.js).

Fixes: #30944

Made with Cursor

Async fs callback errors (created via UVException in C++ when the
JavaScript call stack is empty) previously had no stack frames — only
the error message. This made debugging fs operations very difficult,
as users could not determine where in their code a failed operation
was initiated.

This change enriches async fs callback errors with stack traces via
a two-tier approach:

Tier 1 (always-on, zero overhead on success):
  - `enrichFsError()` is called in `makeCallback()` when an error
    is received from the native binding
  - Captures the stack at callback invocation time, providing at
    minimum the internal Node.js frames
  - Only runs on the error path (~1us), no overhead on success

Tier 2 (opt-in via NODE_FS_ASYNC_STACK_TRACES=1):
  - When enabled, captures the JavaScript call-site stack at
    operation initiation time (in makeCallback/readFile)
  - Provides full stack traces showing WHERE in user code the
    operation was called, similar to sync fs error stacks
  - ~1us overhead per async fs call when enabled

This is consistent with how `handleErrorFromBinding` enriches errors
in the sync path (lib/internal/fs/utils.js) and how the promise-based
path enriches errors (lib/internal/fs/promises.js:handleErrorFromBinding).

Before:
  Error: ENOENT: no such file or directory, open 'config.json'
  (no stack frames)

After (default):
  Error: ENOENT: no such file or directory, open 'config.json'
      at FSReqCallback.readFileAfterOpen [as oncomplete] (node:fs:292:13)

After (NODE_FS_ASYNC_STACK_TRACES=1):
  Error: ENOENT: no such file or directory, open 'config.json'
      at loadConfig (/app/server.js:42:6)
      at main (/app/server.js:98:3)

Fixes: nodejs#30944
Co-authored-by: Cursor <cursoragent@cursor.com>
@nodejs-github-bot nodejs-github-bot added fs Issues and PRs related to the fs subsystem / file system. needs-ci PRs that need a full CI run. labels Feb 7, 2026
@benjamingr
Copy link
Member

This sort of thing has existed since ~2010 (see longjohn and stuff like bluebird async stack traces) and in fact you get this mostly by connecting a debugger (which also does non-zero-cost stack traces).

The eventual design was to support this only for async/await since it was zero cost and new and easy to reason about - there are exceptions (e.g. event emitter errors).

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

Labels

fs Issues and PRs related to the fs subsystem / file system. needs-ci PRs that need a full CI run.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fs async functions (both callback and promises APIs) return Errors without stack trace

3 participants