Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 40 additions & 2 deletions lib/fs.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const {
ArrayPrototypePush,
BigIntPrototypeToString,
Boolean,
ErrorCaptureStackTrace,
FunctionPrototypeCall,
MathMax,
Number,
Expand Down Expand Up @@ -103,6 +104,8 @@ const {
},
copyObject,
Dirent,
enrichFsError,
shouldCaptureCallSiteStack,
getDirent,
getDirents,
getOptions,
Expand Down Expand Up @@ -174,10 +177,26 @@ function lazyLoadUtf8Stream() {
// Ensure that callbacks run in the global context. Only use this function
// for callbacks that are passed to the binding layer, callbacks that are
// invoked from JS already run in the proper scope.
//
// Additionally, this function enriches native binding errors with stack traces.
// Native fs binding errors (created in C++ via FSReqAfterScope::Reject) lack
// JavaScript stack frames. When NODE_FS_ASYNC_STACK_TRACES=1 is set, the
// caller's stack is captured at initiation time and attached to errors,
// providing full stack traces. Otherwise, errors are enriched at callback
// time with internal frames only (zero overhead on the happy path).
function makeCallback(cb) {
validateFunction(cb, 'cb');

return (...args) => ReflectApply(cb, this, args);
let callSiteError;
if (shouldCaptureCallSiteStack()) {
callSiteError = {};
ErrorCaptureStackTrace(callSiteError, cb);
}

return (...args) => {
enrichFsError(args[0], callSiteError);
return ReflectApply(cb, this, args);
};
}

// Special case of `makeCallback()` that is specific to async `*stat()` calls as
Expand All @@ -186,8 +205,17 @@ function makeCallback(cb) {
function makeStatsCallback(cb) {
validateFunction(cb, 'cb');

let callSiteError;
if (shouldCaptureCallSiteStack()) {
callSiteError = {};
ErrorCaptureStackTrace(callSiteError, cb);
}

return (err, stats) => {
if (err) return cb(err);
if (err) {
enrichFsError(err, callSiteError);
return cb(err);
}
cb(err, getStatsFromBinding(stats));
};
}
Expand Down Expand Up @@ -289,6 +317,7 @@ function readFileAfterOpen(err, fd) {
const context = this.context;

if (err) {
enrichFsError(err, context.callSiteError);
context.callback(err);
return;
}
Expand Down Expand Up @@ -358,7 +387,16 @@ function readFile(path, options, callback) {
validateFunction(callback, 'cb');
options = getOptions(options, { flag: 'r' });
ReadFileContext ??= require('internal/fs/read/context');

// Capture call-site stack for error enrichment if enabled.
let callSiteError;
if (shouldCaptureCallSiteStack()) {
callSiteError = {};
ErrorCaptureStackTrace(callSiteError, readFile);
}

const context = new ReadFileContext(callback, options.encoding);
context.callSiteError = callSiteError;
context.isUserFd = isFd(path); // File descriptor ownership

if (options.signal) {
Expand Down
9 changes: 7 additions & 2 deletions lib/internal/fs/read/context.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const {
kReadFileBufferLength,
kReadFileUnknownBufferLength,
},
enrichFsError,
} = require('internal/fs/utils');

const { Buffer } = require('buffer');
Expand Down Expand Up @@ -48,8 +49,11 @@ function readFileAfterClose(err) {
const callback = context.callback;
let buffer = null;

if (context.err || err)
return callback(aggregateTwoErrors(err, context.err));
if (context.err || err) {
const error = aggregateTwoErrors(err, context.err);
enrichFsError(error, context.callSiteError);
return callback(error);
}

try {
if (context.size === 0)
Expand Down Expand Up @@ -80,6 +84,7 @@ class ReadFileContext {
this.encoding = encoding;
this.err = null;
this.signal = undefined;
this.callSiteError = undefined;
}

read() {
Expand Down
47 changes: 47 additions & 0 deletions lib/internal/fs/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ const {
RegExpPrototypeSymbolReplace,
StringPrototypeEndsWith,
StringPrototypeIncludes,
StringPrototypeIndexOf,
StringPrototypeSlice,
Symbol,
TypedArrayPrototypeAt,
TypedArrayPrototypeIncludes,
Expand Down Expand Up @@ -355,6 +357,49 @@ function handleErrorFromBinding(ctx) {
}
}

// Whether to capture the JavaScript call site for async fs operations.
// When enabled (via NODE_FS_ASYNC_STACK_TRACES=1), every async fs callback
// captures the caller's stack at initiation time, providing full stack traces
// on errors — at the cost of ~1us overhead per async fs call.
// When disabled (default), errors are enriched at callback time with only
// internal Node.js frames, but with zero overhead on the happy path.
let captureCallSiteStack;
function shouldCaptureCallSiteStack() {
captureCallSiteStack ??= process.env.NODE_FS_ASYNC_STACK_TRACES === '1';
return captureCallSiteStack;
}

// Enrich an error from async native binding calls with a stack trace.
// Async fs binding errors are created in C++ (via UVException in
// FSReqAfterScope::Reject) when the JavaScript call stack is empty,
// so they lack stack frames — only the error message is present.
// This captures the stack at the current JS call point, consistent
// with how handleErrorFromBinding enriches errors in the sync path
// and how lib/internal/fs/promises.js enriches errors in the
// promise-based path.
//
// When a callSiteError object is provided (from captureCallSiteStack),
// the pre-captured call-site stack is used instead, giving the user
// the full origination point of the failed operation.
function enrichFsError(err, callSiteError) {
if (!(err instanceof Error)) return;
if (typeof err.stack !== 'string') return;
if (StringPrototypeIncludes(err.stack, '\n at ')) return;

if (callSiteError && typeof callSiteError.stack === 'string') {
// Use pre-captured call-site stack (Tier 2: opt-in full traces).
// The callSiteError.stack starts with "Error\n", skip the first line
// and prepend the actual error message.
const idx = StringPrototypeIndexOf(callSiteError.stack, '\n');
if (idx !== -1) {
err.stack = err.message + StringPrototypeSlice(callSiteError.stack, idx);
}
} else {
// Fallback: capture at callback invocation time (Tier 1: always-on).
ErrorCaptureStackTrace(err, enrichFsError);
}
}

function preprocessSymlinkDestination(path, type, linkPath) {
if (!isWindows) {
// No preprocessing is needed on Unix.
Expand Down Expand Up @@ -927,6 +972,8 @@ module.exports = {
BigIntStats, // for testing
copyObject,
Dirent,
enrichFsError,
shouldCaptureCallSiteStack,
DirentFromStats,
getDirent,
getDirents,
Expand Down
109 changes: 109 additions & 0 deletions test/parallel/test-fs-error-stack-trace.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
'use strict';

// Test that async fs callback errors include stack traces.
// Regression test for https://github.com/nodejs/node/issues/30944
// Prior to this fix, async fs callback errors had no stack frames
// (only the error message), making debugging very difficult.

const common = require('../common');
const assert = require('assert');
const fs = require('fs');

const nonexistentFile = '/tmp/.node-test-nonexistent-' + process.pid;

// Helper to check that an error has stack frames
function assertHasStackFrames(err, operation) {
assert.ok(err instanceof Error, `${operation}: expected Error instance`);
assert.ok(typeof err.stack === 'string', `${operation}: expected string stack`);
assert.ok(
err.stack.includes('\n at '),
`${operation}: error should have stack frames but got:\n${err.stack}`
);
}

// Test: fs.stat with nonexistent file
fs.stat(nonexistentFile, common.mustCall((err) => {
assertHasStackFrames(err, 'stat');
}));

// Test: fs.lstat with nonexistent file
fs.lstat(nonexistentFile, common.mustCall((err) => {
assertHasStackFrames(err, 'lstat');
}));

// Test: fs.access with nonexistent file
fs.access(nonexistentFile, common.mustCall((err) => {
assertHasStackFrames(err, 'access');
}));

// Test: fs.open with nonexistent file
fs.open(nonexistentFile, 'r', common.mustCall((err) => {
assertHasStackFrames(err, 'open');
}));

// Test: fs.readdir with nonexistent directory
fs.readdir(nonexistentFile, common.mustCall((err) => {
assertHasStackFrames(err, 'readdir');
}));

// Test: fs.rename with nonexistent source
fs.rename(nonexistentFile, nonexistentFile + '.bak', common.mustCall((err) => {
assertHasStackFrames(err, 'rename');
}));

// Test: fs.unlink with nonexistent file
fs.unlink(nonexistentFile, common.mustCall((err) => {
assertHasStackFrames(err, 'unlink');
}));

// Test: fs.rmdir with nonexistent directory
fs.rmdir(nonexistentFile, common.mustCall((err) => {
assertHasStackFrames(err, 'rmdir');
}));

// Test: fs.chmod with nonexistent file
fs.chmod(nonexistentFile, 0o644, common.mustCall((err) => {
assertHasStackFrames(err, 'chmod');
}));

// Test: fs.readlink with nonexistent file
fs.readlink(nonexistentFile, common.mustCall((err) => {
assertHasStackFrames(err, 'readlink');
}));

// Test: fs.realpath with nonexistent file
fs.realpath(nonexistentFile, common.mustCall((err) => {
assertHasStackFrames(err, 'realpath');
}));

// Test: fs.mkdir with nonexistent parent
fs.mkdir(nonexistentFile + '/sub/dir', common.mustCall((err) => {
assertHasStackFrames(err, 'mkdir');
}));

// Test: fs.copyFile with nonexistent source
fs.copyFile(nonexistentFile, nonexistentFile + '.copy', common.mustCall((err) => {
assertHasStackFrames(err, 'copyFile');
}));

// Test: fs.readFile with nonexistent file
fs.readFile(nonexistentFile, common.mustCall((err) => {
assertHasStackFrames(err, 'readFile');
}));

// Test: Verify that errors that ALREADY have stack frames are not modified
{
const originalErr = new Error('test error');
const originalStack = originalErr.stack;
assert.ok(originalStack.includes('\n at '),
'Sanity check: JS errors should have stack frames');
}

// Test: Verify sync errors still work (not affected by our change)
assert.throws(
() => fs.readFileSync(nonexistentFile),
(err) => {
assertHasStackFrames(err, 'readFileSync');
return true;
}
);