From 8a749d7e75fed9bcedf3b414d2f68ee337342b6f Mon Sep 17 00:00:00 2001 From: "Kamat, Trivikram" <16024985+trivikr@users.noreply.github.com> Date: Tue, 23 Jun 2026 18:11:51 -0700 Subject: [PATCH 1/2] vfs: read RealFSProvider files from open fd Read RealFileHandle contents through the open file descriptor instead of reopening the original real path. This keeps already-open VFS file descriptors usable after the backing file is renamed. Use positioned reads so readFileSync() and readFile() preserve the handle's current offset. Signed-off-by: Kamat, Trivikram <16024985+trivikr@users.noreply.github.com> Assisted-by: openai:gpt-5.5 --- lib/internal/vfs/providers/real.js | 47 ++++++++++++++++++- test/parallel/test-vfs-fs-readFileSync.js | 29 ++++++++++++ .../parallel/test-vfs-real-provider-handle.js | 17 +++++++ 3 files changed, 91 insertions(+), 2 deletions(-) diff --git a/lib/internal/vfs/providers/real.js b/lib/internal/vfs/providers/real.js index fbcff25e39ccfe..7637f1ef7a97b6 100644 --- a/lib/internal/vfs/providers/real.js +++ b/lib/internal/vfs/providers/real.js @@ -5,6 +5,7 @@ const { StringPrototypeStartsWith, } = primordials; +const { Buffer } = require('buffer'); const fs = require('fs'); const path = require('path'); const { VirtualProvider } = require('internal/vfs/provider'); @@ -34,6 +35,19 @@ class RealFileHandle extends VirtualFileHandle { } } + #readFileBuffer(size) { + return Buffer.allocUnsafe(size || 8192); + } + + #readFileResult(buffer, bytesRead, options) { + buffer = buffer.subarray(0, bytesRead); + const encoding = typeof options === 'string' ? options : options?.encoding; + if (encoding && encoding !== 'buffer') { + buffer = buffer.toString(encoding); + } + return buffer; + } + /** * @param {string} path The VFS path * @param {string} flags The open flags @@ -79,12 +93,41 @@ class RealFileHandle extends VirtualFileHandle { readFileSync(options) { this.#checkClosed('read'); - return fs.readFileSync(this.#realPath, options); + const size = fs.fstatSync(this.#fd).size; + const buffer = this.#readFileBuffer(size); + let bytesRead = 0; + while (bytesRead < buffer.byteLength) { + const read = fs.readSync( + this.#fd, + buffer, + bytesRead, + buffer.byteLength - bytesRead, + bytesRead, + ); + if (read === 0) break; + bytesRead += read; + } + + return this.#readFileResult(buffer, bytesRead, options); } async readFile(options) { this.#checkClosed('read'); - return fs.promises.readFile(this.#realPath, options); + const size = (await this.stat()).size; + const buffer = this.#readFileBuffer(size); + let bytesRead = 0; + while (bytesRead < buffer.byteLength) { + const { bytesRead: read } = await this.read( + buffer, + bytesRead, + buffer.byteLength - bytesRead, + bytesRead, + ); + if (read === 0) break; + bytesRead += read; + } + + return this.#readFileResult(buffer, bytesRead, options); } writeFileSync(data, options) { diff --git a/test/parallel/test-vfs-fs-readFileSync.js b/test/parallel/test-vfs-fs-readFileSync.js index 96e39892e66a9f..1ca906b2a0577c 100644 --- a/test/parallel/test-vfs-fs-readFileSync.js +++ b/test/parallel/test-vfs-fs-readFileSync.js @@ -43,3 +43,32 @@ assert.strictEqual( } myVfs.unmount(); + +// readFileSync via a RealFSProvider fd remains usable after the backing path +// is renamed. +{ + const root = path.join('/tmp', 'vfs-real-readFileSync-' + process.pid); + const realMountPoint = path.join('/tmp', 'vfs-real-readFileSync-mount-' + process.pid); + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(realMountPoint, { recursive: true, force: true }); + fs.mkdirSync(root, { recursive: true }); + fs.mkdirSync(realMountPoint, { recursive: true }); + + const realVfs = vfs + .create(new vfs.RealFSProvider(root), { emitExperimentalWarning: false }) + .mount(realMountPoint); + try { + fs.writeFileSync(path.join(root, 'a.txt'), 'still readable'); + const fd = fs.openSync(path.join(realMountPoint, 'a.txt'), 'r'); + try { + fs.renameSync(path.join(root, 'a.txt'), path.join(root, 'b.txt')); + assert.strictEqual(fs.readFileSync(fd, 'utf8'), 'still readable'); + } finally { + fs.closeSync(fd); + } + } finally { + realVfs.unmount(); + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(realMountPoint, { recursive: true, force: true }); + } +} diff --git a/test/parallel/test-vfs-real-provider-handle.js b/test/parallel/test-vfs-real-provider-handle.js index 5246e28e3206c5..7739548002da7a 100644 --- a/test/parallel/test-vfs-real-provider-handle.js +++ b/test/parallel/test-vfs-real-provider-handle.js @@ -75,6 +75,23 @@ const myVfs = vfs.create(new vfs.RealFSProvider(root)); await handle.close(); } + // ===== readFile through an open real fd survives backing path rename ===== + { + fs.writeFileSync(path.join(root, 'rename-read.txt'), 'still readable'); + const syncHandle = await myVfs.provider.open('/rename-read.txt', 'r'); + const asyncHandle = await myVfs.provider.open('/rename-read.txt', 'r'); + fs.renameSync(path.join(root, 'rename-read.txt'), + path.join(root, 'rename-read-renamed.txt')); + try { + assert.strictEqual(syncHandle.readFileSync('utf8'), 'still readable'); + assert.strictEqual(await asyncHandle.readFile('utf8'), 'still readable'); + } finally { + await syncHandle.close(); + await asyncHandle.close(); + fs.unlinkSync(path.join(root, 'rename-read-renamed.txt')); + } + } + // ===== EBADF after close ===== { await myVfs.promises.writeFile('/h.txt', 'hello'); From 2cef1497b73cce56c7cb3229cb2973324ab2ba19 Mon Sep 17 00:00:00 2001 From: "Kamat, Trivikram" <16024985+trivikr@users.noreply.github.com> Date: Thu, 25 Jun 2026 13:46:31 -0700 Subject: [PATCH 2/2] fixup! vfs: read full zero-size real file handles --- lib/internal/vfs/providers/real.js | 52 ++++++++++++++++--- .../parallel/test-vfs-real-provider-handle.js | 32 ++++++++++++ 2 files changed, 78 insertions(+), 6 deletions(-) diff --git a/lib/internal/vfs/providers/real.js b/lib/internal/vfs/providers/real.js index 7637f1ef7a97b6..085485ca8a1a97 100644 --- a/lib/internal/vfs/providers/real.js +++ b/lib/internal/vfs/providers/real.js @@ -1,6 +1,7 @@ 'use strict'; const { + ArrayPrototypePush, Promise, StringPrototypeStartsWith, } = primordials; @@ -18,6 +19,8 @@ const { createENOENT, } = require('internal/vfs/errors'); +const kReadFileUnknownBufferLength = 8192; + /** * A file handle that wraps a real file descriptor. */ @@ -35,10 +38,6 @@ class RealFileHandle extends VirtualFileHandle { } } - #readFileBuffer(size) { - return Buffer.allocUnsafe(size || 8192); - } - #readFileResult(buffer, bytesRead, options) { buffer = buffer.subarray(0, bytesRead); const encoding = typeof options === 'string' ? options : options?.encoding; @@ -48,6 +47,11 @@ class RealFileHandle extends VirtualFileHandle { return buffer; } + #readFileUnknownSizeResult(buffers, totalRead, options) { + return this.#readFileResult( + Buffer.concat(buffers, totalRead), totalRead, options); + } + /** * @param {string} path The VFS path * @param {string} flags The open flags @@ -94,7 +98,23 @@ class RealFileHandle extends VirtualFileHandle { readFileSync(options) { this.#checkClosed('read'); const size = fs.fstatSync(this.#fd).size; - const buffer = this.#readFileBuffer(size); + if (size === 0) { + const buffers = []; + let totalRead = 0; + + while (true) { + const buffer = Buffer.allocUnsafe(kReadFileUnknownBufferLength); + const read = fs.readSync( + this.#fd, buffer, 0, buffer.byteLength, totalRead); + if (read === 0) break; + ArrayPrototypePush(buffers, buffer.subarray(0, read)); + totalRead += read; + } + + return this.#readFileUnknownSizeResult(buffers, totalRead, options); + } + + const buffer = Buffer.allocUnsafe(size); let bytesRead = 0; while (bytesRead < buffer.byteLength) { const read = fs.readSync( @@ -114,7 +134,27 @@ class RealFileHandle extends VirtualFileHandle { async readFile(options) { this.#checkClosed('read'); const size = (await this.stat()).size; - const buffer = this.#readFileBuffer(size); + if (size === 0) { + const buffers = []; + let totalRead = 0; + + while (true) { + const buffer = Buffer.allocUnsafe(kReadFileUnknownBufferLength); + const { bytesRead: read } = await this.read( + buffer, + 0, + buffer.byteLength, + totalRead, + ); + if (read === 0) break; + ArrayPrototypePush(buffers, buffer.subarray(0, read)); + totalRead += read; + } + + return this.#readFileUnknownSizeResult(buffers, totalRead, options); + } + + const buffer = Buffer.allocUnsafe(size); let bytesRead = 0; while (bytesRead < buffer.byteLength) { const { bytesRead: read } = await this.read( diff --git a/test/parallel/test-vfs-real-provider-handle.js b/test/parallel/test-vfs-real-provider-handle.js index 7739548002da7a..50d31470b8644f 100644 --- a/test/parallel/test-vfs-real-provider-handle.js +++ b/test/parallel/test-vfs-real-provider-handle.js @@ -92,6 +92,38 @@ const myVfs = vfs.create(new vfs.RealFSProvider(root)); } } + // ===== readFile reads past the fallback chunk when fstat reports size 0 ===== + { + const content = 'a'.repeat(8192) + 'trailing data'; + fs.writeFileSync(path.join(root, 'zero-stat.txt'), content); + const syncHandle = await myVfs.provider.open('/zero-stat.txt', 'r'); + const asyncHandle = await myVfs.provider.open('/zero-stat.txt', 'r'); + const originalFstatSync = fs.fstatSync; + const originalFstat = fs.fstat; + + fs.fstatSync = common.mustCall(function fstatSync(...args) { + const stats = originalFstatSync.apply(this, args); + stats.size = 0; + return stats; + }); + fs.fstat = common.mustCall(function fstat(fd, options, callback) { + return originalFstat.call(this, fd, options, (err, stats) => { + if (stats) stats.size = 0; + callback(err, stats); + }); + }); + + try { + assert.strictEqual(syncHandle.readFileSync('utf8'), content); + assert.strictEqual(await asyncHandle.readFile('utf8'), content); + } finally { + fs.fstatSync = originalFstatSync; + fs.fstat = originalFstat; + await syncHandle.close(); + await asyncHandle.close(); + } + } + // ===== EBADF after close ===== { await myVfs.promises.writeFile('/h.txt', 'hello');