Repository navigation
Virtual File System Follow Ups #62328
Description
Activity
I wonder if we should use Matteo's fork to send PRs, I also have a bunch of "refactor requests" in mind that I don't feel like doing on the one PR, given the thread is already too long to follow. That might help grow the confidence on that PR before actually merging it
Reacted by Matteo Collina and Gürgün DayıoğluI'd say that's up to @mcollina ... personally I'd be just as happy for it to land here and have the follow-up PRs opened and discussed here.
- addedvfsIssues and PRs related to the virtual filesystem subsystem.Issues and PRs related to the virtual filesystem subsystem.
on Mar 18, 2026 @aduh95 I'm applying some refactoring myself as people point things down. Just drop a long comment there, here or in another issue and I'll do them.
- added a commit that references this issue
on Mar 20, 2026 The thing is, the GitHub UI is basically useless when it comes to reviewing those big PRs, so I've always given up on reviewing the content of the
lib/internal/vfs/**files – also the stakes are lower, it can only affect itself, so I feel it's not worth the noise (trust me I can be very noisy)Reacted by Matteo Collina@jasnell here is a full follow up (AI-gen):
Status update on follow-ups from #62328
Here's a summary of what has been addressed so far and what remains open.
Addressed
# Item Status 1.1 VFS bypasses permission model Addressed — registerVFS()throws whenpermission.isEnabled()unless--allow-fs-vfsruntime flag is set (added tosrc/node_options.ccin the permission namespace)1.3 Overlapping mounts undefined behavior Addressed — registerVFS()detects prefix-overlapping mount points (using/-terminated prefix comparison to avoid false positives like/virtualvs/virtual2) and rejects withERR_INVALID_STATE1.4 RealFSProvider symlink jail escape Addressed — symlinkSyncvalidates target stays within root,readlinkSynctranslates paths to VFS-relative,realpathSyncthrows EACCES on escape2.1 VirtualFileHandle missing methods Partially addressed — added chmod/chown/utimes/datasync/sync(no-op stubs),readv/writev/appendFile,[Symbol.asyncDispose](),readLines/readableWebStream/createReadStream/createWriteStream(throwERR_METHOD_NOT_IMPLEMENTED). Still missing:fdproperty andEventEmitterextension (no'close'event)2.2 fsPromises.open()not interceptedAddressed — separate promisesOpenhandler insetup.jsreturns VirtualFileHandle forfsPromises.open(), whileopenhandler returns numeric FD for callbackfs.open()2.3 Streams missing properties Addressed — added bytesRead/pendingon VirtualReadStream,bytesWritten/pendingon VirtualWriteStream2.4 VirtualDir missing disposal Addressed — added [Symbol.asyncDispose](),[Symbol.dispose](),entries()auto-close via try/finally2.5 Stats ino/dev always 0 Addressed — unique incrementing ino, distinctivedev = 4085(0xFF9) applied to all three stats factory functions2.7 Watcher API mismatches Partially addressed — VFSStatWatcher.addListener/removeListenerfixed to(event, listener)signature. Still missing:VFSWatcherdoes not emit'error'events from catch blocks3.1 Streams stall on destroyed/null fd Addressed — _write()callscallback(createEBADF('write')),_read()callsthis.destroy(createEBADF('read'))3.2 #checkClosed()always reports 'read'Addressed — parameterized with syscallargument, all call sites pass correct syscall name, both base and subclass#checkClosed(syscall)pass it tocreateEBADF(syscall)3.3 writeFileSyncmisses 'ax'/'ax+'Addressed — uses #isAppend()instead of manual flag check3.4 Dead ternary in #hookProcessCwdAddressed — simplified to single resolvePath(directory)3.5 SEAProvider statSync reads full asset Addressed — asset sizes cached in _assetSizesmap on first access3.6 SEAProvider recursive readdir Addressed — #readdirRecursive()walks subdirectories3.7 SEAProvider numeric flags Addressed — openSync()handles numeric0(O_RDONLY) inline3.8 Shared mutable statsArray Addressed — safety comment added explaining synchronous-copy invariant 5.1 readFile handler still sync Partially addressed — readFilehandler insetup.jsconverted to async (vfs.promises.readFile),lib/fs.jsusesvfsResult()to route promise to callback. Other callback handlers still use sync+nextTick6.2 Cross-VFS operations Addressed — checkSameVFS()helper validatesrename/copyFile/linksrc and dest resolve to same VFS; throws EXDEV otherwise6.3 Package.json cache not invalidated on unmount Addressed — deregisterVFS()callsclearPackageJSONCache()exported frompackage_json_reader.js7.1 restoreAll()error handlingAddressed — each restore()wrapped in try/catch, AggregateError thrown after all mocks restored7.2 MockFSContext.vfsexposes full instanceAddressed — changed to #vfsprivate field with read-only getter7.3 Platform-specific root check Addressed — uses path.parse(parentDir).root !== parentDirinstead of!== '/'8.1 MemoryProvider stat size 0 for dynamic files Addressed — #createStatscallsentry.getContentSync().lengthwhenentry.isDynamic()8.2 MemoryProvider symlinkSync auto-creates dirs Addressed — changed to #ensureParent(normalized, false, 'symlink')consistent with other operations8.3 MemoryProvider recursive readdir doesn't follow symlinks Addressed — #readdirRecursiveresolves symlinks before checkingisDirectory()8.5 RealFSProvider no watch support Addressed — watch()/watchFile()/unwatchFile()implemented with path translation,supportsWatch = true10.1 Watcher unbounded memory growth Addressed — kMaxPendingEvents = 1024cap on#pendingEvents, oldest events dropped when full10.2 Redundant #destroyedin streamsAddressed — removed, uses inherited this.destroyed10.3 SEAProvider primordials Addressed — uses StringPrototypeReplaceAll,ArrayFrom,StringPrototypeStartsWithfrom primordialsTests added
test-vfs-overlapping-mounts.js— overlapping mount rejection, including non-overlapping prefix and root cases (1.3)test-vfs-promises-open.js—fsPromises.open()interception (2.2)test-vfs-stream-properties.js—bytesRead/bytesWritten/pending(2.3)test-vfs-dir-disposal.js—Symbol.asyncDispose/Symbol.dispose(2.4)test-vfs-stats-ino-dev.js— unique ino, distinctive dev (2.5)test-vfs-append-write.js— append mode correctness (3.3)test-vfs-cross-device.js— EXDEV for cross-VFS rename/copyFile/link (6.2)test-vfs-readdir-symlink-recursive.js— recursive readdir follows symlinks (8.3)test-vfs-package-json-cache.js— cache cleared on unmount (6.3)test-vfs-readfile-async.js— readFile uses async handler (5.1)
Not addressed (requires further work)
# Item Notes 1.2 Any loaded code can call mount()Design/policy discussion — needs --experimental-vfsflag or security event2.1 (partial) fdproperty, EventEmitter extensionVirtualFileHandle doesn't expose fdnumeric property or emit'close'event2.3 (partial) VirtualWriteStream close(callback)Missing explicit close(callback)method2.7 (partial) VFSWatcher error events VFSWatcherdoes not emit'error'events from catch blocks in#getStats()/#getStatsFor()4.1 Case-insensitive path comparison (Windows) Requires Windows-specific path normalization 4.2 UNC paths unsupported Windows-specific 4.3 Virtual CWD hardcoded /separatorWindows-specific 4.4 findVFSPackageJSON mixed separator Note only 5.1 (partial) Other callback APIs still sync Only readFileconverted; broader pattern remains5.2 existsSyncin async code pathsfindVFSWith()still usesexistsSyncin paths used by async handlers5.3 Linear scan of activeVFSList Note only, unlikely to matter in practice 6.1 Maintenance burden of 164+ interception points Design discussion 6.4 C++ format coupling in package.json serialization Note only 6.5 legacyMainResolve hardcoded extensions Note only 8.4 RealFSProvider path traversal via real-fs symlinks #resolvePathvalidates logical path but doesn't resolve real-fs symlinks before checking9.1 Windows tests minimal Blocked on Windows path handling (4.x items) 9.4 Permission model interaction untested Needs subprocess test with --permissionflag10.4 VirtualFileHandle #checkClosedduplicationNote only — inherent to JS private fields 10.5 file_system.js and setup.js are very large Note only 10.6 VirtualFD range starts at 10,000 Note only Reacted by James M Snell, Gürgün Dayıoğlu and Stephen Belangerfyi, i'm not really against or for this virtual fs (but it's going to sound like i'm against it - or maybe i'm slightly against it).
But i just want to raise something that hasn't been addressed in this discussion: I don't think the benefits here justify what this adds to Node.js core.
The reaction to this PR itself is a signal worth paying attention to: roughly 33% downvotes on a Node.js core feature PR is unusually high. For most uncontroversial additions the opposition is in the low single digits. That level of disagreement from the community suggests the concerns here aren't just noise.
The use cases already have solutions
For testing/mocking: A temporary directory in
os.tmpdir()with cleanup afterwards is good enough for most cases. It's boring, it works everywhere, zero new APIs to learn.For in-memory use cases:
BlobandFilealready exist and cover the simple cases well. They're standardized and work in every runtime.For anything more serious: Every major OS has built-in support for mounting virtual and RAM-backed filesystems. You get a real directory that every process sees — native addons, child processes, file descriptors, the permission model, everything just works. Zero lines added to Node.js core.
For SEA assets specifically: yes, an in-process solution makes sense here since there's no real path to mount. That's the one genuinely novel use case. But that's a much smaller, more focused feature than what this PR is.
The cost to core is too high
- 21,645 lines added to Node.js core
- 164+ manual interception hooks in
lib/fs.jsandlib/internal/fs/promises.jsthat every futurefsAPI addition must remember to include - The bugs catalogued in this very issue exist because that surface area is nearly impossible to keep complete and correct — it will never be fully done
- Native addons and child processes still bypass VFS entirely, so it doesn't even fully solve the problem it sets out to solve
The fragmentation problem
This is my bigger concern. If Node.js ships its own in-process VFS, Bun and Deno will either ignore it or ship their own incompatible version. We end up with yet another Node-specific API that library authors have to either avoid or special-case per runtime.
We already have this problem all over the place. Every time Node.js invents a non-web-standard API instead of aligning with or pushing for a web standard, it makes cross-runtime code harder.
Blob,File,ReadableStream, OPFS — these exist so that code dealing with files and data doesn't have to care what runtime it's on. Anode:vfsmodule goes in exactly the opposite direction.I understand OPFS was considered and rejected — but the answer to "OPFS doesn't fit our needs" should be "let's work with the standards bodies to extend it", not "let's build our own thing in core."
What I'd suggest instead
- If ppl need fast / non disk IO, then FUSE or ram disk might be the solution instead.
- Drop the general-purpose in-process VFS from core
- embrace OPFS & filesystem access that can make things work the same way across NodeJS and Browsers
The Node.js ecosystem is already massive. Adding permanent, hard-to-remove APIs to core should clear a much higher bar than this currently does.
as for my understanding ppl seem to like how Bun handles file IO buy using Blob/Files more than using node:fs when it comes to reading files, i think it's more supperior to do something like
await Bun.file(path).arrayBuffer()then any of the alternative solution that exist in node or deno. i certainly for one prefer to use theopenAsBlobthen using any of the other method that exist on node:fs to handle reading files from the disk. so why should i have to use a virtual fs when i could just simply create virtual files usingnew Blob() || new File()?21,645 lines added to Node.js core
Folks keep pointing out the size of the PR. Let's break it down.
Category +lines -lines net files % of additions Impl 9,162 75 9,087 38 42.3% Test 11,202 0 11,202 82 51.8% Doc 1,281 0 1,281 8 5.9% Total 21,645 75 21,570 128 100.0% A big part of the reason the PR is so large is the test coverage and doc requirements -- a PR won't pass CI without a minimal amount of test coverage.
By comparison, the existing
fsimplementation breaks down as:Category Lines Files JS impl 9,824 13 C++ impl 6,982 12 Tests 25,155 359 Docs 9,075 1 Benchmarks 2,528 46 Total 53,564 431 The
cryptomodule...Category Lines Files JS impl 11,003 28 C++ impl 21,827 60 Tests 16,043 128 Docs 6,939 1 Benchmarks 1,394 21 Total 57,206 238 The point here is that "the cost to core is too high" really isn't true. The cost is far less than other existing core modules. The only reason it looks big is that it's coming in at once rather than incrementally over a longer period of time.
The bugs catalogued in this very issue exist because...
Bugs exist in every subsystem. I wouldn't even consider these to be bugs at all. It's a work in progress and these are outstanding todos.
Native addons and child processes still bypass VFS entirely
And that's fine. Native addons have always been special and can bypass pretty much everything. If someone needed to, they could use the permission system to disable native addons.
If Node.js ships its own in-process VFS, Bun and Deno will either ignore it or ship their own incompatible version
Or they'll duplicate it as part of their respective Node.js compatibility efforts (which is likely what we would do in cloudflare workers... at least to the extent there is overlap -- we don't have SEA or the same kind of module loader hooks). What other runtimes may or may not do relative to Node.js specific APIs really isn't much of a concern for us here. If this were implementing to a standard spec that would be a different matter.
Reacted by Wouter KlijnWorker threads don't inherit VFS mounts (SEA use case)
While integrating
@platformatic/vfs(the userland polyfill of this PR) as the VFS layer for @yao-pkg/pkg SEA mode, we discovered that worker threads don't inherit VFS hooks from the main thread. This affects real-world applications that use libraries likepino(viathread-stream), which spawn workers to handle log transport.The problem
When a SEA binary sets up VFS in the main thread and then a library spawns a
Workerwith a path inside the VFS mount (e.g.,/snapshot/node_modules/thread-stream/lib/worker.js), the worker fails with:Error: Cannot find module '/snapshot/node_modules/thread-stream/lib/worker.js'This happens because:
- VFS hooks (both
setVfsHandlersat the C++ level andModule.registerHooksat the JS level) are per-Environment (per-isolate) - Worker threads get a fresh Environment with no VFS mounted
node:seaassets ARE process-wide (accessible from any thread), but nothing re-initializes the VFS in the worker
Current workaround
We had to monkey-patch the
Workerconstructor to intercept/snapshot/...paths. For each intercepted worker:- Read the worker file from VFS in the main thread
- Prepend a bundled VFS bootstrap (same
@platformatic/vfscode, bundled separately by esbuild) - Pass the combined code via
new Worker(code, { eval: true })
This works but is fragile — it requires bundling a separate copy of the VFS module hooks for workers, and
eval: truehas limitations (no real file URL,import()complications).Suggestion for
node:vfsSince
node:seaassets are process-wide, the SEA VFS initialization (lib/internal/vfs/sea.js) could be extended to automatically run in worker threads too. Something like:- During SEA preparation, store the VFS config (mount point, provider type) in the SEA blob metadata
- In
prepareWorkerThreadExecution(or equivalent), check if running as SEA with VFS enabled - If so, call
initSeaVfs()with the stored config to set up the same VFS mount in the worker
This would make SEA + VFS + worker threads work transparently without any userland patching.
Alternatively, a more general mechanism could allow VFS mounts to be "inherited" by workers, perhaps via a flag on the
Workerconstructor:new Worker('./worker.js', { inheritVFS: true })
Context
- The test
test-vfs-chdir-worker.jsin this PR explicitly asserts that VFS is NOT shared with workers — so this is by-design, not a bug - But for SEA use cases, this makes worker threads essentially broken unless the consumer implements the workaround above
- Libraries like pino, workerpool, jest-worker, and many others use worker threads
- The traditional
pkgapproach (C++ libuv-level patching) didn't have this issue since the patches were process-wide
Related: yao-pkg/pkg#229, platformatic/vfs#9, platformatic/vfs#10
Reacted by Matteo Collina- VFS hooks (both
About the number one (permission model)
1.1 VFS interception bypasses the Node.js permission model — CRITICAL
Initially, I thought
vfsoperates only on in-memmory temporary folder/files, but while seeing Matteo's presentation of VFS feature on Collaborator Summit, I saw you could.mount()the vfs into the file system and make real calls touv_fs, bypassing the permission model guarantees (on the fs module) entirely. The checks withpermission.has()might not be sufficient if they are not placed in the same manner we donode_file.cc. Ideally, we could think about a way thevfsdoes reuse the fs API, but pass a flag if that's avfsor not, so we won't need to change or reallocate permission model checks.UPDATE: Alright, so after talking with Matteo to understand more about the implementation, there's no
uv_fscall with thevfsimplementation, the only concern is that it will mocknode:fsoperations to the vfs, so operations that were supposed to fail (write access to unnauthorized paths), might succeedd (although no real write access would happen).Reacted by Matteo Collinagithub-actions commented
on Jul 20, 2026 on Jul 20, 2026 – with GitHub ActionsContributorMore actionsThis issue has been marked as stale due to 90 days of inactivity.
It will be automatically closed in 30 days if no further activity occurs. If this is still relevant, please leave a comment or update it to keep it open.- addedstaleIssues and PRs marked stale due to inactivity and scheduled for automatic closure.Issues and PRs marked stale due to inactivity and scheduled for automatic closure.
on Jul 20, 2026
/cc @mcollina
With The Virtual File System PR getting close to landing, there are a number of areas / issues to follow-up on. Flagging here in a new issue to prevent overloading the PR discussion.
Code Review: PR #61478 — Virtual File System for Node.js
1. Security & Permission Model
We need to decide if VFS should participate in the permissions model and how.
1.1 VFS interception bypasses the Node.js permission model — CRITICAL
Disposition: follow-up
In
lib/fs.js, VFS interception happens before the permission modelcheck in every intercepted function. Pattern (repeated 96+ times):
When
--experimental-permissionis active, a VFS mount can servecontent for any path without triggering the permission gate. This is
the most significant security issue in the PR.
Recommendation: At minimum, when
permission.isEnabled(), theVFS handler path should still check
permission.has('fs.read', ...)/permission.has('fs.write', ...)as appropriate. Alternatively,disable VFS mounting entirely when the permission model is active
until a proper integration is designed.
1.2 Any loaded code can call
mount()and shadow real paths — HIGHDisposition: follow-up
require('node:vfs')is a public module with no gating. Any npmdependency can create a VFS, write arbitrary content, and
mount('/')to intercept every
fscall process-wide. There are no restrictionson mount paths —
/etc,/usr,node_modules, or/itself areall valid targets.
Combined with overlay mode, this enables targeted, nearly undetectable
interception of specific files (e.g.,
.env, credentials, SSH keys)while passing all other reads through to the real filesystem.
This was raised by @Qard in
#61478 (comment)
and the
vfs-mountevent was removed without a replacement gate.Recommendation: Consider:
--experimental-vfsflag to enable the module1.3 Overlapping mounts have undefined behavior — MEDIUM
Disposition: follow-up
activeVFSListinsetup.jsis iterated linearly. First-registeredVFS wins. If VFS-A mounts at
/appand VFS-B at/app/src, readsto
/app/src/file.jshit VFS-A. In mounted (non-overlay) mode,VFS-A returns ENOENT without consulting VFS-B. No conflict detection
or warning exists.
Recommendation:
registerVFS()should detect and warn (or reject)overlapping mount points.
1.4
RealFSProvidersymlink target not validated — MEDIUMDisposition: follow-up
providers/real.js:315-318—symlinkSync(target, vfsPath, type)validates
vfsPathvia#resolvePath()but passestargetthroughto
fs.symlinkSyncwithout validation. A user can create a symlinkinside the VFS root pointing to any path on the real filesystem,
enabling a jail escape.
Similarly,
readlinkSyncmay return absolute real-filesystem pathswithout translating them into VFS coordinates, leaking path info.
And
realpathSyncsilently returns the original VFS path when theresolved path escapes the root (lines 336-337, 350-351), masking
the escape rather than throwing.
2. API Compatibility Gaps
These are places where VFS objects don't match the behavior of their
real
fscounterparts. Code that works with realfswill break insubtle ways when given VFS equivalents.
2.1
VirtualFileHandlemissing ~15 methods — HIGHDisposition: follow-up
lib/internal/vfs/file_handle.js—VirtualFileHandleis missingthese methods that exist on real
fs.promises.FileHandle:chmod(mode),chown(uid, gid),utimes(atime, mtime)datasync(),sync()readv(buffers),writev(buffers)appendFile(data),readLines(),readableWebStream()createReadStream(),createWriteStream()fdproperty (numeric file descriptor)[Symbol.asyncDispose]()Additionally,
VirtualFileHandledoes not extendEventEmitter, sothe
'close'event (available since v15.4.0) doesn't fire. Codecalling
filehandle.on('close', ...)will throw.Recommendation: Add stub methods that throw
ERR_METHOD_NOT_IMPLEMENTEDfor unimplemented APIs, and extendEventEmitter.2.2
fsPromises.open()not intercepted by VFS — HIGHDisposition: follow-up
lib/internal/fs/promises.js— Theopen()function goes directlyto
binding.openFileHandle()without any VFS check. Any code usingthe modern
FileHandleAPI viafsPromises.open()bypasses VFSentirely. This includes all
FileHandlemethods (fh.read(),fh.write(),fh.stat(),fh.close()).2.3 Streams missing standard properties — HIGH
Disposition: follow-up
lib/internal/vfs/streams.js:VirtualReadStream— missingbytesReadandpendingpropertiesVirtualWriteStream— missingbytesWritten,pendingproperties,and explicit
close(callback)method2.4
VirtualDirmissing disposal and auto-close — MEDIUMDisposition: follow-up
lib/internal/vfs/dir.js:[Symbol.asyncDispose]()and[Symbol.dispose]()entries()async iterator doesn't auto-close the directory afteriteration (real
fs.Dirdoes)read()andclose()areasyncmethods that always return aPromise, even in callback mode (real
fs.Dirreturnsundefinedwhen a callback is provided)
2.5 Stats objects have
ino: 0anddev: 0for all files — MEDIUMDisposition: follow-up
lib/internal/vfs/stats.js— Every virtual file getsino = 0anddev = 0. This means:stat.inofor identity checks (e.g., detecting hardlinks, circular references in
fs.cp) treats all VFS files as thesame file
stat.dev === 0could collide with real device IDsRecommendation: Generate unique inode numbers (incrementing
counter) and use a distinctive
devnumber for VFS files.2.6 No
bigintStats support — LOWDisposition: note
The stats factory functions don't handle
{ bigint: true }. Callerspassing this option get regular Stats without bigint values. Unlikely
to matter for most use cases but worth documenting.
2.7
VFSWatcher/VFSStatWatcherAPI mismatches — MEDIUMDisposition: follow-up
lib/internal/vfs/watcher.js:VFSWatchernever emits'error'event — errors during pollingare silently swallowed in
#getStats()and#getStatsFor()VFSStatWatcher.addListener(listener)andremoveListener(listener)break theEventEmittercontract — theytake only one argument instead of
(event, listener), shadowingthe inherited methods with incompatible signatures
3. Correctness Bugs
3.1 Streams stall on destroyed/null fd — HIGH
Disposition: follow-up
lib/internal/vfs/streams.js:VirtualWriteStream._write()(lines 255-258): Whenthis.#destroyed || this.#fd === null, the method returns withoutcalling
callback(). The writable stream will hang indefinitely.Must call
callback(error)orcallback().VirtualReadStream._read()(lines 94-97): Same pattern — returnswithout pushing
nullor callingdestroy(). The readable streamstalls.
3.2
#checkClosed()always reports'read'syscall — MEDIUMDisposition: follow-up
lib/internal/vfs/file_handle.js— BothVirtualFileHandleandMemoryFileHandleimplementations of#checkClosed()always throwcreateEBADF('read'), even when called from write, truncate, or statoperations. The error message will be misleading.
3.3
writeFileSyncmisses'ax'/'ax+'append flags — MEDIUMDisposition: follow-up
lib/internal/vfs/file_handle.js:508— The append-mode check onlyhandles
'a'and'a+', missing'ax'and'ax+'. Data writtenvia
writeFileSyncwithflags: 'ax'will replace content insteadof appending. The
#isAppend()method already handles all fourvariants and should be used instead.
3.4 Dead ternary in
#hookProcessCwd— LOWDisposition: follow-up
lib/internal/vfs/file_system.js:282-283:Both branches are identical. Likely a leftover from a refactor.
3.5
SEAProvider.statSyncreads full asset to get size — MEDIUMDisposition: follow-up
lib/internal/vfs/providers/sea.js:304—statSynccallsthis.#getAssetContent(normalized)which copies the entire asset intoa new Buffer just to measure
.length. For large embedded assets,this is wasteful. A size-only path or caching would help.
3.6
SEAProviderdoesn't handle recursivereaddir— MEDIUMDisposition: follow-up
providers/sea.js—readdirSyncdoes not handle{ recursive: true }.Called with that option, it silently returns only immediate children.
3.7
SEAProvider.openSynconly accepts string flag'r'— LOWDisposition: follow-up
providers/sea.js:273— Rejects numeric flags likeO_RDONLY = 0.The
MemoryProviderhandles this vianormalizeFlags()butSEAProviderdoes not.3.8 Shared mutable
statsArrayis fragile — MEDIUMDisposition: note
lib/internal/vfs/stats.js:27— A module-levelFloat64Array(18)singleton is reused across all stats creation calls. This is safe only
because
getStatsFromBinding()copies synchronously. Any futurerefactor that makes it async will introduce silent data corruption.
Should at minimum have a comment asserting this invariant.
4. Windows Path Handling
4.1 Case-insensitive path comparison missing — CRITICAL
Disposition: follow-up (depending on Windows CI status)
All path comparisons in the VFS are case-sensitive:
router.js:30—normalizedPath === mountPoint(strict equality)router.js:40—StringPrototypeStartsWith(normalizedPath, prefix)memory.js:293—current.children.get(segment)(Map is case-sensitive)On Windows,
C:\Virtualandc:\virtualandC:\VIRTUALare thesame path, but the VFS treats them as different. A case-variant access
silently bypasses the VFS and falls through to the real filesystem.
This is both a correctness bug and a security vulnerability if the VFS
is used for sandboxing.
Recommendation: On
process.platform === 'win32', normalize pathsto a canonical case before comparison. Provider Map lookups should
also be case-insensitive on Windows.
4.2 UNC paths (
\\server\share) unsupported — HIGHDisposition: follow-up
Zero UNC path handling exists. The
MemoryProvider's#normalizePathconverts\\server\share\file→//server/share/file→/server/share/file(POSIX normalizecollapses
//), destroying the UNC prefix. Should either beexplicitly rejected with a clear error or properly supported.
4.3 Virtual CWD uses hardcoded
/separator — MEDIUMDisposition: follow-up
file_system.js:211—const resolved = \${this[kVirtualCwd]}/${inputPath}`uses a hardcoded/to join paths. On Windows this produces mixed separators likeC:\virtual\subdir/relative/path. WhileresolvePath()normalizes this on the next line, it's fragile. Should useresolvePath(this[kVirtualCwd], inputPath)` directly.4.4
findVFSPackageJSONmixed separator check — LOWDisposition: note
setup.js:352-353checks both'/node_modules'and'\\node_modules'. On each platform one branch is dead code. Usingsep + 'node_modules'would be cleaner.5. Performance Concerns
5.1 Async callback APIs are sync under the hood — HIGH
Disposition: follow-up
Throughout the codebase, callback-based async fs functions delegate to
sync VFS operations wrapped in
process.nextTick. This affects:lib/fs.js— All 96+ callback interception points call thesync handler and deliver results via
nextTickfile_system.js—rm(),truncate(),ftruncate(),link(),mkdtemp(),opendir()all call their sync counterpartsMemoryProvider— Every async method calls the sync counterpartSEAProvider— Same patternThis means the async API provides zero concurrency benefit and blocks
the event loop. For the in-memory and SEA providers this is pragmatic,
but for
RealFSProvider(which has genuine async I/O) or user-createdcustom providers, this is a real problem since the
lib/fs.jsinterception layer forces sync execution regardless of provider
capability.
Recommendation: Document this limitation. Consider allowing the
handlers in
setup.jsto return a Promise that thelib/fs.jscallback path can handle asynchronously.
5.2
existsSynccalled in async code paths — MEDIUMDisposition: follow-up
lib/internal/vfs/setup.js:236—findVFSWithAsync()callsvfs.existsSync()(sync!) inside an async function. If a customprovider's
existsSyncis expensive (e.g., network-backed), thisblocks the event loop in a nominally async path.
5.3 Linear scan of
activeVFSList— LOWDisposition: note
All
findVFSFor*functions iterate the active VFS list linearly.With many mounted VFS instances this could become slow, though in
practice the list is likely very small (1-3 instances).
6. Architecture & Design
6.1 Maintenance burden of 164+ interception points — HIGH
Disposition: follow-up (design discussion)
The VFS integration adds:
lib/fs.jslib/internal/fs/promises.jsEvery new
fsAPI must remember to add a VFS hook. This is asignificant ongoing maintenance burden and a source of future bugs
when hooks are forgotten.
Recommendation: Consider whether the interception could be
centralized (e.g., a Proxy-based approach, or a single dispatch
function that handles all operations) to reduce the per-function
boilerplate.
6.2 Cross-VFS operations not handled — MEDIUM
Disposition: follow-up
setup.js—renameSync,copyFileSync,linkSynchandlersresolve
newPath/destusing the source VFS. IfnewPathis in adifferent VFS mount or outside any VFS, the operation is still
dispatched to the source VFS, which will likely fail in confusing
ways.
6.3 Package.json cache not invalidated on unmount — MEDIUM
Disposition: follow-up
lib/internal/modules/package_json_reader.jscaches inmoduleToParentPackageJSONCacheanddeserializedPackageJSONCacheare never cleared when a VFS is unmounted. Only the CJS
_pathCacheand stat cache are cleared (in
setup.js:92-93). Stale VFSpackage.json data persists after unmount.
6.4 C++ format coupling in package.json serialization — MEDIUM
Disposition: note
setup.jsserializePackageJSON()must produce tuples matching theexact format of the C++
readPackageJSONbinding. The VFS does aparse→serialize→deserialize round-trip (JSON parse the file, extract
fields, stringify certain fields, then the reader deserializes again).
If the C++ format changes, the JS VFS serialization must be updated
too. This is a fragile contract that should be documented with a
cross-reference.
6.5
legacyMainResolvehas hardcoded extension arrays — LOWDisposition: note
setup.js— The VFS implementation oflegacyMainResolvehardcodesextension arrays matching the C++ implementation. If the C++ side
changes the extension mapping, this will silently diverge.
7. Test Runner Mock Integration
7.1
MockFSContext.restore()error prevents other mock cleanup — MEDIUMDisposition: follow-up
lib/internal/test_runner/mock/mock.js— Ifvfs.unmount()throwsinside
restore(), the error propagates throughrestoreAll()whichhas no try/catch, preventing subsequent mocks from being restored.
7.2
MockFSContext.vfsproperty exposes full VFS instance — LOWDisposition: note
The public
.vfsproperty gives test code direct access to the fullVirtualFileSysteminstance, allowing unintended operations likecalling
mount()at a different prefix. Consider making this aprivate field.
7.3 Platform-specific
parentDir !== '/'check — LOWDisposition: follow-up
mock.js:441—if (parentDir !== '/')doesn't account for Windowsroots like
C:\. Should usepath.parse()or compare against themount prefix root.
8. Provider-Specific Issues
8.1
MemoryProvider:statSyncreports size 0 for dynamic files — MEDIUMDisposition: follow-up
providers/memory.js:408— When a file has acontentProvider(dynamiccontent) and no explicit
size,statSyncreadsentry.content.lengthwhich is the initial empty buffer (
Buffer.alloc(0)), reporting size 0.8.2
MemoryProvider:symlinkSyncauto-creates parent dirs — LOWDisposition: note
providers/memory.js:808—symlinkSyncpassescreate=trueto#ensureParent, auto-creating intermediate directories. Othermutating operations pass
create=false. This is inconsistent andcould surprise users.
8.3
MemoryProvider: Recursivereaddirdoesn't follow symlinks — LOWDisposition: note
providers/memory.js:597—#readdirRecursivecheckschildEntry.isDirectory()directly without following symlinks. Thismay differ from real
readdirbehavior where{ recursive: true }follows symlinks.
8.4
RealFSProvider: Path traversal via symlinks — MEDIUMDisposition: follow-up
providers/real.js—#resolvePathvalidates the logical path butdoes NOT resolve symlinks before checking. If the real filesystem has
a symlink inside
rootPathpointing outside it, operations followthat symlink and access files outside the jail. A proper chroot would
need
fs.realpathSyncin#resolvePathor a post-resolution check.8.5
RealFSProvider: No watch support — LOWDisposition: note
The real filesystem provider doesn't implement
watch/watchFile/unwatchFiledespite wrapping a real filesystem that could easilysupport them.
9. Test Coverage Gaps
9.1 Windows tests are minimal — HIGH
Disposition: follow-up
test/parallel/test-vfs-windows.jshas only 4 test cases (100 lines).Missing coverage:
C:\virtual/subdir\file.txt)..on WindowsC:/virtual/file.txt)fson Windows9.2
fsPromises.open()/ FileHandle path untested — HIGHDisposition: follow-up
No tests verify that
fsPromises.open()works with VFS paths(because it doesn't — see §2.2). Tests should be added once this
is supported.
9.3 Overlapping mount behavior untested — MEDIUM
Disposition: follow-up
No tests verify behavior when multiple VFS instances mount at
overlapping paths.
9.4 Permission model interaction untested — MEDIUM
Disposition: follow-up
No tests verify VFS behavior when
--experimental-permissionisactive.
10. Code Quality & Cleanup
10.1 Watcher unbounded memory growth — HIGH
Disposition: follow-up
lib/internal/vfs/watcher.js:VFSWatchAsyncIterable.#pendingEventsgrows without bound ifthe consumer reads slower than events arrive
#pendingResolversgrows without bound ifnext()is calledmany times without events
10.2 Redundant
#destroyedfield in streams — LOWDisposition: note
Both
VirtualReadStreamandVirtualWriteStreamtrack#destroyedmanually, but
ReadableandWritablealready have adestroyedproperty.
10.3
SEAProviderprimordials inconsistency — LOWDisposition: follow-up
providers/sea.js:path.replace()regex instead ofStringPrototypeReplaceAllfrom primordials[...children]instead ofArrayFromfrom primordials
10.4
VirtualFileHandleprivate method inaccessible to subclass — LOWDisposition: note
file_handle.js—#checkClosed()is a private method on the baseclass, but private methods aren't inherited.
MemoryFileHandlere-declares its own
#checkClosed()at line 261. The duplicationconfirms the pattern doesn't work cleanly. Consider making it a
regular method or using the symbol-based pattern already used for
properties.
10.5
file_system.jsandsetup.jsare very large — LOWDisposition: note
file_system.js(1347 lines) andsetup.js(1080 lines) aresubstantial, driven by the three API surfaces (sync/callback/promise)
and comprehensive fs handler delegation. The code is functional but
difficult to navigate. Consider splitting in follow-ups.
10.6
VirtualFDrange starts at 10,000 — LOWDisposition: note
lib/internal/vfs/fd.js:18— Virtual FDs start at 10,000 to avoidcollision with real OS FDs. As noted in a PR comment by @arcanis, this
is low for long-running apps that open/close many files. The fslib
project reserves the upper byte of an i32 instead. No FD reuse or
wraparound exists. Worth revisiting in a follow-up.