Repository navigation
fs: restore stack traces of fs/promises readFile and writeFile errors - #66757
vedchaudhari wants to merge 1 commit into
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #66757 +/- ##
=======================================
Coverage 90.67% 90.68%
=======================================
Files 797 797
Lines 278773 278807 +34
Branches 53607 53613 +6
=======================================
+ Hits 252778 252836 +58
+ Misses 16356 16311 -45
- Partials 9639 9660 +21
🚀 New features to boost your workflow:
|
christianaurichzm
left a comment
There was a problem hiding this comment.
Thanks for fixing this. I left one comment about permission errors losing the caller's stack frames.
| try { | ||
| result = await readFileInOneRoundTrip(path, flagsNumber); | ||
| } catch (err) { | ||
| ErrorCaptureStackTrace(err); |
There was a problem hiding this comment.
This loses the caller's frames for permission errors:
const { readFile } = require('node:fs/promises');
function loadConfig() {
return readFile('/notallowed/config.json').catch(console.error);
}
loadConfig();With node --permission --allow-fs-read=. repro.cjs, main shows at loadConfig. With this PR, the stack only has readFile and processTicksAndRejections.
job.run() returns the permission error synchronously, so capturing its stack after the await loses those frames. Could we keep the existing capture for accessError in the helper and only recapture errors from the async completion?
writeFile() has the same issue, and appendFile() loses the caller's frame even when awaited. Please add a permission test with a non-async caller that checks its frame is preserved in the stack. The existing fixtures don't check the stack.
There was a problem hiding this comment.
You're right, I only tested ENOENT and didn't think about the permission error. I changed the helpers to throw it directly instead of rejecting, so the caller shows up in the stack again, appendFile() too.
I’ve also added a test that fails without this change. Fixed in 42e6ebd. If you’d prefer to keep the capture in the helper, I can change it.
There was a problem hiding this comment.
Looks good to me, no need to move the capture back into the helper. The throw happens before anything is awaited, so the catch still sees the caller's frames.
One non-blocking nit on the new test. The child doesn't check that all four assert.rejects() calls finished. If a denied call ever stayed pending, the process would exit with 0 without checking any stack. A counter checked on exit would cover it. The existing fixtures would still catch a hang, so this is only about this test.
There was a problem hiding this comment.
Good point, I've added a counter that's checked on exit, so the test now fails if any of the calls never finishes.
6931507 to
42e6ebd
Compare
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: nodejs#65327 Refs: nodejs#65489 Assisted-by: claude:opus-5.5 Signed-off-by: vedchaudhari <vedc2853@gmail.com>
42e6ebd to
b9df2e3
Compare
Since #65327 and #65489, errors from
fs.promises.readFile(),writeFile()andappendFile()have no stack frames. This affects v26.8.0+ (readFile()) andv26.9.0+ (
writeFile(),appendFile()); v24.x and v22.x aren't affected.The stack was captured from a C++ callback with no JS frames on it. This captures
it after the
awaitinstead, like the rest offs/promises. The success path isunchanged, and the
fs/readfile-promisesandfs/writefile-promisesbenchmarksshow no difference.
Benchmark results
macOS arm64, 10 runs per binary. p-values are from a Welch's t-test;
*marks p < 0.05.concurrent=1 len=1024 encoding=''concurrent=1 len=1024 encoding='utf-8'concurrent=1 len=16777216 encoding=''concurrent=1 len=16777216 encoding='utf-8'concurrent=1 len=33554432 encoding=''concurrent=1 len=33554432 encoding='utf-8'concurrent=1 len=4194304 encoding=''concurrent=1 len=4194304 encoding='utf-8'concurrent=1 len=524288 encoding=''concurrent=1 len=524288 encoding='utf-8'concurrent=1 len=8388608 encoding=''concurrent=1 len=8388608 encoding='utf-8'concurrent=10 len=1024 encoding=''concurrent=10 len=1024 encoding='utf-8'concurrent=10 len=16777216 encoding=''concurrent=10 len=16777216 encoding='utf-8'concurrent=10 len=33554432 encoding=''concurrent=10 len=33554432 encoding='utf-8'concurrent=10 len=4194304 encoding=''concurrent=10 len=4194304 encoding='utf-8'concurrent=10 len=524288 encoding=''concurrent=10 len=524288 encoding='utf-8'concurrent=10 len=8388608 encoding=''concurrent=10 len=8388608 encoding='utf-8'concurrent=1 size=1024 encodingType='asc'concurrent=1 size=1024 encodingType='buf'concurrent=1 size=1024 encodingType='utf'concurrent=1 size=1048576 encodingType='asc'concurrent=1 size=1048576 encodingType='buf'concurrent=1 size=1048576 encodingType='utf'concurrent=1 size=2 encodingType='asc'concurrent=1 size=2 encodingType='buf'concurrent=1 size=2 encodingType='utf'concurrent=1 size=65535 encodingType='asc'concurrent=1 size=65535 encodingType='buf'concurrent=1 size=65535 encodingType='utf'concurrent=10 size=1024 encodingType='asc'concurrent=10 size=1024 encodingType='buf'concurrent=10 size=1024 encodingType='utf'concurrent=10 size=1048576 encodingType='asc'concurrent=10 size=1048576 encodingType='buf'concurrent=10 size=1048576 encodingType='utf'concurrent=10 size=2 encodingType='asc'concurrent=10 size=2 encodingType='buf'concurrent=10 size=2 encodingType='utf'concurrent=10 size=65535 encodingType='asc'concurrent=10 size=65535 encodingType='buf'concurrent=10 size=65535 encodingType='utf'1 of 48 configurations is under p < 0.05, about what chance alone gives with
48 comparisons. It's a 1 MiB write (+6.9%), which is above
kWriteFileMaxChunkSize(512 KiB) and doesn't go through the changed code.
AI-assisted. I reproduced the bug and ran the tests and benchmarks myself.
cc @codebytere