Skip to content

Commit 4894f33

Browse files
committed
sea: mount the assets VFS in workers
A single executable application built with "useVfs" mounted the VFS serving its assets in the main thread only, at a mount point numbered in mount order, so a worker started from a script among the assets could not load it. On Windows this failed as an invalid package.json at \\.\nul\package.json, because paths under the NUL device open it. Reserve layer 1 for the assets, as layer 0 is reserved for the --vfs-load source and the "vfsArchive" archive, and mount them in workers too. A worker started from a path into a VFS that is not mounted in it, such as one mounted with vfs.mount() in its parent, now fails with ERR_VFS_NOT_MOUNTED. Fixes: #67223 Assisted-by: a coding agent Signed-off-by: Manjeet Singh <024manjeetsingh@gmail.com>
1 parent c0abfea commit 4894f33

15 files changed

Lines changed: 194 additions & 20 deletions

File tree

‎doc/api/errors.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3588,6 +3588,17 @@ entry types are found.
35883588
A `--vfs-load` source does not exist, is neither a regular file nor a
35893589
directory, or is a source no provider claims.
35903590

3591+
<a id="ERR_VFS_NOT_MOUNTED"></a>
3592+
3593+
### `ERR_VFS_NOT_MOUNTED`
3594+
3595+
A path into a virtual file system was used in a thread in which that virtual
3596+
file system is not mounted. Each thread has its own mounts: a virtual file
3597+
system mounted with [`vfs.mount()`][] is not mounted in the [`Worker`][]
3598+
threads, so a worker cannot be started from a script in it. The source
3599+
mounted with [`--vfs-load`][] and the virtual file system of a
3600+
[single executable application][] are mounted in every thread.
3601+
35913602
<a id="ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING"></a>
35923603

35933604
### `ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING`
@@ -4777,6 +4788,7 @@ An error occurred trying to allocate memory. This should never happen.
47774788
[`--force-fips`]: cli.md#--force-fips
47784789
[`--no-addons`]: cli.md#--no-addons
47794790
[`--unhandled-rejections`]: cli.md#--unhandled-rejectionsmode
4791+
[`--vfs-load`]: cli.md#--vfs-loadsource
47804792
[`ABORT_ERR`]: #abort_err
47814793
[`BoundSocket`]: net.md#class-netboundsocket
47824794
[`Class: DOMException`]: globals.md#class-domexception
@@ -4795,6 +4807,7 @@ An error occurred trying to allocate memory. This should never happen.
47954807
[`REPL`]: repl.md
47964808
[`ServerResponse`]: http.md#class-httpserverresponse
47974809
[`Temporal`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal
4810+
[`Worker`]: worker_threads.md#class-worker
47984811
[`Writable`]: stream.md#class-streamwritable
47994812
[`ZipBuffer`]: zlib.md#class-zlibzipbuffer
48004813
[`ZipFile`]: zlib.md#class-zlibzipfile
@@ -4858,6 +4871,7 @@ An error occurred trying to allocate memory. This should never happen.
48584871
[`util.inspect()`]: util.md#utilinspectobject-options
48594872
[`util.parseArgs()`]: util.md#utilparseargsconfig
48604873
[`v8.startupSnapshot.setDeserializeMainFunction()`]: v8.md#v8startupsnapshotsetdeserializemainfunctioncallback-data
4874+
[`vfs.mount()`]: vfs.md#vfsmount
48614875
[`zlib`]: zlib.md
48624876
[crypto digest algorithm]: crypto.md#cryptogethashes
48634877
[debugger]: debugger.md
@@ -4868,6 +4882,7 @@ An error occurred trying to allocate memory. This should never happen.
48684882
[package map]: packages.md#package-maps
48694883
[relative URL]: https://url.spec.whatwg.org/#relative-url-string
48704884
[self-reference a package using its name]: packages.md#self-referencing-a-package-using-its-name
4885+
[single executable application]: single-executable-applications.md#virtual-file-system-vfs-for-assets
48714886
[special scheme]: https://url.spec.whatwg.org/#special-scheme
48724887
[stream-based]: stream.md
48734888
[syscall]: https://man7.org/linux/man-pages/man2/syscalls.2.html

‎doc/api/single-executable-applications.md‎

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,25 @@ Module format detection works the same way as on the real file
261261
system: name bundled ES modules with the `.mjs` extension (or provide the
262262
relevant `package.json` files as assets) so they are interpreted as ESM.
263263
264+
#### Worker threads
265+
266+
Worker threads mount the virtual file system as well, at the same mount
267+
point as the main thread. A path into it, such as the main script's
268+
`__filename` or a worker script bundled as an asset, can therefore be loaded
269+
in a [`Worker`][], which reaches the other assets the same way the main
270+
script does:
271+
272+
```cjs
273+
const { Worker } = require('node:worker_threads');
274+
const path = require('node:path');
275+
276+
// worker.js is bundled as an asset, at the root of the mount.
277+
new Worker(path.join(__dirname, 'worker.js'));
278+
```
279+
280+
A virtual file system mounted with [`vfs.mount()`][] in one thread is not
281+
mounted in the others, so a worker cannot load a script from it.
282+
264283
#### Serving the assets from a ZIP archive with `"vfsArchive"`
265284
266285
Instead of listing individual `"assets"`, the configuration can point
@@ -296,10 +315,10 @@ executable only embeds the archive; read the files through the file system
296315
APIs instead. `"vfsArchive"` requires `"useVfs": true` and cannot be
297316
combined with `"assets"`.
298317
299-
The archive is mounted at the mount point reserved for [`--vfs-load`][], which
300-
is the same in every thread, and worker threads mount it as well. A path into
301-
the archive, such as the main script's `__filename`, can therefore be loaded
302-
in a worker:
318+
The archive is mounted at the mount point reserved for [`--vfs-load`][], and,
319+
like the assets, it is mounted in [worker threads](#worker-threads) as well.
320+
A path into the archive, such as the main script's `__filename`, can therefore
321+
be loaded in a worker:
303322
304323
```cjs
305324
const { Worker, isMainThread } = require('node:worker_threads');
@@ -808,6 +827,7 @@ to help us document them.
808827
[VFS documentation]: vfs.md
809828
[Windows SDK]: https://developer.microsoft.com/en-us/windows/downloads/windows-sdk/
810829
[`--vfs-load`]: cli.md#--vfs-loadsource
830+
[`Worker`]: worker_threads.md#class-worker
811831
[`node:zlib`]: zlib.md
812832
[`process.execPath`]: process.md#processexecpath
813833
[`require()`]: modules.md#requireid
@@ -818,6 +838,7 @@ to help us document them.
818838
[`sea.getRawAsset()`]: #seagetrawassetkey
819839
[`v8.startupSnapshot.setDeserializeMainFunction()`]: v8.md#v8startupsnapshotsetdeserializemainfunctioncallback-data
820840
[`v8.startupSnapshot` API]: v8.md#startup-snapshot-api
841+
[`vfs.mount()`]: vfs.md#vfsmount
821842
[documentation about startup snapshot support in Node.js]: cli.md#--build-snapshot
822843
[fuse]: https://www.electronjs.org/docs/latest/tutorial/fuses
823844
[module loader integration]: vfs.md#module-loader-integration

‎lib/internal/errors.js‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1962,6 +1962,8 @@ E('ERR_VALID_PERFORMANCE_ENTRY_TYPE',
19621962
'At least one valid performance entry type is required', Error);
19631963
E('ERR_VFS_INVALID_TARGET',
19641964
'%s is not a valid --vfs-load source: must be an existing file or directory', Error);
1965+
E('ERR_VFS_NOT_MOUNTED',
1966+
'%s is in a virtual file system that is not mounted in this thread', Error);
19651967
E('ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING',
19661968
'A dynamic import callback was not specified.', TypeError);
19671969
E('ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING_FLAG',

‎lib/internal/main/worker_thread.js‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const {
1111
ObjectDefineProperty,
1212
PromisePrototypeThen,
1313
RegExpPrototypeExec,
14+
StringPrototypeIncludes,
1415
} = primordials;
1516

1617
const {
@@ -153,9 +154,10 @@ port.on('message', (message) => {
153154
if (getOptionValue('--import').length === 0) {
154155
finishVfsMounts();
155156
}
156-
// Unlike the --vfs-load source, a SEA archive needs no provider that a
157+
// Unlike the --vfs-load source, the SEA VFS needs no provider that a
157158
// preload could register, so its mount is never deferred.
158-
if (getOptionValue('[sea_vfs_archive]')) {
159+
if (getOptionValue('[sea_vfs_archive]') ||
160+
getOptionValue('[sea_vfs_assets]')) {
159161
require('internal/vfs/sea').initSeaVfsInWorker();
160162
}
161163
}
@@ -247,6 +249,13 @@ port.on('message', (message) => {
247249
runWebWorkerScript(source, webWorkerData);
248250
break;
249251
}
252+
// A worker does not share the VFS mounts of its parent. Report a path
253+
// into one that is not mounted here instead of letting the real file
254+
// system answer for it. Every such path contains the reserved `vfs`
255+
// directory, which spares other workers from loading the VFS.
256+
if (StringPrototypeIncludes(filename, 'vfs')) {
257+
require('internal/vfs/setup').assertVFSPathMounted(filename);
258+
}
250259
// script filename
251260
// runMain here might be monkey-patched by users in --require.
252261
// XXX: the monkey-patchability here should probably be deprecated.

‎lib/internal/vfs/file_system.js‎

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ const { VirtualReadStream, VirtualWriteStream } = require('internal/vfs/streams'
4040
const { VirtualDir } = require('internal/vfs/dir');
4141
const { emitExperimentalWarning, kEmptyObject } = require('internal/util');
4242
const { validateBoolean } = require('internal/validators');
43+
const { getOptionValue } = require('internal/options');
4344
let debug = require('internal/util/debuglog').debuglog('vfs', (fn) => {
4445
debug = fn;
4546
});
@@ -54,13 +55,23 @@ const kPromises = Symbol('kPromises');
5455
const kLayerId = Symbol('kLayerId');
5556
const kReservedRoot = Symbol('kReservedRoot');
5657
const kLoadLayer = Symbol('kLoadLayer');
58+
const kSeaLayer = Symbol('kSeaLayer');
5759

5860
// Layer 0 is reserved for the file system --vfs-load mounts, so that source is
5961
// at the same mount point in every thread whatever else a thread mounts, and a
60-
// path into it stays valid in a worker. Everything else is numbered from 1, in
61-
// the order it is mounted.
62+
// path into it stays valid in a worker. For the same reason, in a single
63+
// executable application whose VFS serves its assets, layer 1 is reserved for
64+
// them. Everything else is numbered from the next free id, in the order it is
65+
// mounted.
6266
const kLoadLayerId = 0;
63-
let nextLayerId = kLoadLayerId + 1;
67+
const kSeaLayerId = 1;
68+
let nextLayerId;
69+
70+
function allocateLayerId() {
71+
nextLayerId ??= getOptionValue('[sea_vfs_assets]') ?
72+
kSeaLayerId + 1 : kLoadLayerId + 1;
73+
return nextLayerId++;
74+
}
6475

6576
/**
6677
* Normalizes paths for mount-point comparisons. On Windows, `path.resolve('/x')`
@@ -147,7 +158,13 @@ class VirtualFileSystem {
147158
this[kMountPoint] = null;
148159
this[kNormalizedMountPoint] = null;
149160
this[kMounted] = false;
150-
this[kLayerId] = options[kLoadLayer] === true ? kLoadLayerId : nextLayerId++;
161+
if (options[kLoadLayer] === true) {
162+
this[kLayerId] = kLoadLayerId;
163+
} else if (options[kSeaLayer] === true) {
164+
this[kLayerId] = kSeaLayerId;
165+
} else {
166+
this[kLayerId] = allocateLayerId();
167+
}
151168
}
152169

153170
/**
@@ -1404,5 +1421,6 @@ module.exports = {
14041421
kLayerId,
14051422
kLoadLayer,
14061423
kReservedRoot,
1424+
kSeaLayer,
14071425
normalizeMountedPath,
14081426
};

‎lib/internal/vfs/sea.js‎

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,11 @@ function initSeaVfs(mainCode) {
4545
return null;
4646
}
4747

48-
const { VirtualFileSystem, kLoadLayer } = require('internal/vfs/file_system');
48+
const {
49+
VirtualFileSystem,
50+
kLoadLayer,
51+
kSeaLayer,
52+
} = require('internal/vfs/file_system');
4953

5054
const extraFiles = { __proto__: null, [getSeaMainName()]: mainCode };
5155
const isArchive = isVfsArchiveEnabled();
@@ -58,27 +62,27 @@ function initSeaVfs(mainCode) {
5862
}
5963
// The SEA warning already covers the feature; don't emit the
6064
// VirtualFileSystem experimental warning for the implicit SEA mount.
61-
// An archive is mounted where --vfs-load mounts its source, which is the
62-
// same mount point in every thread, so a worker can reach the code in it.
65+
// An archive is mounted where --vfs-load mounts its source, and the assets
66+
// at the mount point reserved for them. Either is the same mount point in
67+
// every thread, so a worker can reach the code in it.
6368
const vfs = new VirtualFileSystem(provider, {
6469
__proto__: null,
6570
emitExperimentalWarning: false,
6671
[kLoadLayer]: isArchive,
72+
[kSeaLayer]: !isArchive,
6773
});
6874
vfs.mount();
6975

7076
return vfs;
7177
}
7278

7379
/**
74-
* Mounts the SEA virtual file system in a worker, when it is backed by a
75-
* `"vfsArchive"`, so that a path into the mount in the main thread, such as
76-
* the main script's `__filename`, is also valid in the worker.
80+
* Mounts the SEA virtual file system in a worker, so that a path into the
81+
* mount in the main thread, such as the main script's `__filename`, is also
82+
* valid in the worker.
7783
*/
7884
function initSeaVfsInWorker() {
79-
if (isVfsArchiveEnabled()) {
80-
initSeaVfs(getMainCode());
81-
}
85+
initSeaVfs(getMainCode());
8286
}
8387

8488
/**

‎lib/internal/vfs/setup.js‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ const {
2222
ERR_INVALID_ARG_TYPE,
2323
ERR_INVALID_STATE,
2424
ERR_MODULE_NOT_FOUND,
25+
ERR_VFS_NOT_MOUNTED,
2526
},
2627
} = require('internal/errors');
2728
const { createENOENT, createEROFS, createEXDEV } = require('internal/vfs/errors');
@@ -176,6 +177,27 @@ function findVFSOrRoot(inputPath) {
176177
return { vfs, normalized };
177178
}
178179

180+
/**
181+
* Throws if `filename` is under the reserved VFS root but no layer mounted in
182+
* this thread owns it. Each thread has its own mounts, so this is the case of
183+
* a path a worker is handed into a VFS its parent mounted. Left to the native
184+
* loader, such a path fails confusingly: on Windows, where the root sits under
185+
* `\\.\nul`, the NUL device is found as the file and as an empty, invalid
186+
* package.json above it.
187+
* @param {string} filename
188+
*/
189+
function assertVFSPathMounted(filename) {
190+
const normalized = normalizeMountedPath(filename);
191+
const root = getNormalizedVfsRoot();
192+
if (normalized !== root && !StringPrototypeStartsWith(normalized, root + sep)) {
193+
return;
194+
}
195+
const layerId = getLayerIdFromPath(normalized);
196+
if (layerId === -1 || !activeVFSLayers.has(layerId)) {
197+
throw new ERR_VFS_NOT_MOUNTED(filename);
198+
}
199+
}
200+
179201
/**
180202
* Resolves a path string to the VFS that serves it for the fs functions,
181203
* or null for a path outside the reserved VFS root. Unlike the loader, the
@@ -1164,6 +1186,7 @@ function uninstallHooks() {
11641186
}
11651187

11661188
module.exports = {
1189+
assertVFSPathMounted,
11671190
registerVFS,
11681191
deregisterVFS,
11691192
};

‎src/node_options.cc‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,11 @@ void EnvironmentOptions::CheckOptions(std::vector<std::string>* errors,
319319
#ifndef DISABLE_SINGLE_EXECUTABLE_APPLICATION
320320
sea_vfs_archive = sea::IsSingleExecutable() &&
321321
sea::FindSingleExecutableResource().use_vfs_archive();
322+
// Without an archive, the VFS serves the assets themselves, and takes the
323+
// mount point reserved for them.
324+
sea_vfs_assets = sea::IsSingleExecutable() &&
325+
sea::FindSingleExecutableResource().use_vfs() &&
326+
!sea_vfs_archive;
322327
// The archive takes the mount point reserved for the --vfs-load source.
323328
if (!vfs_load_source.empty() && sea_vfs_archive) {
324329
errors->push_back(
@@ -828,6 +833,7 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() {
828833
AddOption("[vfs_load_set]", "", BOOL_FIELD(vfs_load));
829834
Implies("--vfs-load", "[vfs_load_set]");
830835
AddOption("[sea_vfs_archive]", "", BOOL_FIELD(sea_vfs_archive));
836+
AddOption("[sea_vfs_assets]", "", BOOL_FIELD(sea_vfs_assets));
831837
AddOption("--experimental-quic",
832838
#ifndef OPENSSL_NO_QUIC
833839
"experimental QUIC support",

‎src/node_options.h‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@ class EnvironmentOptions : public Options {
218218
DEFINE_BOOL_FIELD(experimental_vfs) = true;
219219
DEFINE_BOOL_FIELD(vfs_load) = false;
220220
DEFINE_BOOL_FIELD(sea_vfs_archive) = false;
221+
DEFINE_BOOL_FIELD(sea_vfs_assets) = false;
221222
DEFINE_BOOL_FIELD(webstorage) = HAVE_SQLITE;
222223
DEFINE_BOOL_FIELD(experimental_dtls) = EXPERIMENTALS_DEFAULT_VALUE;
223224
DEFINE_BOOL_FIELD(experimental_quic) = EXPERIMENTALS_DEFAULT_VALUE;

‎src/node_sea.cc‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,10 @@ bool SeaResource::use_code_cache() const {
250250
return static_cast<bool>(flags & SeaFlags::kUseCodeCache);
251251
}
252252

253+
bool SeaResource::use_vfs() const {
254+
return static_cast<bool>(flags & SeaFlags::kEnableVfs);
255+
}
256+
253257
bool SeaResource::use_vfs_archive() const {
254258
return static_cast<bool>(flags & SeaFlags::kVfsArchive);
255259
}

0 commit comments

Comments
 (0)