Commit 28994575bcd for nodejs

commit 28994575bcd7a7f10d1c9ca1046c8391571f9448
Author: Philipp Dunkel <pipobscure@users.noreply.github.com>
Date:   Wed Sep 23 16:45:25 2026 +0200

    vfs: drop --vfs-mount, pin the load mount point

    --vfs-mount mounted a source without running it, and shared one ordered
    list of sources with --vfs-load, so neither option could say which entry
    it had contributed: the entry point was recovered from the position of
    --vfs-load among the mounts, in a list NODE_OPTIONS could prepend to.
    Nothing needs more than one mount from the command line: a program that
    wants more can mount them itself through node:vfs, where it also gets
    the instance.

    Remove --vfs-mount, leaving --vfs-load with the single source it mounts
    and runs, and reserve layer 0 for that source, numbering the file
    systems a program mounts itself from 1. The source is then at the same
    mount point in every thread, whatever else that thread mounts -
    including a thread where a --require preload mounted a file system of
    its own first - so a path into it stays valid in a worker.

    A worker still does not run that entry point: it inherits the source but
    not the decision to load from it. A worker created with its own execArgv
    inherits neither, so the documentation now says that such a worker must
    be given --experimental-vfs and --vfs-load again to run a script from
    the mount, and that --experimental-vfs is also what makes node:vfs
    available to the worker's own code.

    ERR_VFS_INVALID_TARGET now names --vfs-load as the source's origin, and
    the startup test moves to test-vfs-load.js, with the cases that covered
    mounting without loading removed and cases for the reserved mount point
    added.

    Signed-off-by: Philipp Dunkel <pip@pipobscure.com>
    PR-URL: https://github.com/nodejs/node/pull/66162
    Reviewed-By: James M Snell <jasnell@gmail.com>
    Reviewed-By: Matteo Collina <matteo.collina@gmail.com>

diff --git a/doc/api/cli.md b/doc/api/cli.md
index cde00859c92..f3cc2ee7e1a 100644
--- a/doc/api/cli.md
+++ b/doc/api/cli.md
@@ -3795,56 +3795,18 @@ added: v26.10.0

 Requires [`--experimental-vfs`][]. May be given at most once.

-Mounts `source` exactly as [`--vfs-mount`][] does, and additionally runs the
-entry point and all subsequent `require()`/`import` resolution against that
-mount rather than the real file system. The entry point is taken from the mount
-the same way `node <directory>` takes one: the mount's own `package.json`
-`"main"`, or `index.js`. Any positional command-line argument is the program's
-own (available from `process.argv[2]` onward), never an entry-point override.
+Mounts `source` as a virtual file system ([`node:vfs`][]), and runs the entry
+point and all subsequent `require()`/`import` resolution against that mount
+rather than the real file system. The mount is placed at a reserved mount point
+assigned by Node.js, so it never shadows real paths and no target can be
+chosen. The entry point is taken from the mount the same way `node <directory>`
+takes one: the mount's own `package.json` `"main"`, or `index.js`. Any
+positional command-line argument is the program's own (available from
+`process.argv[2]` onward), never an entry-point override.

 `process.argv[1]` reports `source` rather than the reserved mount point, since
 the mount point is an opaque implementation detail.

-Mounting the same source twice mounts it twice, at two separate mount points.
-The entry point then comes from the mount `--vfs-load` itself contributed, not
-from an earlier `--vfs-mount` of the same source.
-
-In worker threads `--vfs-load` mounts but does not load: a worker inherits the
-same mounts, in the same order, and runs its own entry point.
-
-`--vfs-load` is not permitted in [`NODE_OPTIONS`][]: which entry point runs is
-the command line's decision, and the environment must not be able to redirect
-it.
-
-```console
-$ node --experimental-vfs --vfs-load=app.zip
-$ node --experimental-vfs --vfs-mount=lib.zip --vfs-load=app.zip
-```
-
-### `--vfs-mount=source`
-
-<!-- YAML
-added: v26.10.0
--->
-
-* `source` {string} A directory or an archive file to mount.
-
-Requires [`--experimental-vfs`][]. May be repeated to mount several sources.
-
-Mounts `source` as a virtual file system ([`node:vfs`][]). Each mount is placed
-at a reserved mount point assigned by Node.js, so mounts never shadow real
-paths and no target can be chosen. Mounting alone does not change the entry
-point; use [`--vfs-load`][] for the source to run from.
-
-`--vfs-mount` and [`--vfs-load`][] mount in the order they are written, so
-
-```console
-$ node --experimental-vfs --vfs-mount=a --vfs-load=b --vfs-mount=c
-```
-
-mounts `a`, `b` and `c` in that order and runs `b`. Mounts contributed by
-[`NODE_OPTIONS`][] are mounted before the command line's.
-
 The provider backing a source is chosen from the source itself rather than from
 its file name:

@@ -3857,6 +3819,29 @@ preloaded with [`--require`][] or [`--import`][]) are consulted first, in
 reverse registration order, and may claim directories as well as files. If no
 provider claims the source, Node.js exits with an error.

+In worker threads `--vfs-load` mounts but does not load: a worker inherits the
+mount and runs its own entry point, which may itself live in the mount.
+
+The source is mounted at the same reserved mount point in every thread that
+mounts it, whatever else that thread mounts, so a path into the mount means the
+same thing in all of them.
+
+A worker created with its own `execArgv` inherits none of the parent's options,
+and so does not mount the source at all. To run a script from the mount, such a
+worker must be given the same options again, `--experimental-vfs` and
+`--vfs-load`; without them, that thread has no mount for the script to come
+from, and the worker fails to load it. `--experimental-vfs` is also what makes
+[`node:vfs`][] available to the worker's own code. A worker whose script comes
+from anywhere else, such as the real file system, needs nothing added.
+
+`--vfs-load` is not permitted in [`NODE_OPTIONS`][]: which entry point runs is
+the command line's decision, and the environment must not be able to redirect
+it.
+
+```console
+$ node --experimental-vfs --vfs-load=app.zip
+```
+
 ### `--watch`

 <!-- YAML
@@ -4299,7 +4284,6 @@ one is included in the list below.
 * `--use-openssl-ca`
 * `--use-system-ca`
 * `--v8-pool-size`
-* `--vfs-mount`
 * `--watch-kill-signal`
 * `--watch-path`
 * `--watch-preserve-output`
@@ -4825,8 +4809,6 @@ node --stack-trace-limit=12 -p -e "Error.stackTraceLimit" # prints 12
 [`--require`]: #-r---require-module
 [`--use-env-proxy`]: #--use-env-proxy
 [`--use-system-ca`]: #--use-system-ca
-[`--vfs-load`]: #--vfs-loadsource
-[`--vfs-mount`]: #--vfs-mountsource
 [`AsyncLocalStorage`]: async_context.md#class-asynclocalstorage
 [`Buffer`]: buffer.md#class-buffer
 [`CRYPTO_secure_malloc_init`]: https://www.openssl.org/docs/man3.0/man3/CRYPTO_secure_malloc_init.html
diff --git a/doc/api/errors.md b/doc/api/errors.md
index f02a2950318..11292b60b5d 100644
--- a/doc/api/errors.md
+++ b/doc/api/errors.md
@@ -3569,7 +3569,7 @@ entry types are found.

 ### `ERR_VFS_INVALID_TARGET`

-A `--vfs-mount` source does not exist, is neither a regular file nor a
+A `--vfs-load` source does not exist, is neither a regular file nor a
 directory, or is a source no provider claims.

 <a id="ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING"></a>
diff --git a/doc/api/vfs.md b/doc/api/vfs.md
index 6f6fc53a49e..edbb1c306df 100644
--- a/doc/api/vfs.md
+++ b/doc/api/vfs.md
@@ -106,7 +106,7 @@ added: v26.10.0
   * `create` {Function} Called with the resolved path and its [`fs.Stats`][].
     Returns the {VirtualProvider} backing the source.

-Registers a provider that [`--vfs-mount`][] can select for a source it
+Registers a provider that [`--vfs-load`][] can select for a source it
 recognizes, so a file format Node.js has no built-in provider for can still be
 mounted.

@@ -118,7 +118,7 @@ source, the built-in providers handle it: a directory with
 [`RealFSProvider`][], and a file whose bytes are a ZIP archive with
 [`ZipProvider`][].

-Providers must be registered before the mounts are created. Register from a
+Providers must be registered before the source is mounted. Register from a
 module preloaded with [`--require`][] or [`--import`][]:

 ```cjs
@@ -702,7 +702,7 @@ fields use synthetic but stable values:
 [Single Executable Application]: single-executable-applications.md
 [`--import`]: cli.md#--importmodule
 [`--require`]: cli.md#-r---require-module
-[`--vfs-mount`]: cli.md#--vfs-mountsource
+[`--vfs-load`]: cli.md#--vfs-loadsource
 [`MemoryProvider`]: #class-memoryprovider
 [`RealFSProvider`]: #class-realfsprovider
 [`VirtualFileSystem`]: #class-virtualfilesystem
diff --git a/doc/node.1 b/doc/node.1
index 1b873d1ecca..1f97de57033 100644
--- a/doc/node.1
+++ b/doc/node.1
@@ -1888,43 +1888,16 @@ Print node's version.
 \fBsource\fR \fB{string}\fR A directory or an archive file to mount and run.
 .El
 Requires \fB--experimental-vfs\fR. May be given at most once.
-Mounts \fBsource\fR exactly as \fB--vfs-mount\fR does, and additionally runs the
-entry point and all subsequent \fBrequire()\fR/\fBimport\fR resolution against that
-mount rather than the real file system. The entry point is taken from the mount
-the same way \fBnode <directory>\fR takes one: the mount's own \fBpackage.json\fR
-\fB"main"\fR, or \fBindex.js\fR. Any positional command-line argument is the program's
-own (available from \fBprocess.argv[2]\fR onward), never an entry-point override.
+Mounts \fBsource\fR as a virtual file system (\fBnode:vfs\fR), and runs the entry
+point and all subsequent \fBrequire()\fR/\fBimport\fR resolution against that mount
+rather than the real file system. The mount is placed at a reserved mount point
+assigned by Node.js, so it never shadows real paths and no target can be
+chosen. The entry point is taken from the mount the same way \fBnode <directory>\fR
+takes one: the mount's own \fBpackage.json\fR \fB"main"\fR, or \fBindex.js\fR. Any
+positional command-line argument is the program's own (available from
+\fBprocess.argv[2]\fR onward), never an entry-point override.
 \fBprocess.argv[1]\fR reports \fBsource\fR rather than the reserved mount point, since
 the mount point is an opaque implementation detail.
-Mounting the same source twice mounts it twice, at two separate mount points.
-The entry point then comes from the mount \fB--vfs-load\fR itself contributed, not
-from an earlier \fB--vfs-mount\fR of the same source.
-In worker threads \fB--vfs-load\fR mounts but does not load: a worker inherits the
-same mounts, in the same order, and runs its own entry point.
-\fB--vfs-load\fR is not permitted in \fBNODE_OPTIONS\fR: which entry point runs is
-the command line's decision, and the environment must not be able to redirect
-it.
-.Bd -literal
-$ node --experimental-vfs --vfs-load=app.zip
-$ node --experimental-vfs --vfs-mount=lib.zip --vfs-load=app.zip
-.Ed
-.
-.It Fl -vfs-mount Ns = Ns Ar source
-.Bl -bullet
-.It
-\fBsource\fR \fB{string}\fR A directory or an archive file to mount.
-.El
-Requires \fB--experimental-vfs\fR. May be repeated to mount several sources.
-Mounts \fBsource\fR as a virtual file system (\fBnode:vfs\fR). Each mount is placed
-at a reserved mount point assigned by Node.js, so mounts never shadow real
-paths and no target can be chosen. Mounting alone does not change the entry
-point; use \fB--vfs-load\fR for the source to run from.
-\fB--vfs-mount\fR and \fB--vfs-load\fR mount in the order they are written, so
-.Bd -literal
-$ node --experimental-vfs --vfs-mount=a --vfs-load=b --vfs-mount=c
-.Ed
-mounts \fBa\fR, \fBb\fR and \fBc\fR in that order and runs \fBb\fR. Mounts contributed by
-\fBNODE_OPTIONS\fR are mounted before the command line's.
 The provider backing a source is chosen from the source itself rather than from
 its file name:
 .Bl -bullet
@@ -1938,6 +1911,24 @@ Providers registered with \fBvfs.registerProvider()\fR (typically from a module
 preloaded with \fB--require\fR or \fB--import\fR) are consulted first, in
 reverse registration order, and may claim directories as well as files. If no
 provider claims the source, Node.js exits with an error.
+In worker threads \fB--vfs-load\fR mounts but does not load: a worker inherits the
+mount and runs its own entry point, which may itself live in the mount.
+The source is mounted at the same reserved mount point in every thread that
+mounts it, whatever else that thread mounts, so a path into the mount means the
+same thing in all of them.
+A worker created with its own \fBexecArgv\fR inherits none of the parent's options,
+and so does not mount the source at all. To run a script from the mount, such a
+worker must be given the same options again, \fB--experimental-vfs\fR and
+\fB--vfs-load\fR; without them, that thread has no mount for the script to come
+from, and the worker fails to load it. \fB--experimental-vfs\fR is also what makes
+\fBnode:vfs\fR available to the worker's own code. A worker whose script comes
+from anywhere else, such as the real file system, needs nothing added.
+\fB--vfs-load\fR is not permitted in \fBNODE_OPTIONS\fR: which entry point runs is
+the command line's decision, and the environment must not be able to redirect
+it.
+.Bd -literal
+$ node --experimental-vfs --vfs-load=app.zip
+.Ed
 .
 .It Fl -watch
 Starts Node.js in watch mode.
@@ -2430,8 +2421,6 @@ one is included in the list below.
 .It
 \fB--v8-pool-size\fR
 .It
-\fB--vfs-mount\fR
-.It
 \fB--watch-kill-signal\fR
 .It
 \fB--watch-path\fR
diff --git a/lib/internal/errors.js b/lib/internal/errors.js
index 27c8202c04e..f8383a3511a 100644
--- a/lib/internal/errors.js
+++ b/lib/internal/errors.js
@@ -1962,7 +1962,7 @@ E('ERR_USE_AFTER_CLOSE', '%s was closed', Error);
 E('ERR_VALID_PERFORMANCE_ENTRY_TYPE',
   'At least one valid performance entry type is required', Error);
 E('ERR_VFS_INVALID_TARGET',
-  '%s is not a valid --vfs-mount source: must be an existing file or directory', Error);
+  '%s is not a valid --vfs-load source: must be an existing file or directory', Error);
 E('ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING',
   'A dynamic import callback was not specified.', TypeError);
 E('ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING_FLAG',
diff --git a/lib/internal/main/worker_thread.js b/lib/internal/main/worker_thread.js
index 5c6443661c4..e2b30de754a 100644
--- a/lib/internal/main/worker_thread.js
+++ b/lib/internal/main/worker_thread.js
@@ -145,8 +145,8 @@ port.on('message', (message) => {
       // initializeAsyncLoaderHooksOnLoaderHookWorker() which needs to run preloads
       // after the asynchronous loader hooks are registered.
       initializeModuleLoaders({ shouldSpawnLoaderHookWorker: true, shouldPreloadModules: true });
-      // Re-mount inherited --vfs-mount sources so their reserved paths (which a
-      // worker filename may point into) resolve in this thread too. With
+      // Re-mount the inherited --vfs-load source so its reserved path (which a
+      // worker filename may point into) resolves in this thread too. With
       // --import, mounting is deferred to after that loop in run_main, matching
       // the main thread; finishVfsMounts() is idempotent so it runs once.
       if (getOptionValue('--import').length === 0) {
diff --git a/lib/internal/process/pre_execution.js b/lib/internal/process/pre_execution.js
index eb6a056cc05..74f4480565e 100644
--- a/lib/internal/process/pre_execution.js
+++ b/lib/internal/process/pre_execution.js
@@ -14,8 +14,6 @@ const {
   ObjectDefineProperty,
   ObjectFreeze,
   String,
-  StringPrototypeIndexOf,
-  StringPrototypeSlice,
   globalThis,
 } = primordials;

@@ -216,91 +214,58 @@ function setupVmModules() {
 let vfsMounted = false;
 let vfsLoadRoot;

-// --vfs-mount and --vfs-load append to one list, so `mounts` is already in the
-// order the command line gave, and the entry point comes from whichever of them
-// --vfs-load contributed. The parser stores plain strings and cannot record
-// which flag produced an entry, so its position is recovered from execArgv -
-// the command line's own node options, in order. NODE_OPTIONS may add mounts
-// but not a --vfs-load, so anything it contributed sits ahead of these.
-// Returns -1 when no --vfs-load was given.
-function getVfsLoadIndex(mountCount) {
-  if (!getOptionValue('[vfs_load_set]')) return -1;
-
-  const execArgv = process.execArgv;
-  let seen = 0;
-  let found = -1;
-  for (let i = 0; i < execArgv.length; i++) {
-    const arg = execArgv[i];
-    let name = arg;
-    const eq = StringPrototypeIndexOf(arg, '=');
-    let spaced = false;
-    if (eq !== -1) {
-      name = StringPrototypeSlice(arg, 0, eq);
-    } else {
-      // `--vfs-mount value`: the value is the next argument, so skip it rather
-      // than counting it as a flag of its own.
-      spaced = true;
-    }
-    if (name !== '--vfs-mount' && name !== '--vfs-load') continue;
-    if (name === '--vfs-load') found = seen;
-    seen++;
-    if (spaced) i++;
-  }
-  if (found === -1) return -1;
-  // Mounts from NODE_OPTIONS are parsed first and so precede the command
-  // line's; `seen` counts only the latter.
-  return mountCount - seen + found;
-}
-
-// Mounts every --vfs-mount source. Called from prepareExecution() when there is
+// Mounts the --vfs-load source. Called from prepareExecution() when there is
 // no --import, and otherwise from run_main after the --import loop has run; the
-// guard makes the second call a no-op so a provider registered by either a -r or
-// an --import preload is available before its source's provider is chosen.
+// guard makes the second call a no-op so a provider registered by either a -r
+// or an --import preload is available before the source's provider is chosen.
 function finishVfsMounts() {
   if (vfsMounted) return;
   vfsMounted = true;

-  const entries = getOptionValue('--vfs-mount');
-  if (entries.length === 0) return;
-  emitExperimentalWarning('--vfs-mount');
+  const source = getOptionValue('--vfs-load');
+  if (source === '') return;
+  emitExperimentalWarning('--vfs-load');

   const fs = require('fs');
   const path = require('path');
   const { selectProvider } = require('internal/vfs/provider_registry');
-  const { VirtualFileSystem } = require('internal/vfs/file_system');
-
-  // --vfs-load is forced off in workers (see node_worker.cc), so this records a
-  // load root only on the main thread; a worker re-mounts the same sources in
-  // the same order (the reserved paths line up) but runs its own entry.
-  const loadIndex = getVfsLoadIndex(entries.length);
-  for (let i = 0; i < entries.length; i++) {
-    const resolvedSource = path.resolve(entries[i]);
-    let stats;
-    try {
-      stats = fs.statSync(resolvedSource);
-    } catch {
-      throw new ERR_VFS_INVALID_TARGET(resolvedSource);
-    }
-    if (!stats.isDirectory() && !stats.isFile()) {
-      throw new ERR_VFS_INVALID_TARGET(resolvedSource);
-    }
-    const provider = selectProvider(resolvedSource, stats);
-    if (provider === null) {
-      throw new ERR_VFS_INVALID_TARGET(resolvedSource);
-    }
-    const vfs = new VirtualFileSystem(provider, { emitExperimentalWarning: false });
-    const mountPoint = vfs.mount();
-    // The mount --vfs-load contributed is what the entry is require()d from;
-    // process.argv[1] names the real source instead, since the reserved mount
-    // point is an opaque implementation detail.
-    //
-    // The source is spliced in rather than assigned over argv[1]: the entry
-    // comes from the mount, so nothing was consumed as an entry point and the
-    // first positional argument is the program's own. Overwriting would drop it.
-    if (i === loadIndex) {
-      vfsLoadRoot = mountPoint;
-      ArrayPrototypeSplice(process.argv, 1, 0, resolvedSource);
-    }
+  const { VirtualFileSystem, kLoadLayer } = require('internal/vfs/file_system');
+
+  const resolvedSource = path.resolve(source);
+  let stats;
+  try {
+    stats = fs.statSync(resolvedSource);
+  } catch {
+    throw new ERR_VFS_INVALID_TARGET(resolvedSource);
+  }
+  if (!stats.isDirectory() && !stats.isFile()) {
+    throw new ERR_VFS_INVALID_TARGET(resolvedSource);
+  }
+  const provider = selectProvider(resolvedSource, stats);
+  if (provider === null) {
+    throw new ERR_VFS_INVALID_TARGET(resolvedSource);
+  }
+  // The source is mounted at the layer reserved for it, so it is at the same
+  // reserved path in every thread.
+  const vfs = new VirtualFileSystem(provider, {
+    __proto__: null,
+    emitExperimentalWarning: false,
+    [kLoadLayer]: true,
+  });
+  const mountPoint = vfs.mount();
+  // A worker inherits the source but not [vfs_load_set] (see node_worker.cc):
+  // it mounts the same source, and so reaches it at the same reserved path,
+  // but runs its own entry point.
+  //
+  // On the main thread the entry is require()d from the mount point, while
+  // process.argv[1] reports the source, since the reserved mount point is an
+  // opaque implementation detail. The source is spliced in rather than
+  // assigned over argv[1]: the entry comes from the mount, so nothing was
+  // consumed as an entry point and the first positional argument is the
+  // program's own. Overwriting would drop it.
+  if (getOptionValue('[vfs_load_set]')) {
+    vfsLoadRoot = mountPoint;
+    ArrayPrototypeSplice(process.argv, 1, 0, resolvedSource);
   }
 }

diff --git a/lib/internal/vfs/file_system.js b/lib/internal/vfs/file_system.js
index afb5fab3eb7..a496bd32efa 100644
--- a/lib/internal/vfs/file_system.js
+++ b/lib/internal/vfs/file_system.js
@@ -47,8 +47,14 @@ const kNormalizedMountPoint = Symbol('kNormalizedMountPoint');
 const kMounted = Symbol('kMounted');
 const kPromises = Symbol('kPromises');
 const kLayerId = Symbol('kLayerId');
+const kLoadLayer = Symbol('kLoadLayer');

-let nextLayerId = 0;
+// Layer 0 is reserved for the file system --vfs-load mounts, so that source is
+// at the same mount point in every thread whatever else a thread mounts, and a
+// path into it stays valid in a worker. Everything else is numbered from 1, in
+// the order it is mounted.
+const kLoadLayerId = 0;
+let nextLayerId = kLoadLayerId + 1;

 /**
  * Normalizes paths for mount-point comparisons. On Windows, `path.resolve('/x')`
@@ -120,7 +126,7 @@ class VirtualFileSystem {
     this[kNormalizedMountPoint] = null;
     this[kMounted] = false;
     this[kPromises] = null;
-    this[kLayerId] = nextLayerId++;
+    this[kLayerId] = options[kLoadLayer] === true ? kLoadLayerId : nextLayerId++;
   }

   /**
@@ -1309,5 +1315,6 @@ class VirtualFileSystem {
 module.exports = {
   VirtualFileSystem,
   kLayerId,
+  kLoadLayer,
   normalizeMountedPath,
 };
diff --git a/lib/internal/vfs/provider_registry.js b/lib/internal/vfs/provider_registry.js
index f74f82b4a6a..19d0d4c5451 100644
--- a/lib/internal/vfs/provider_registry.js
+++ b/lib/internal/vfs/provider_registry.js
@@ -1,6 +1,6 @@
 'use strict';

-// Maps a `--vfs-mount` source to the provider that backs it. Directories are
+// Maps a `--vfs-load` source to the provider that backs it. Directories are
 // served by RealFSProvider and ZIP archives by ZipProvider; both are claimed
 // from the source itself (a stat, or a trial open) rather than its file
 // extension. Any other source type is added via node:vfs's registerProvider().
@@ -57,7 +57,7 @@ const providers = [
 ];

 /**
- * Registers a provider that `--vfs-mount` can select for a source it
+ * Registers a provider that `--vfs-load` can select for a source it
  * recognizes. The newest registration is consulted first, and all registered
  * providers outrank the built-in directory provider, so a custom provider can
  * back, wrap, or vet any mount.
diff --git a/src/node.cc b/src/node.cc
index e1021ebd27b..2993fda0e9c 100644
--- a/src/node.cc
+++ b/src/node.cc
@@ -1036,25 +1036,16 @@ static ExitCode InitializeNodeWithArgsInternal(
   CheckGlobalBenchOptions(errors);
   if (!errors->empty()) return ExitCode::kInvalidCommandLineArgument;

-  // Checked here rather than in EnvironmentOptions::CheckOptions(), which runs
-  // at the end of every parse: NODE_OPTIONS is parsed before the command line,
-  // so a check there would reject `NODE_OPTIONS=--vfs-mount=x node
-  // --experimental-vfs` for an --experimental-vfs it had not read yet. These
-  // options only make sense as a set, so they are validated once all of them
-  // are in.
+  // Checked here, once every source of options has been parsed, because the
+  // count below needs the arguments the command line itself gave.
   {
     auto* env_options = per_process::cli_options->per_isolate->per_env.get();
-    if (!env_options->experimental_vfs) {
-      if (!env_options->vfs_mounts.empty()) {
-        errors->push_back("--vfs-mount requires --experimental-vfs");
-      }
-      if (env_options->vfs_load) {
-        errors->push_back("--vfs-load requires --experimental-vfs");
-      }
+    if (!env_options->experimental_vfs && env_options->vfs_load) {
+      errors->push_back("--vfs-load requires --experimental-vfs");
     }
-    // --vfs-load shares vfs_mounts with --vfs-mount, so the options themselves
-    // cannot say how often it was given; count it in the node options the
-    // command line yielded. A second one would silently win over the first.
+    // A second --vfs-load would silently replace the first, and the option
+    // itself cannot say how often it was given; count it in the node options
+    // the command line yielded.
     if (env_options->vfs_load && exec_argv != nullptr) {
       size_t seen = 0;
       for (const std::string& arg : *exec_argv) {
diff --git a/src/node_options.cc b/src/node_options.cc
index 6dcdfd6d1bd..692ac3b6240 100644
--- a/src/node_options.cc
+++ b/src/node_options.cc
@@ -691,23 +691,16 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() {
             "experimental node:vfs module",
             BOOL_FIELD(experimental_vfs),
             kAllowedInEnvvar);
-  // --vfs-mount and --vfs-load both append to vfs_mounts, so the list holds
-  // every mount in the order the command line asked for them. Which of those
-  // the entry point comes from is recovered from the position of --vfs-load,
-  // rather than an index the user has to count out.
-  AddOption("--vfs-mount",
-            "mount a directory or archive as a virtual file system "
-            "(option can be repeated; requires --experimental-vfs)",
-            &EnvironmentOptions::vfs_mounts,
-            kAllowedInEnvvar);
   // Choosing the entry point is the command line's alone: an environment
   // variable must not be able to redirect what a `node <args>` invocation runs,
-  // so this is rejected in NODE_OPTIONS.
+  // so this is rejected in NODE_OPTIONS. The source and whether to run from it
+  // are separate fields so that a worker can inherit the mount without
+  // inheriting the entry point.
   AddOption("--vfs-load",
             "mount a directory or archive as a virtual file system and run the "
             "entry point and module resolution against it instead of the real "
             "file system (may be given once; requires --experimental-vfs)",
-            &EnvironmentOptions::vfs_mounts,
+            &EnvironmentOptions::vfs_load_source,
             kDisallowedInEnvvar);
   AddOption("[vfs_load_set]", "", BOOL_FIELD(vfs_load));
   Implies("--vfs-load", "[vfs_load_set]");
diff --git a/src/node_options.h b/src/node_options.h
index cad2e850e31..9f31af1cb1b 100644
--- a/src/node_options.h
+++ b/src/node_options.h
@@ -152,6 +152,7 @@ class EnvironmentOptions : public Options {
   std::string tls_keylog;
   std::string experimental_config_file_path;
   std::string experimental_package_map_path;
+  std::string vfs_load_source;
 #if HAVE_INSPECTOR
   std::string cpu_prof_dir;
   std::string cpu_prof_name;
@@ -178,7 +179,6 @@ class EnvironmentOptions : public Options {
   std::vector<std::string> watch_mode_paths;
   std::vector<std::string> preload_cjs_modules;
   std::vector<std::string> preload_esm_modules;
-  std::vector<std::string> vfs_mounts;
   std::vector<std::string> user_argv;

   int64_t heap_snapshot_near_heap_limit = 0;
diff --git a/src/node_worker.cc b/src/node_worker.cc
index a90495409d1..308d987680d 100644
--- a/src/node_worker.cc
+++ b/src/node_worker.cc
@@ -705,8 +705,8 @@ void Worker::New(const FunctionCallbackInfo<Value>& args) {
   }

   // --vfs-load selects the main thread's entry point; a worker always starts
-  // from its own entry (which may itself live inside a --vfs-mount), so the
-  // mounts are inherited but the load behavior must not be.
+  // from its own entry (which may itself live inside the mount), so the mount
+  // is inherited but the load behavior must not be.
   per_isolate_opts->per_env->vfs_load = false;

   // Internal workers should not wait for inspector frontend to connect or
diff --git a/test/parallel/test-vfs-layer-tag-prefix.mjs b/test/parallel/test-vfs-layer-tag-prefix.mjs
index 8c751cee73f..c4083e53999 100644
--- a/test/parallel/test-vfs-layer-tag-prefix.mjs
+++ b/test/parallel/test-vfs-layer-tag-prefix.mjs
@@ -13,22 +13,26 @@ import vfs from 'node:vfs';

 const vfsImport = (path) => pathToFileURL(path).href;

-// Layer ids are per-process and increment on every `vfs.create()`, so the
-// second construction lands at id 1 and the eleventh at id 10.
-const instances = [];
-for (let i = 0; i < 11; i++) instances.push(vfs.create());
-const layerOne = instances[1];
-const layerTen = instances[10];
-
-layerOne.writeFileSync('/m.mjs', 'export const tag = "one";');
-const mountOne = layerOne.mount();
-
-layerTen.writeFileSync('/m.mjs', 'export const tag = "ten";');
-const mountTen = layerTen.mount();
-
-assert.notStrictEqual(mountOne, mountTen);
-assert.ok(mountTen.startsWith(mountOne),
-          'test scaffolding: expected layerTen mount to start with layerOne mount');
+// Layer ids are per-process and increment on every `vfs.create()`, so mounting
+// enough instances yields a pair whose mount points collide by prefix (an id
+// and that id followed by another digit), whichever id the numbering starts at.
+const mounted = [];
+for (let i = 0; i < 12; i++) {
+  const layer = vfs.create();
+  mounted.push({ layer, mountPoint: layer.mount() });
+}
+const pair = mounted.flatMap((shorter) =>
+  mounted.filter((longer) => longer !== shorter &&
+                             longer.mountPoint.startsWith(shorter.mountPoint))
+    .map((longer) => [shorter, longer]))[0];
+assert.ok(pair, 'test scaffolding: expected a pair of mount points that collide by prefix');
+const [{ layer: layerOne, mountPoint: mountOne },
+       { layer: layerTen, mountPoint: mountTen }] = pair;
+for (const { layer, mountPoint } of mounted) {
+  if (layer !== layerOne && layer !== layerTen) layer.unmount();
+  else layer.writeFileSync(`${mountPoint}/m.mjs`,
+                           `export const tag = "${layer === layerOne ? 'one' : 'ten'}";`);
+}

 const oneA = await import(vfsImport(`${mountOne}/m.mjs`));
 const tenA = await import(vfsImport(`${mountTen}/m.mjs`));
diff --git a/test/parallel/test-vfs-mount-load.js b/test/parallel/test-vfs-load.js
similarity index 59%
rename from test/parallel/test-vfs-mount-load.js
rename to test/parallel/test-vfs-load.js
index 21a05ed24c0..09db73c8fac 100644
--- a/test/parallel/test-vfs-mount-load.js
+++ b/test/parallel/test-vfs-load.js
@@ -1,10 +1,10 @@
 'use strict';

-// Covers --vfs-mount / --vfs-load: running a mounted directory's entry point
-// with require() resolving inside the mount, a provider registered by either a
-// -r (CJS) or an --import (ESM) preload backing a non-directory source, a ZIP
-// archive claimed by the built-in provider, a worker inheriting the mounts,
-// and the position of --vfs-load among the mounts deciding which one runs.
+// Covers --vfs-load: running a mounted directory's entry point with require()
+// resolving inside the mount, a provider registered by either a -r (CJS) or an
+// --import (ESM) preload backing a non-directory source, a ZIP archive claimed
+// by the built-in provider, and a worker reaching the mount, whether it
+// inherits the options or is given them itself.
 //
 // Native addon loading from a mount is not exercised here (it needs a compiled
 // .node), only the startup wiring around it.
@@ -136,56 +136,7 @@ registerProvider({
   assert.match(res.stdout, /hello from zip archive/);
 }

-// Two different ZIP archives mounted together each keep their own contents.
-// The built-in provider opens the archive while deciding whether it can claim
-// the source and hands that same handle to the provider it then creates, so
-// this pins down that the handle belongs to the source it was opened for and
-// is not shared between mounts.
-{
-  const zlib = require('zlib');
-
-  // Each archive prints which one it is and what it can see, so a mix-up shows
-  // up as the wrong marker or the other archive's file.
-  const body = Buffer.from(
-    'const fs = require("fs");\n' +
-    'console.log("marker:" + fs.readFileSync(__dirname + "/marker.txt", "utf8").trim());\n' +
-    'console.log("entries:" + fs.readdirSync(__dirname).sort().join(","));\n');
-
-  function archive(name, unique) {
-    const zipPath = fixture(`${name}.zip`);
-    const entries = [
-      zlib.ZipEntry.createSync('index.js', body),
-      zlib.ZipEntry.createSync('marker.txt', Buffer.from(`${name}\n`)),
-      zlib.ZipEntry.createSync(unique, Buffer.from('x\n')),
-    ];
-    const chunks = [];
-    for (const chunk of zlib.createZipArchiveSync(entries)) chunks.push(chunk);
-    fs.writeFileSync(zipPath, Buffer.concat(chunks));
-    return zipPath;
-  }
-
-  const first = archive('first-archive', 'first-only.txt');
-  const second = archive('second-archive', 'second-only.txt');
-
-  // Whichever archive --vfs-load names is the one that runs, in either order,
-  // and it sees its own entries rather than the other archive's.
-  for (const [args, name, unique, absent] of [
-    [[`--vfs-load=${first}`, `--vfs-mount=${second}`],
-     'first-archive', 'first-only.txt', 'second-only.txt'],
-    [[`--vfs-mount=${first}`, `--vfs-load=${second}`],
-     'second-archive', 'second-only.txt', 'first-only.txt'],
-    [[`--vfs-mount=${second}`, `--vfs-load=${first}`],
-     'first-archive', 'first-only.txt', 'second-only.txt'],
-  ]) {
-    const res = run(args);
-    assert.strictEqual(res.status, 0, res.stderr);
-    assert.match(res.stdout, new RegExp(`marker:${name}`));
-    assert.match(res.stdout, new RegExp(`entries:.*${unique}`));
-    assert.doesNotMatch(res.stdout, new RegExp(absent));
-  }
-}
-
-// A worker inherits --vfs-mount, so a worker script that lives inside the mount
+// A worker inherits the mount, so a worker script that lives inside it
 // (addressed here via the entry's own __dirname) resolves and runs.
 {
   const dir = fixture('worker-app');
@@ -235,6 +186,70 @@ parentPort.postMessage('hello from esm worker in mount');
   assert.match(res.stdout, /hello from esm worker in mount/);
 }

+// A worker created with its own execArgv does not inherit the parent's options,
+// so it has to be given the mount itself. Because the loaded source is always
+// the first file system a thread mounts, it lands at the same reserved mount
+// point in both threads, and a worker path the parent built still resolves.
+{
+  const dir = fixture('worker-execargv-app');
+  fs.mkdirSync(dir, { recursive: true });
+  fs.writeFileSync(path.join(dir, 'index.js'), `
+'use strict';
+const path = require('path');
+const { Worker } = require('worker_threads');
+const w = new Worker(path.join(__dirname, 'worker.js'), {
+  execArgv: ['--experimental-vfs', '--vfs-load=' + process.argv[1]],
+});
+w.on('message', (m) => { console.log(m); process.exit(0); });
+w.on('error', (e) => { console.error(e); process.exit(1); });
+`);
+  fs.writeFileSync(path.join(dir, 'worker.js'), `
+'use strict';
+require('worker_threads').parentPort.postMessage('worker ran from ' + __dirname);
+`);
+  // A preload that mounts a file system of its own runs before the --vfs-load
+  // source is mounted, and still does not move it.
+  const preload = fixture('mounting-preload.js');
+  fs.writeFileSync(preload, `
+'use strict';
+require('node:vfs').create().mount();
+`);
+
+  const seen = new Set();
+  for (const args of [[`--vfs-load=${dir}`], ['-r', preload, `--vfs-load=${dir}`]]) {
+    const res = run(args);
+    assert.strictEqual(res.status, 0, res.stderr);
+    const [, dirname] = /worker ran from (\S+)/.exec(res.stdout);
+    seen.add(dirname);
+  }
+  // The worker resolved a path the main thread built, in both runs, and that
+  // path did not move between them.
+  assert.strictEqual(seen.size, 1, [...seen].join());
+}
+
+// Without that flag the worker has no mount to load from, so a script in the
+// mount cannot be its entry point.
+{
+  const dir = fixture('worker-execargv-missing');
+  fs.mkdirSync(dir, { recursive: true });
+  fs.writeFileSync(path.join(dir, 'index.js'), `
+'use strict';
+const path = require('path');
+const { Worker } = require('worker_threads');
+const w = new Worker(path.join(__dirname, 'worker.js'), { execArgv: [] });
+w.on('message', (m) => { console.log('ran:' + m); process.exit(0); });
+w.on('error', (e) => { console.log('failed:' + e.code); process.exit(0); });
+`);
+  fs.writeFileSync(path.join(dir, 'worker.js'), `
+'use strict';
+require('worker_threads').parentPort.postMessage('unexpected');
+`);
+  const res = run([`--vfs-load=${dir}`]);
+  assert.strictEqual(res.status, 0, res.stderr);
+  assert.match(res.stdout, /failed:/);
+  assert.doesNotMatch(res.stdout, /ran:/);
+}
+
 // --vfs-load names the source it loads, so it always takes a value.
 {
   const res = run(['--vfs-load']);
@@ -242,54 +257,31 @@ parentPort.postMessage('hello from esm worker in mount');
   assert.match(res.stderr, /--vfs-load requires an argument/);
 }

-// --vfs-mount and --vfs-load share one ordered list, so mounts happen in the
-// order written and the entry point comes from whichever source --vfs-load
-// names, wherever it sits among them.
+// The value may also be given as a separate argument.
 {
-  const dirs = {};
-  for (const name of ['a', 'b', 'c']) {
-    dirs[name] = fixture(name);
-    fs.mkdirSync(dirs[name], { recursive: true });
-    fs.writeFileSync(path.join(dirs[name], 'index.js'),
-                     `console.log('ran:${name}');\n`);
-  }
-
-  for (const [args, expected] of [
-    [[`--vfs-load=${dirs.a}`, `--vfs-mount=${dirs.b}`], 'a'],
-    [[`--vfs-mount=${dirs.a}`, `--vfs-load=${dirs.b}`, `--vfs-mount=${dirs.c}`], 'b'],
-    [[`--vfs-mount=${dirs.a}`, `--vfs-mount=${dirs.b}`, `--vfs-load=${dirs.c}`], 'c'],
-    // The value may also be given as a separate argument.
-    [['--vfs-mount', dirs.a, '--vfs-load', dirs.b], 'b'],
-  ]) {
-    const res = run(args);
-    assert.strictEqual(res.status, 0, res.stderr);
-    assert.match(res.stdout, new RegExp(`ran:${expected}`));
-  }
+  const dir = fixture('spaced-value');
+  fs.mkdirSync(dir, { recursive: true });
+  fs.writeFileSync(path.join(dir, 'index.js'), "console.log('ran:spaced');\n");
+  const res = run(['--vfs-load', dir]);
+  assert.strictEqual(res.status, 0, res.stderr);
+  assert.match(res.stdout, /ran:spaced/);
 }

-// The same source given twice is mounted twice, at two mount points. The entry
-// point comes from the one --vfs-load contributed, not from the earlier mount
-// of the same source.
+// A source whose path holds spaces or quotes is mounted as given. Windows
+// forbids `"` in a file name, so only the spaces and the `$` are exercised
+// there.
 {
-  const dir = fixture('twice');
+  const oddName = common.isWindows ? `${id++}-od d $x` : `${id++}-od d "q" $x`;
+  const dir = path.join(tmpdir.path, oddName);
   fs.mkdirSync(dir, { recursive: true });
-  fs.writeFileSync(path.join(dir, 'index.js'),
-                   'console.log("dir:" + __dirname);\n');
-
-  const res = run([`--vfs-mount=${dir}`, `--vfs-load=${dir}`]);
+  fs.writeFileSync(path.join(dir, 'index.js'), 'console.log("ran:odd");\n');
+  const res = run([`--vfs-load=${dir}`]);
   assert.strictEqual(res.status, 0, res.stderr);
-  const [, first] = /dir:(\S+)/.exec(res.stdout);
-
-  // With the order reversed the entry point is the other mount point, which is
-  // what shows that the position decides and not the source.
-  const reversed = run([`--vfs-load=${dir}`, `--vfs-mount=${dir}`]);
-  assert.strictEqual(reversed.status, 0, reversed.stderr);
-  const [, second] = /dir:(\S+)/.exec(reversed.stdout);
-  assert.notStrictEqual(first, second);
+  assert.match(res.stdout, /ran:odd/);
 }

-// --vfs-load may only be given once: it shares one list with --vfs-mount, so a
-// second one would otherwise quietly win over the first.
+// --vfs-load may only be given once: a second one would otherwise quietly
+// replace the first.
 {
   const dirs = {};
   for (const name of ['once-a', 'once-b']) {
@@ -303,13 +295,6 @@ parentPort.postMessage('hello from esm worker in mount');
                      `--vfs-load=${dirs['once-b']}`]);
   assert.notStrictEqual(twice.status, 0);
   assert.match(twice.stderr, /--vfs-load may only be given once/);
-
-  // Repeating --vfs-mount stays allowed; only the loading one is limited.
-  const many = run([`--vfs-mount=${dirs['once-a']}`,
-                    `--vfs-load=${dirs['once-b']}`,
-                    `--vfs-mount=${dirs['once-a']}`]);
-  assert.strictEqual(many.status, 0, many.stderr);
-  assert.match(many.stdout, /ran:once-b/);
 }

 // --vfs-load picks the entry point, so it is refused in NODE_OPTIONS: the
@@ -332,74 +317,25 @@ if (hasNodeOptions) {
     assert.notStrictEqual(res.status, 0);
     assert.match(res.stderr, /--vfs-load.* is not allowed in NODE_OPTIONS/);
   }
-
-  // --vfs-mount, by contrast, is accepted from the environment.
-  const mountFromEnv = spawnSync(
-    process.execPath, ['--experimental-vfs', `--vfs-load=${dir}`], {
-      encoding: 'utf8',
-      env: { ...process.env, NODE_OPTIONS: envArg('--vfs-mount', dir) },
-    });
-  assert.strictEqual(mountFromEnv.status, 0, mountFromEnv.stderr);
 }

-// --experimental-vfs and --vfs-mount may arrive from different places. The
-// options are validated once every source has been parsed, so a mount from
-// NODE_OPTIONS is not rejected for an --experimental-vfs that only the command
-// line carries.
+// --experimental-vfs and --vfs-load may arrive from different places: the
+// options are validated once every source has been parsed, so a --vfs-load on
+// the command line is not rejected for an --experimental-vfs that only
+// NODE_OPTIONS carries.
 if (hasNodeOptions) {
-  const dir = fixture('env-mount-cli-flag');
+  const dir = fixture('env-flag-cli-load');
   fs.mkdirSync(dir, { recursive: true });
   fs.writeFileSync(path.join(dir, 'index.js'), 'console.log("ran");\n');

-  const res = spawnSync(
-    process.execPath, ['--experimental-vfs', `--vfs-load=${dir}`],
-    { encoding: 'utf8',
-      env: { ...process.env, NODE_OPTIONS: envArg('--vfs-mount', dir) } });
+  const res = spawnSync(process.execPath, [`--vfs-load=${dir}`], {
+    encoding: 'utf8',
+    env: { ...process.env, NODE_OPTIONS: '--experimental-vfs' },
+  });
   assert.strictEqual(res.status, 0, res.stderr);
   assert.match(res.stdout, /ran/);
 }

-// --vfs-mount is allowed in NODE_OPTIONS and adds to the same ordered list.
-// Because --vfs-load names its source rather than counting a position, it no
-// longer matters that the environment is parsed first: what the command line
-// loads is unaffected by how many mounts the environment contributed.
-if (hasNodeOptions) {
-  const dirs = {};
-  for (const name of ['envA', 'cliX']) {
-    dirs[name] = fixture(name);
-    fs.mkdirSync(dirs[name], { recursive: true });
-    fs.writeFileSync(path.join(dirs[name], 'index.js'),
-                     `console.log('ran:${name}');\n`);
-  }
-
-  const res = spawnSync(
-    process.execPath, ['--experimental-vfs', `--vfs-load=${dirs.cliX}`], {
-      encoding: 'utf8',
-      env: { ...process.env, NODE_OPTIONS: envArg('--vfs-mount', dirs.envA) },
-    });
-  assert.strictEqual(res.status, 0, res.stderr);
-  assert.match(res.stdout, /ran:cliX/);
-}
-
-// A mount source holding spaces or quotes survives NODE_OPTIONS when quoted,
-// which is the only way such a path can be expressed there at all. Windows
-// forbids `"` in a file name, so only the spaces and the `$` can be exercised
-// there; the quote escaping itself stays covered on every other platform.
-if (hasNodeOptions) {
-  const oddName = common.isWindows ? `${id++}-od d $x` : `${id++}-od d "q" $x`;
-  const dir = path.join(tmpdir.path, oddName);
-  fs.mkdirSync(dir, { recursive: true });
-  fs.writeFileSync(path.join(dir, 'index.js'), 'console.log("ran:odd");\n');
-
-  const res = spawnSync(
-    process.execPath, ['--experimental-vfs', `--vfs-load=${dir}`], {
-      encoding: 'utf8',
-      env: { ...process.env, NODE_OPTIONS: envArg('--vfs-mount', dir) },
-    });
-  assert.strictEqual(res.status, 0, res.stderr);
-  assert.match(res.stdout, /ran:odd/);
-}
-
 // Under --vfs-load the entry point comes from the mount, so no positional
 // argument is consumed as one: every positional reaches the program verbatim
 // from argv[2] onward, and argv[1] reports the mounted source.