From e1adb4c782da463f0c725bb198098c98443e2786 Mon Sep 17 00:00:00 2001 From: Trivikram Kamat <16024985+trivikr@users.noreply.github.com> Date: Sun, 27 Sep 2026 11:03:06 -0700 Subject: [PATCH] vfs: keep open memory handles in sync with file A MemoryFileHandle snapshotted the file's buffer at open and never checked the entry again. After the file changed through the provider or another handle, an open handle served stale reads and fstat sizes, and its writes went into the old copy, undoing other handles' changes. Record the entry content each handle last saw or wrote, and adopt the entry's content whenever another handle has replaced it, so an open descriptor is a live view of the file as on a real filesystem. Signed-off-by: Trivikram Kamat <16024985+trivikr@users.noreply.github.com> Assisted-by: claude:opus-5.5 --- lib/internal/vfs/file_handle.js | 22 +++++ .../test-vfs-memory-handle-live-view.js | 94 +++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 test/parallel/test-vfs-memory-handle-live-view.js diff --git a/lib/internal/vfs/file_handle.js b/lib/internal/vfs/file_handle.js index 4d28abde535e..adf6332941ec 100644 --- a/lib/internal/vfs/file_handle.js +++ b/lib/internal/vfs/file_handle.js @@ -429,6 +429,7 @@ class MemoryFileHandle extends VirtualFileHandle { #size; #entry; #getStats; + #published; // The entry content this handle last saw or wrote. #checkClosed(syscall) { if (this.closed) { @@ -450,6 +451,7 @@ class MemoryFileHandle extends VirtualFileHandle { this.#size = content.length; this.#entry = entry; this.#getStats = getStats; + this.#published = content; // O_TRUNC empties the file at open time. O_APPEND does not move the // read offset: it only forces writes to the end, so the position stays @@ -459,10 +461,24 @@ class MemoryFileHandle extends VirtualFileHandle { this.#size = 0; if (entry) { entry.content = this.#content; + this.#published = this.#content; } } } + /** + * Adopts the entry's content if another handle replaced it, since an + * open descriptor is a live view of the file, not a snapshot. + */ + #sync() { + const entry = this.#entry; + if (entry && entry.content !== this.#published) { + this.#content = entry.content; + this.#size = entry.content.length; + this.#published = entry.content; + } + } + /** * Throws EBADF if the handle was not opened for writing. */ @@ -499,6 +515,7 @@ class MemoryFileHandle extends VirtualFileHandle { if (this.#entry?.isDynamic && this.#entry.isDynamic()) { return this.#entry.getContentSync(); } + this.#sync(); return this.#content.subarray(0, this.#size); } @@ -572,6 +589,7 @@ class MemoryFileHandle extends VirtualFileHandle { writeSync(buffer, offset, length, position) { this.#checkClosed('write'); this.#checkWritable(); + this.#sync(); // In append mode, always write at the end const useCurrentPosition = isCurrentPosition(position); @@ -603,6 +621,7 @@ class MemoryFileHandle extends VirtualFileHandle { this.#entry.content = this.#content.subarray(0, this.#size); this.#entry.mtime = now; this.#entry.ctime = now; + this.#published = this.#entry.content; } // Update position if not using explicit position @@ -693,6 +712,7 @@ class MemoryFileHandle extends VirtualFileHandle { statSync(options) { this.#checkClosed('fstat'); if (this.#getStats) { + this.#sync(); return this.#getStats(this.#size); } throw new ERR_INVALID_STATE('stats not available'); @@ -741,6 +761,7 @@ class MemoryFileHandle extends VirtualFileHandle { truncateSync(len = 0) { this.#checkClosed('ftruncate'); this.#checkWritable(); + this.#sync(); if (len < this.#size) { // Zero out truncated region to avoid stale data @@ -764,6 +785,7 @@ class MemoryFileHandle extends VirtualFileHandle { this.#entry.content = this.#content.subarray(0, this.#size); this.#entry.mtime = now; this.#entry.ctime = now; + this.#published = this.#entry.content; } } diff --git a/test/parallel/test-vfs-memory-handle-live-view.js b/test/parallel/test-vfs-memory-handle-live-view.js new file mode 100644 index 000000000000..58a9ce321102 --- /dev/null +++ b/test/parallel/test-vfs-memory-handle-live-view.js @@ -0,0 +1,94 @@ +// Flags: --experimental-vfs +'use strict'; +// MemoryProvider: an open handle is a live view of the file, not a snapshot +// taken at open time. Writes made through the provider or through another +// handle must be visible to handles that are already open, as they are on a +// real filesystem. Refs: https://github.com/nodejs/node/issues/66355 +const common = require('../common'); +const assert = require('assert'); +const { create } = require('node:vfs'); + +// Content replaced through the provider is seen by an open read handle. +{ + const { provider: p } = create(); + p.writeFileSync('/f', 'AAAA'); + const h = p.openSync('/f', 'r'); + p.writeFileSync('/f', '0123456789'); + + const b = Buffer.alloc(10); + assert.strictEqual(h.readSync(b, 0, 10, 0), 10); + assert.strictEqual(b.toString(), '0123456789'); + assert.strictEqual(h.readFileSync('utf8'), '0123456789'); + assert.strictEqual(h.statSync().size, 10); + assert.strictEqual(p.statSync('/f').size, 10); + h.readFile('utf8').then(common.mustCall((s) => { + assert.strictEqual(s, '0123456789'); + h.closeSync(); + })); +} + +// A shrinking rewrite is seen too: reads stop at the new end. +{ + const { provider: p } = create(); + p.writeFileSync('/f', '0123456789'); + const h = p.openSync('/f', 'r'); + p.writeFileSync('/f', 'AB'); + + const b = Buffer.alloc(10); + assert.strictEqual(h.readSync(b, 0, 10, 0), 2); + assert.strictEqual(b.subarray(0, 2).toString(), 'AB'); + assert.strictEqual(h.readFileSync('utf8'), 'AB'); + assert.strictEqual(h.statSync().size, 2); + h.closeSync(); +} + +// Two writable handles on the same file do not clobber each other. +{ + const { provider: p } = create(); + p.writeFileSync('/f', 'AAAA'); + const h1 = p.openSync('/f', 'r+'); + const h2 = p.openSync('/f', 'r+'); + + h1.writeSync(Buffer.from('XY'), 0, 2, 0); + h2.writeSync(Buffer.from('Z'), 0, 1, 3); + assert.strictEqual(p.readFileSync('/f', 'utf8'), 'XYAZ'); + assert.strictEqual(h1.readFileSync('utf8'), 'XYAZ'); + + // Growing through one handle is visible to the other. + h1.writeSync(Buffer.from('0123456789'), 0, 10, 4); + assert.strictEqual(h2.statSync().size, 14); + assert.strictEqual(h2.readFileSync('utf8'), 'XYAZ0123456789'); + + // Truncating through one handle is visible to the other. + h2.truncateSync(3); + assert.strictEqual(h1.statSync().size, 3); + assert.strictEqual(h1.readFileSync('utf8'), 'XYA'); + + h1.closeSync(); + h2.closeSync(); +} + +// A write through a handle opened before the file was replaced lands in the +// current content instead of resurrecting the old bytes. +{ + const { provider: p } = create(); + p.writeFileSync('/f', 'AAAA'); + const h = p.openSync('/f', 'r+'); + p.writeFileSync('/f', 'BB'); + + h.writeSync(Buffer.from('C'), 0, 1, 3); + assert.deepStrictEqual(p.readFileSync('/f'), Buffer.from('BB\0C', 'latin1')); + h.closeSync(); +} + +// An append handle opened earlier appends after the current end. +{ + const { provider: p } = create(); + p.writeFileSync('/f', 'AAAA'); + const h = p.openSync('/f', 'a'); + p.writeFileSync('/f', 'B'); + + h.writeSync(Buffer.from('C'), 0, 1, null); + assert.strictEqual(p.readFileSync('/f', 'utf8'), 'BC'); + h.closeSync(); +}