Commit 7614f1820ed for nodejs
commit 7614f1820ed8744c7e554296eadb0c380fcba121
Author: James M Snell <jasnell@gmail.com>
Date: Sat Oct 3 14:39:48 2026 +0000
fs: lock FileHandle on first read in pull() and pullSync()
pull() and pullSync() locked the handle as soon as they were called,
but only released the lock from inside the iteration. An iterable that
was created but never consumed therefore left the handle locked
forever, so every later pull(), pullSync() and writer() call failed
with ERR_INVALID_STATE. pullSync() also took a reference on the handle
eagerly, and returning an iterator that had not started unlocked the
handle even if another consumer held the lock.
Take the lock (and the reference) when iteration actually starts, as
the documentation already describes ("locked while the iterable is
being consumed"). Iterating after the handle has been closed now fails
with ERR_INVALID_STATE instead of reading from a stale descriptor.
testPullLocking is updated accordingly: a second iterable may be
created while the first is unconsumed, but consuming it while the
first is being consumed still fails.
Assisted-by: OpenCode
Signed-off-by: James M Snell <jasnell@gmail.com>
PR-URL: https://github.com/nodejs/node/pull/66483
Reviewed-By: Trivikram Kamat <trivikr.dev@gmail.com>
diff --git a/lib/internal/fs/promises.js b/lib/internal/fs/promises.js
index 5ab7e1dacdc..c21b1e5740f 100644
--- a/lib/internal/fs/promises.js
+++ b/lib/internal/fs/promises.js
@@ -480,6 +480,18 @@ if (getOptionValue('--experimental-stream-iter')) {
const kNullPrototo = { __proto__: null };
const kDefaultChunkSize = 131072;
const kNone = -1;
+
+ // Called when iteration of a pull()/pullSync() iterable actually starts.
+ function lockForIteration(handle, fd) {
+ if (handle[kFd] === kNone || handle[kFd] !== fd)
+ throw new ERR_INVALID_STATE('The FileHandle is closed');
+ if (handle[kClosePromise])
+ throw new ERR_INVALID_STATE('The FileHandle is closing');
+ if (handle[kLocked])
+ throw new ERR_INVALID_STATE('The FileHandle is locked');
+ handle[kLocked] = true;
+ }
+
/**
* Return the file contents as an AsyncIterable<Uint8Array[]> using the
* new streams pull model. Optional transforms and options (including
@@ -527,11 +539,13 @@ if (getOptionValue('--experimental-stream-iter')) {
validateAbortSignal(signal, 'options.signal');
}
- this[kLocked] = true;
-
const source = {
__proto__: null,
async *[SymbolAsyncIterator]() {
+ // The handle is locked only while the iterable is actually being
+ // consumed. Locking eagerly in pull() would leave the handle locked
+ // forever if the returned iterable were never iterated.
+ lockForIteration(handle, fd);
handle[kRef]();
try {
if (signal) {
@@ -641,10 +655,6 @@ if (getOptionValue('--experimental-stream-iter')) {
validateInteger(readSize, 'options.chunkSize', 1);
}
- this[kLocked] = true;
-
- handle[kRef]();
-
function cleanup() {
handle[kLocked] = false;
handle[kUnref]();
@@ -656,15 +666,24 @@ if (getOptionValue('--experimental-stream-iter')) {
const source = {
__proto__: null,
[SymbolIterator]() {
+ let started = false;
let done = false;
return {
__proto__: null,
next() {
- if (done || remaining === 0) {
- if (!done) {
- done = true;
- cleanup();
- }
+ if (done) {
+ return { done: true, value: undefined };
+ }
+ if (!started) {
+ // Lock lazily, on the first read, so that an iterable that is
+ // never consumed does not leave the handle locked forever.
+ lockForIteration(handle, fd);
+ handle[kRef]();
+ started = true;
+ }
+ if (remaining === 0) {
+ done = true;
+ cleanup();
return { done: true, value: undefined };
}
const toRead = remaining > 0 ?
@@ -692,7 +711,7 @@ if (getOptionValue('--experimental-stream-iter')) {
return() {
if (!done) {
done = true;
- cleanup();
+ if (started) cleanup();
}
return { done: true, value: undefined };
},
diff --git a/test/parallel/test-fs-promises-file-handle-pull.js b/test/parallel/test-fs-promises-file-handle-pull.js
index cdfaf273f93..055a1e39529 100644
--- a/test/parallel/test-fs-promises-file-handle-pull.js
+++ b/test/parallel/test-fs-promises-file-handle-pull.js
@@ -148,17 +148,30 @@ async function testPullLocking() {
const fh = await open(filePath, 'r');
try {
- // First pull locks the handle
+ // The handle is locked once the first iterable starts being consumed.
const readable = fh.pull();
+ const other = fh.pull();
+ const iter = readable[Symbol.asyncIterator]();
+ const first = await iter.next();
+ assert.strictEqual(first.done, false);
- // Second pull while locked should throw
+ // Consuming a second iterable while locked should fail, and so should
+ // creating new consumers.
+ await assert.rejects(
+ other[Symbol.asyncIterator]().next(),
+ { code: 'ERR_INVALID_STATE' },
+ );
assert.throws(
() => fh.pull(),
{ code: 'ERR_INVALID_STATE' },
);
+ assert.throws(
+ () => fh.writer(),
+ { code: 'ERR_INVALID_STATE' },
+ );
- // Consume the first stream to unlock
- await text(readable);
+ // Finish consuming the first stream to unlock
+ while (!(await iter.next()).done);
// Now it should be usable again
const readable2 = fh.pull();
@@ -417,6 +430,36 @@ async function testPullSyncArgumentValidation() {
}
}
+// =============================================================================
+// An iterable that is never consumed must not lock the handle
+// =============================================================================
+
+async function testPullUnconsumedDoesNotLock() {
+ const filePath = path.join(tmpDir, 'pull-unconsumed.txt');
+ fs.writeFileSync(filePath, 'unconsumed');
+
+ const fh = await open(filePath, 'r');
+ try {
+ fh.pull();
+ fh.pull((chunks) => chunks);
+ assert.strictEqual(await text(fh.pull()), 'unconsumed');
+ const w = fh.writer();
+ assert.strictEqual(w.endSync(), 0);
+ } finally {
+ await fh.close();
+ }
+}
+
+async function testPullAfterCloseRejectsOnIteration() {
+ const filePath = path.join(tmpDir, 'pull-closed-before-iter.txt');
+ fs.writeFileSync(filePath, 'data');
+
+ const fh = await open(filePath, 'r');
+ const readable = fh.pull();
+ await fh.close();
+ await assert.rejects(text(readable), { code: 'ERR_INVALID_STATE' });
+}
+
Promise.all([
testBasicPull(),
testPullBinary(),
@@ -437,4 +480,6 @@ Promise.all([
testPullChunkSize(),
testPullChunkSizeSmall(),
testPullSyncArgumentValidation(),
+ testPullUnconsumedDoesNotLock(),
+ testPullAfterCloseRejectsOnIteration(),
]).then(common.mustCall());
diff --git a/test/parallel/test-fs-promises-file-handle-pullsync.js b/test/parallel/test-fs-promises-file-handle-pullsync.js
index 20c42997257..82bb5d82393 100644
--- a/test/parallel/test-fs-promises-file-handle-pullsync.js
+++ b/test/parallel/test-fs-promises-file-handle-pullsync.js
@@ -473,6 +473,31 @@ async function testPullArgumentValidation() {
// Run all tests
// =============================================================================
+// =============================================================================
+// An iterable that is never consumed must not lock the handle
+// =============================================================================
+
+async function testPullSyncUnconsumedDoesNotLock() {
+ const filePath = path.join(tmpDir, 'pullsync-unconsumed.txt');
+ fs.writeFileSync(filePath, 'unconsumed');
+
+ const fh = await open(filePath, 'r');
+ try {
+ fh.pullSync();
+ // Returning an iterator that never started must not unlock or close
+ // the handle on behalf of another consumer.
+ fh.pullSync()[Symbol.iterator]().return();
+ const iter = fh.pullSync()[Symbol.iterator]();
+ assert.strictEqual(iter.next().done, false);
+ assert.throws(() => fh.writer(), { code: 'ERR_INVALID_STATE' });
+ iter.return();
+ const w = fh.writer();
+ assert.strictEqual(w.endSync(), 0);
+ } finally {
+ await fh.close();
+ }
+}
+
Promise.all([
testBasicPullSync(),
testLargeFile(),
@@ -495,4 +520,5 @@ Promise.all([
testPullSyncChunkSize(),
testWriterChunkSize(),
testPullArgumentValidation(),
+ testPullSyncUnconsumedDoesNotLock(),
]).then(common.mustCall());