Skip to content

Commit face7e9

Browse files
committed
fs: extend signal option to lstat, fstat and promises API
Signed-off-by: Mert Can Altin <mertgold60@gmail.com>
1 parent e4d317e commit face7e9

4 files changed

Lines changed: 157 additions & 16 deletions

File tree

doc/api/fs.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1478,6 +1478,10 @@ link(2) documentation for more detail.
14781478
<!-- YAML
14791479
added: v10.0.0
14801480
changes:
1481+
- version: REPLACEME
1482+
pr-url: https://github.com/nodejs/node/pull/57775
1483+
description: Accepts an additional `signal` option to allow aborting the
1484+
operation.
14811485
- version: v10.5.0
14821486
pr-url: https://github.com/nodejs/node/pull/20220
14831487
description: Accepts an additional `options` object to specify whether
@@ -1488,6 +1492,8 @@ changes:
14881492
* `options` {Object}
14891493
* `bigint` {boolean} Whether the numeric values in the returned
14901494
{fs.Stats} object should be `bigint`. **Default:** `false`.
1495+
* `signal` {AbortSignal} An AbortSignal to cancel the operation.
1496+
**Default:** `undefined`.
14911497
* Returns: {Promise} Fulfills with the {fs.Stats} object for the given
14921498
symbolic link `path`.
14931499
@@ -1982,6 +1988,10 @@ Removes files and directories (modeled on the standard POSIX `rm` utility).
19821988
<!-- YAML
19831989
added: v10.0.0
19841990
changes:
1991+
- version: REPLACEME
1992+
pr-url: https://github.com/nodejs/node/pull/57775
1993+
description: Accepts an additional `signal` option to allow aborting the
1994+
operation.
19851995
- version: v25.7.0
19861996
pr-url: https://github.com/nodejs/node/pull/61178
19871997
description: Accepts a `throwIfNoEntry` option to specify whether
@@ -1999,6 +2009,8 @@ changes:
19992009
* `throwIfNoEntry` {boolean} Whether an exception will be thrown
20002010
if no file system entry exists, rather than returning `undefined`.
20012011
**Default:** `true`.
2012+
* `signal` {AbortSignal} An AbortSignal to cancel the operation.
2013+
**Default:** `undefined`.
20022014
* Returns: {Promise} Fulfills with the {fs.Stats} object for the
20032015
given `path`.
20042016
@@ -3309,6 +3321,10 @@ exception are given to the completion callback.
33093321
<!-- YAML
33103322
added: v0.1.95
33113323
changes:
3324+
- version: REPLACEME
3325+
pr-url: https://github.com/nodejs/node/pull/57775
3326+
description: Accepts an additional `signal` option to allow aborting the
3327+
operation.
33123328
- version: v18.0.0
33133329
pr-url: https://github.com/nodejs/node/pull/41678
33143330
description: Passing an invalid callback to the `callback` argument
@@ -3332,6 +3348,8 @@ changes:
33323348
* `options` {Object}
33333349
* `bigint` {boolean} Whether the numeric values in the returned
33343350
{fs.Stats} object should be `bigint`. **Default:** `false`.
3351+
* `signal` {AbortSignal} An AbortSignal to cancel the operation.
3352+
**Default:** `undefined`.
33353353
* `callback` {Function}
33363354
* `err` {Error}
33373355
* `stats` {fs.Stats}
@@ -3673,6 +3691,10 @@ exception are given to the completion callback.
36733691
<!-- YAML
36743692
added: v0.1.30
36753693
changes:
3694+
- version: REPLACEME
3695+
pr-url: https://github.com/nodejs/node/pull/57775
3696+
description: Accepts an additional `signal` option to allow aborting the
3697+
operation.
36763698
- version: v18.0.0
36773699
pr-url: https://github.com/nodejs/node/pull/41678
36783700
description: Passing an invalid callback to the `callback` argument
@@ -3700,6 +3722,8 @@ changes:
37003722
* `options` {Object}
37013723
* `bigint` {boolean} Whether the numeric values in the returned
37023724
{fs.Stats} object should be `bigint`. **Default:** `false`.
3725+
* `signal` {AbortSignal} An AbortSignal to cancel the operation.
3726+
**Default:** `undefined`.
37033727
* `callback` {Function}
37043728
* `err` {Error}
37053729
* `stats` {fs.Stats}

lib/fs.js

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1584,7 +1584,7 @@ function readdirSync(path, options) {
15841584
* Invokes the callback with the `fs.Stats`
15851585
* for the file descriptor.
15861586
* @param {number} fd
1587-
* @param {{ bigint?: boolean; }} [options]
1587+
* @param {{ bigint?: boolean, signal?: AbortSignal }} [options]
15881588
* @param {(
15891589
* err?: Error,
15901590
* stats?: Stats
@@ -1595,19 +1595,24 @@ function fstat(fd, options = { bigint: false }, callback) {
15951595
if (typeof options === 'function') {
15961596
callback = options;
15971597
options = kEmptyObject;
1598+
} else if (options === null || typeof options !== 'object') {
1599+
options = kEmptyObject;
15981600
}
15991601
callback = makeStatsCallback(callback);
16001602

1603+
if (options.signal !== undefined) validateAbortSignal(options.signal, 'options.signal');
1604+
if (checkAborted(options.signal, callback)) return;
1605+
16011606
const req = new FSReqCallback(options.bigint);
1602-
req.oncomplete = callback;
1607+
bindSignalToReq(req, options.signal, callback);
16031608
binding.fstat(fd, options.bigint, req);
16041609
}
16051610

16061611
/**
16071612
* Retrieves the `fs.Stats` for the symbolic link
16081613
* referred to by the `path`.
16091614
* @param {string | Buffer | URL} path
1610-
* @param {{ bigint?: boolean; }} [options]
1615+
* @param {{ bigint?: boolean, signal?: AbortSignal }} [options]
16111616
* @param {(
16121617
* err?: Error,
16131618
* stats?: Stats
@@ -1618,6 +1623,10 @@ function lstat(path, options = { bigint: false }, callback) {
16181623
if (typeof options === 'function') {
16191624
callback = options;
16201625
options = kEmptyObject;
1626+
} else if (options === null || typeof options !== 'object') {
1627+
options = kEmptyObject;
1628+
} else {
1629+
options = getOptions(options, { bigint: false });
16211630
}
16221631
callback = makeStatsCallback(callback);
16231632
path = getValidatedPath(path);
@@ -1627,8 +1636,11 @@ function lstat(path, options = { bigint: false }, callback) {
16271636
return;
16281637
}
16291638

1639+
if (options.signal !== undefined) validateAbortSignal(options.signal, 'options.signal');
1640+
if (checkAborted(options.signal, callback)) return;
1641+
16301642
const req = new FSReqCallback(options.bigint);
1631-
req.oncomplete = callback;
1643+
bindSignalToReq(req, options.signal, callback);
16321644
binding.lstat(path, options.bigint, req);
16331645
}
16341646

lib/internal/fs/promises.js

Lines changed: 51 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const {
1414
PromiseResolve,
1515
SafeArrayIterator,
1616
SafePromisePrototypeFinally,
17+
SafePromiseRace,
1718
Symbol,
1819
SymbolAsyncDispose,
1920
SymbolAsyncIterator,
@@ -125,6 +126,7 @@ const kLocked = Symbol('kLocked');
125126
const kCloseSync = Symbol('kCloseSync');
126127

127128
const { kUsePromises } = binding;
129+
let kResistStopPropagation;
128130
const { Interface } = require('internal/readline/interface');
129131
const {
130132
kDeserialize, kTransfer, kTransferList, markTransferMode,
@@ -1116,6 +1118,25 @@ function checkAborted(signal) {
11161118
throw new AbortError(undefined, { cause: signal.reason });
11171119
}
11181120

1121+
async function raceWithSignal(opPromise, signal) {
1122+
if (!signal) return opPromise;
1123+
kResistStopPropagation ??= require('internal/event_target').kResistStopPropagation;
1124+
let onAbort;
1125+
const abortPromise = new Promise((_, reject) => {
1126+
onAbort = () => reject(new AbortError(undefined, { cause: signal.reason }));
1127+
signal.addEventListener('abort', onAbort, {
1128+
__proto__: null,
1129+
once: true,
1130+
[kResistStopPropagation]: true,
1131+
});
1132+
});
1133+
try {
1134+
return await SafePromiseRace([opPromise, abortPromise]);
1135+
} finally {
1136+
signal.removeEventListener('abort', onAbort);
1137+
}
1138+
}
1139+
11191140
async function writeFileHandle(filehandle, data, signal, encoding) {
11201141
checkAborted(signal);
11211142
if (isCustomIterable(data)) {
@@ -1654,33 +1675,51 @@ async function symlink(target, path, type) {
16541675
}
16551676

16561677
async function fstat(handle, options = { bigint: false }) {
1657-
const result = await PromisePrototypeThen(
1658-
binding.fstat(handle.fd, options.bigint, kUsePromises),
1659-
undefined,
1660-
handleErrorFromBinding,
1678+
const signal = options?.signal;
1679+
if (signal !== undefined) validateAbortSignal(signal, 'options.signal');
1680+
checkAborted(signal);
1681+
const result = await raceWithSignal(
1682+
PromisePrototypeThen(
1683+
binding.fstat(handle.fd, options.bigint, kUsePromises),
1684+
undefined,
1685+
handleErrorFromBinding,
1686+
),
1687+
signal,
16611688
);
16621689
return getStatsFromBinding(result);
16631690
}
16641691

16651692
async function lstat(path, options = { bigint: false }) {
1693+
const signal = options?.signal;
1694+
if (signal !== undefined) validateAbortSignal(signal, 'options.signal');
1695+
checkAborted(signal);
16661696
path = getValidatedPath(path);
16671697
if (permission.isEnabled() && !permission.has('fs.read', path)) {
16681698
const resource = pathModule.toNamespacedPath(BufferIsBuffer(path) ? BufferToString(path) : path);
16691699
throw new ERR_ACCESS_DENIED('Access to this API has been restricted', 'FileSystemRead', resource);
16701700
}
1671-
const result = await PromisePrototypeThen(
1672-
binding.lstat(path, options.bigint, kUsePromises),
1673-
undefined,
1674-
handleErrorFromBinding,
1701+
const result = await raceWithSignal(
1702+
PromisePrototypeThen(
1703+
binding.lstat(path, options.bigint, kUsePromises),
1704+
undefined,
1705+
handleErrorFromBinding,
1706+
),
1707+
signal,
16751708
);
16761709
return getStatsFromBinding(result);
16771710
}
16781711

16791712
async function stat(path, options = { bigint: false, throwIfNoEntry: true }) {
1680-
const result = await PromisePrototypeThen(
1681-
binding.stat(getValidatedPath(path), options.bigint, kUsePromises, options.throwIfNoEntry),
1682-
undefined,
1683-
handleErrorFromBinding,
1713+
const signal = options?.signal;
1714+
if (signal !== undefined) validateAbortSignal(signal, 'options.signal');
1715+
checkAborted(signal);
1716+
const result = await raceWithSignal(
1717+
PromisePrototypeThen(
1718+
binding.stat(getValidatedPath(path), options.bigint, kUsePromises, options.throwIfNoEntry),
1719+
undefined,
1720+
handleErrorFromBinding,
1721+
),
1722+
signal,
16841723
);
16851724

16861725
// Binding will resolve undefined if UV_ENOENT or UV_ENOTDIR and throwIfNoEntry is false

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

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ require('../common');
44
const test = require('node:test');
55
const assert = require('node:assert');
66
const fs = require('node:fs');
7+
const fsPromises = require('node:fs/promises');
78
const tmpdir = require('../common/tmpdir');
89

910
tmpdir.refresh();
@@ -37,3 +38,68 @@ test('fs.stat throws ERR_INVALID_ARG_TYPE for invalid signal', () => {
3738
{ code: 'ERR_INVALID_ARG_TYPE' },
3839
);
3940
});
41+
42+
test('fs.lstat aborts in-flight when signal aborts after the call', async () => {
43+
const controller = new AbortController();
44+
const { promise, resolve, reject } = Promise.withResolvers();
45+
fs.lstat(filePath, { signal: controller.signal }, (err, stats) => {
46+
if (err) return reject(err);
47+
resolve(stats);
48+
});
49+
controller.abort();
50+
await assert.rejects(promise, { name: 'AbortError' });
51+
});
52+
53+
test('fs.fstat aborts in-flight when signal aborts after the call', async () => {
54+
const fd = fs.openSync(filePath, 'r');
55+
try {
56+
const controller = new AbortController();
57+
const { promise, resolve, reject } = Promise.withResolvers();
58+
fs.fstat(fd, { signal: controller.signal }, (err, stats) => {
59+
if (err) return reject(err);
60+
resolve(stats);
61+
});
62+
controller.abort();
63+
await assert.rejects(promise, { name: 'AbortError' });
64+
} finally {
65+
fs.closeSync(fd);
66+
}
67+
});
68+
69+
test('fsPromises.stat rejects when signal is already aborted', async () => {
70+
const signal = AbortSignal.abort();
71+
await assert.rejects(fsPromises.stat(filePath, { signal }), { name: 'AbortError' });
72+
});
73+
74+
test('fsPromises.stat rejects in-flight when signal aborts after the call', async () => {
75+
const controller = new AbortController();
76+
const p = fsPromises.stat(filePath, { signal: controller.signal });
77+
controller.abort();
78+
await assert.rejects(p, { name: 'AbortError' });
79+
});
80+
81+
test('fsPromises.stat rejects with ERR_INVALID_ARG_TYPE for invalid signal', async () => {
82+
await assert.rejects(
83+
fsPromises.stat(filePath, { signal: 'not-a-signal' }),
84+
{ code: 'ERR_INVALID_ARG_TYPE' },
85+
);
86+
});
87+
88+
test('fsPromises.lstat rejects in-flight when signal aborts after the call', async () => {
89+
const controller = new AbortController();
90+
const p = fsPromises.lstat(filePath, { signal: controller.signal });
91+
controller.abort();
92+
await assert.rejects(p, { name: 'AbortError' });
93+
});
94+
95+
test('filehandle.stat rejects in-flight when signal aborts after the call', async () => {
96+
const fh = await fsPromises.open(filePath, 'r');
97+
try {
98+
const controller = new AbortController();
99+
const p = fh.stat({ signal: controller.signal });
100+
controller.abort();
101+
await assert.rejects(p, { name: 'AbortError' });
102+
} finally {
103+
await fh.close();
104+
}
105+
});

0 commit comments

Comments
 (0)