Skip to content

Commit fc73958

Browse files
committed
fs: cancel in-flight stat on abort
1 parent 1fd4949 commit fc73958

3 files changed

Lines changed: 53 additions & 18 deletions

File tree

lib/fs.js

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ const {
136136
const {
137137
isInt32,
138138
parseFileMode,
139+
validateAbortSignal,
139140
validateBoolean,
140141
validateBuffer,
141142
validateEncoding,
@@ -340,6 +341,26 @@ function checkAborted(signal, callback) {
340341
return false;
341342
}
342343

344+
function bindSignalToReq(req, signal, callback) {
345+
if (!signal) {
346+
req.oncomplete = callback;
347+
return;
348+
}
349+
let aborted = false;
350+
const onAbort = () => {
351+
aborted = true;
352+
req.cancel();
353+
callback(new AbortError(undefined, { cause: signal.reason }));
354+
};
355+
kResistStopPropagation ??= require('internal/event_target').kResistStopPropagation;
356+
signal.addEventListener('abort', onAbort, { __proto__: null, [kResistStopPropagation]: true });
357+
req.oncomplete = function(err, result) {
358+
signal.removeEventListener('abort', onAbort);
359+
if (aborted) return;
360+
callback(err, result);
361+
};
362+
}
363+
343364
/**
344365
* Asynchronously reads the entire contents of a file.
345366
* @param {string | Buffer | URL | number} path
@@ -1635,11 +1656,12 @@ function stat(path, options = { bigint: false, throwIfNoEntry: true }, callback)
16351656
callback = makeStatsCallback(callback);
16361657
path = getValidatedPath(path);
16371658

1659+
if (options.signal !== undefined) validateAbortSignal(options.signal, 'options.signal');
16381660
if (checkAborted(options.signal, callback)) return;
16391661

16401662
const req = new FSReqCallback(options.bigint);
1641-
req.oncomplete = callback;
1642-
binding.stat(getValidatedPath(path), options.bigint, req, options.throwIfNoEntry);
1663+
bindSignalToReq(req, options.signal, callback);
1664+
binding.stat(path, options.bigint, req, options.throwIfNoEntry);
16431665
}
16441666

16451667
function statfs(path, options = { bigint: false }, callback) {

src/node_file.cc

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -750,6 +750,12 @@ void NewFSReqCallback(const FunctionCallbackInfo<Value>& args) {
750750
new FSReqCallback(binding_data, args.This(), args[0]->IsTrue());
751751
}
752752

753+
void CancelFSReq(const FunctionCallbackInfo<Value>& args) {
754+
FSReqBase* req_wrap;
755+
ASSIGN_OR_RETURN_UNWRAP(&req_wrap, args.This());
756+
req_wrap->Cancel();
757+
}
758+
753759
FSReqAfterScope::FSReqAfterScope(FSReqBase* wrap, uv_fs_t* req)
754760
: wrap_(wrap),
755761
req_(req),
@@ -4215,6 +4221,7 @@ static void CreatePerIsolateProperties(IsolateData* isolate_data,
42154221
fst->InstanceTemplate()->SetInternalFieldCount(
42164222
FSReqBase::kInternalFieldCount);
42174223
fst->Inherit(AsyncWrap::GetConstructorTemplate(isolate_data));
4224+
SetProtoMethod(isolate, fst, "cancel", CancelFSReq);
42184225
SetConstructorFunction(isolate, target, "FSReqCallback", fst);
42194226

42204227
// Create FunctionTemplate for FileHandleReadWrap. There’s no need
@@ -4326,6 +4333,7 @@ void RegisterExternalReferences(ExternalReferenceRegistry* registry) {
43264333

43274334
registry->Register(Mkdtemp);
43284335
registry->Register(NewFSReqCallback);
4336+
registry->Register(CancelFSReq);
43294337

43304338
registry->Register(FileHandle::New);
43314339
registry->Register(FileHandle::Close);

test/parallel/test-fs-stat-abort-test.js

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,29 +6,34 @@ const assert = require('node:assert');
66
const fs = require('node:fs');
77
const tmpdir = require('../common/tmpdir');
88

9-
test('fs.stat should throw AbortError when called with an already aborted AbortSignal', async () => {
10-
// This test verifies that fs.stat immediately throws an AbortError if the provided AbortSignal
11-
// has already been canceled. This approach is used because trying to abort an fs.stat call in flight
12-
// is unreliable given that file system operations tend to complete very quickly on many platforms.
13-
tmpdir.refresh();
9+
tmpdir.refresh();
10+
const filePath = tmpdir.resolve('temp.txt');
11+
fs.writeFileSync(filePath, 'Test');
1412

15-
const filePath = tmpdir.resolve('temp.txt');
16-
fs.writeFileSync(filePath, 'Test');
17-
18-
// Create an already aborted AbortSignal.
13+
test('fs.stat aborts when signal is already aborted', async () => {
1914
const signal = AbortSignal.abort();
20-
2115
const { promise, resolve, reject } = Promise.withResolvers();
2216
fs.stat(filePath, { signal }, (err, stats) => {
23-
if (err) {
24-
return reject(err);
25-
}
17+
if (err) return reject(err);
2618
resolve(stats);
2719
});
20+
await assert.rejects(promise, { name: 'AbortError' });
21+
});
2822

29-
// Assert that the promise is rejected with an AbortError.
23+
test('fs.stat aborts in-flight when signal aborts after the call', async () => {
24+
const controller = new AbortController();
25+
const { promise, resolve, reject } = Promise.withResolvers();
26+
fs.stat(filePath, { signal: controller.signal }, (err, stats) => {
27+
if (err) return reject(err);
28+
resolve(stats);
29+
});
30+
controller.abort();
3031
await assert.rejects(promise, { name: 'AbortError' });
32+
});
3133

32-
fs.unlinkSync(filePath);
33-
tmpdir.refresh();
34+
test('fs.stat throws ERR_INVALID_ARG_TYPE for invalid signal', () => {
35+
assert.throws(
36+
() => fs.stat(filePath, { signal: 'not-a-signal' }, () => {}),
37+
{ code: 'ERR_INVALID_ARG_TYPE' },
38+
);
3439
});

0 commit comments

Comments
 (0)