Commit 933cb117c91 for nodejs
commit 933cb117c91c961880d0b00fd9b6b75ff4580484
Author: Ved Chaudhari <84765263+vedchaudhari@users.noreply.github.com>
Date: Thu Nov 12 03:45:53 2026 +0530
fs: restore stack traces of fs/promises readFile and writeFile errors
Errors from readFile(), writeFile() and appendFile() have had no stack
frames since they started using a single thread pool round trip, as
the stack was captured from a C++ callback with no JS frames on it.
Capture it after the await instead, like the rest of fs/promises.
Refs: https://github.com/nodejs/node/pull/65327
Refs: https://github.com/nodejs/node/pull/65489
Assisted-by: claude:opus-5.5
Signed-off-by: vedchaudhari <vedc2853@gmail.com>
PR-URL: https://github.com/nodejs/node/pull/66757
Reviewed-By: Christian Aurich Zanettini Martins <christian.aurichzm@gmail.com>
Reviewed-By: Gürgün Dayıoğlu <hey@gurgun.day>
diff --git a/lib/internal/fs/promises.js b/lib/internal/fs/promises.js
index b79e807fe6c..5f9765d0cf3 100644
--- a/lib/internal/fs/promises.js
+++ b/lib/internal/fs/promises.js
@@ -2226,8 +2226,14 @@ async function writeFile(path, data, options) {
if (!flush && !isCustomIterable(data) && data.byteLength <= kWriteFileMaxChunkSize) {
path = getValidatedPath(path);
- await writeFileInOneRoundTrip(path, stringToFlags(flag, 'options.flag'),
- parseFileMode(options.mode, 'mode', 0o666), data);
+ const flagsNumber = stringToFlags(flag, 'options.flag');
+ const mode = parseFileMode(options.mode, 'mode', 0o666);
+ try {
+ await writeFileInOneRoundTrip(path, flagsNumber, mode, data);
+ } catch (err) {
+ ErrorCaptureStackTrace(err);
+ throw err;
+ }
checkAborted(options.signal); // An abort during the write still wins.
return;
}
@@ -2244,6 +2250,10 @@ async function writeFile(path, data, options) {
/**
* Open + write + close as one thread pool round trip.
+ * Throws synchronously if the permission model denies access, while the
+ * caller's frames are still on the stack. The caller is responsible for
+ * capturing the stack trace of a rejection, as no JS frames are on the stack
+ * when the job completes.
* @param {string|Buffer} path Validated path
* @param {number} flagsNumber
* @param {number} mode
@@ -2251,22 +2261,21 @@ async function writeFile(path, data, options) {
* @returns {Promise<void>}
*/
function writeFileInOneRoundTrip(path, flagsNumber, mode, data) {
- return new Promise((resolve, reject) => {
- const job = new binding.WriteFileJob(path, flagsNumber, mode, data);
+ const job = new binding.WriteFileJob(path, flagsNumber, mode, data);
+ const promise = new Promise((resolve, reject) => {
job.ondone = (err) => {
if (err != null) {
- ErrorCaptureStackTrace(err, writeFileInOneRoundTrip);
reject(err);
} else {
resolve();
}
};
- const accessError = job.run(path);
- if (accessError !== undefined) {
- ErrorCaptureStackTrace(accessError, writeFileInOneRoundTrip);
- reject(accessError);
- }
});
+ const accessError = job.run(path);
+ if (accessError !== undefined) {
+ throw accessError;
+ }
+ return promise;
}
function isCustomIterable(obj) {
@@ -2309,7 +2318,15 @@ async function readFile(path, options) {
// files of up to one chunk; larger and non-regular files (size 0) come
// back as an open fd + size and are read by readFileHandle() as before.
path = getValidatedPath(path);
- const { 0: buffer, 1: fd, 2: size } = await readFileInOneRoundTrip(path, stringToFlags(flag));
+ const flagsNumber = stringToFlags(flag);
+ let result;
+ try {
+ result = await readFileInOneRoundTrip(path, flagsNumber);
+ } catch (err) {
+ ErrorCaptureStackTrace(err);
+ throw err;
+ }
+ const { 0: buffer, 1: fd, 2: size } = result;
if (fd === -1) {
checkAborted(options.signal); // An abort during the read still wins.
return options.encoding ? buffer.toString(options.encoding) : buffer;
@@ -2323,28 +2340,31 @@ async function readFile(path, options) {
}
/**
+ * Throws synchronously if the permission model denies access, while the
+ * caller's frames are still on the stack. The caller is responsible for
+ * capturing the stack trace of a rejection, as no JS frames are on the stack
+ * when the job completes.
* @param {string|Buffer} path Validated path
* @param {number} flagsNumber
* @returns {Promise<[Buffer|undefined, number, number|undefined]>} [buffer, -1] or [undefined, fd, size]
*/
function readFileInOneRoundTrip(path, flagsNumber) {
- return new Promise((resolve, reject) => {
- const job = new binding.ReadFileJob(path, flagsNumber, kReadFileBufferLength);
+ const job = new binding.ReadFileJob(path, flagsNumber, kReadFileBufferLength);
+ const promise = new Promise((resolve, reject) => {
job.ondone = (err, buffer, fd, size, closeErr) => {
const error = err ?? closeErr;
if (error != null) {
- ErrorCaptureStackTrace(error, readFileInOneRoundTrip);
reject(error);
} else {
resolve([buffer, fd, size]);
}
};
- const accessError = job.run(path);
- if (accessError !== undefined) {
- ErrorCaptureStackTrace(accessError, readFileInOneRoundTrip);
- reject(accessError);
- }
});
+ const accessError = job.run(path);
+ if (accessError !== undefined) {
+ throw accessError;
+ }
+ return promise;
}
async function* _watch(filename, options = kEmptyObject) {
diff --git a/test/parallel/test-fs-promises.js b/test/parallel/test-fs-promises.js
index 07f01187e89..6ddc4cd3cb3 100644
--- a/test/parallel/test-fs-promises.js
+++ b/test/parallel/test-fs-promises.js
@@ -9,6 +9,7 @@ const fs = require('fs');
const fsPromises = fs.promises;
const {
access,
+ appendFile,
chmod,
chown,
copyFile,
@@ -62,6 +63,30 @@ assert.strictEqual(
}
).then(common.mustCall());
+ assert.rejects(
+ readFile('this file does not exist'),
+ {
+ code: 'ENOENT',
+ stack: /at async assert\.rejects/
+ }
+ ).then(common.mustCall());
+
+ assert.rejects(
+ writeFile(path.join('this dir does not exist', 'file'), 'data'),
+ {
+ code: 'ENOENT',
+ stack: /at async assert\.rejects/
+ }
+ ).then(common.mustCall());
+
+ assert.rejects(
+ appendFile(path.join('this dir does not exist', 'file'), 'data'),
+ {
+ code: 'ENOENT',
+ stack: /at async assert\.rejects/
+ }
+ ).then(common.mustCall());
+
assert.rejects(
access(__filename, 8),
{
diff --git a/test/parallel/test-permission-fs-promises-stack-trace.js b/test/parallel/test-permission-fs-promises-stack-trace.js
new file mode 100644
index 00000000000..7160473173e
--- /dev/null
+++ b/test/parallel/test-permission-fs-promises-stack-trace.js
@@ -0,0 +1,42 @@
+'use strict';
+
+require('../common');
+const assert = require('assert');
+const { spawnSync } = require('child_process');
+const tmpdir = require('../common/tmpdir');
+
+tmpdir.refresh();
+
+// The permission model denies access before the first await, so the stack
+// still has the frames of a caller that does not await the promise.
+const { status, stderr } = spawnSync(
+ process.execPath,
+ [
+ '--permission',
+ '-e',
+ `
+ const assert = require('assert');
+ const { readFile, writeFile, appendFile } = require('fs/promises');
+ const target = ${JSON.stringify(tmpdir.resolve('file'))};
+
+ function readCaller() { return readFile(target); }
+ function writeCaller() { return writeFile(target, 'data'); }
+ function appendCaller() { return appendFile(target, 'data'); }
+ async function awaitAppendCaller() { await appendFile(target, 'data'); }
+
+ const cases = [
+ [readCaller, /at readCaller /],
+ [writeCaller, /at writeCaller /],
+ [appendCaller, /at appendCaller /],
+ [awaitAppendCaller, /at awaitAppendCaller /],
+ ];
+ let checked = 0;
+ for (const [caller, stack] of cases) {
+ assert.rejects(caller(), { code: 'ERR_ACCESS_DENIED', stack })
+ .then(() => checked++);
+ }
+ process.on('exit', () => assert.strictEqual(checked, cases.length));
+ `,
+ ],
+);
+assert.strictEqual(status, 0, stderr.toString());