Repository navigation
fix(prelude): resolve symlinks consistently in classic and SEA modes #296
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
e5c0d12
3534931
99e724b
875eede
9fec501
8d09411
034a106
e8263d9
afce27a
961543f
71d7070
22a9355
e2a33c0
6fefaec
a40f9cc
58c78c0
7f3578c
10f47ae
013dcf9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -178,7 +178,7 @@ Each file is stored with one or more store types: | |
|
|
||
| ### Runtime Bootstrap | ||
|
|
||
| `prelude/bootstrap.js` (1970 lines) executes before user code. It: | ||
| `prelude/bootstrap.js` (1949 lines) executes before user code. It: | ||
|
|
||
| 1. **Sets up entrypoint** — Reads `DEFAULT_ENTRYPOINT` from injected parameters, sets `process.argv[1]` | ||
| 2. **Initializes VFS** — Builds in-memory lookup from `VIRTUAL_FILESYSTEM` dictionary with optional path compression via `DICT` | ||
|
|
@@ -394,14 +394,15 @@ ASCII version: | |
|
|
||
| The `SEAProvider` (in `prelude/sea-vfs-setup.js`) implements lazy loading from a single archive blob: | ||
|
|
||
| | Method | Behavior | | ||
| | -------------------------- | ----------------------------------------------------------------------------- | | ||
| | `readFileSync(path)` | Resolve symlinks, `subarray()` from archive via `offsets` map, cache in `Map` | | ||
| | `statSync(path)` | Return metadata from manifest `stats` | | ||
| | `internalModuleStat(path)` | Fast path for module resolution: returns 0 (file), 1 (dir), or -2 (not found) | | ||
| | `readdirSync(path)` | Return directory entries from manifest `directories` | | ||
| | `existsSync(path)` | O(1) check against manifest `stats` | | ||
| | `readlinkSync(path)` | Return symlink target from manifest, fall back to `super.readlinkSync()` | | ||
| | Method | Behavior | | ||
| | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ||
| | `readFileSync(path)` | Resolve symlinks, `subarray()` from archive via `offsets` map, cache in `Map` | | ||
| | `statSync(path)` | Return metadata from manifest `stats` | | ||
| | `internalModuleStat(path)` | Fast path for module resolution: returns 0 (file), 1 (dir), or a negative errno (not found) | | ||
| | `readdirSync(path)` | Return directory entries from manifest `directories` | | ||
| | `existsSync(path)` | O(1) check against manifest `stats` | | ||
| | `readlinkSync(path)` | Return symlink target from manifest, fall back to `super.readlinkSync()` | | ||
| | `realpathSync(path)` | Follow the symlink chain, then return the path if the manifest has it. Load-bearing: without it every archive path raises `ENOENT`, which also breaks `fs.readlinkSync`. The VFS mounts under a POSIX `/snapshot` prefix, so on Windows the result is converted back to `C:\snapshot\...` before it leaves `fs` (yao-pkg/pkg#305) | | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The Why: Readers looking for the #305 fix will look in the wrong place, and the reflow hides the one-row change. Fix: Keep the row to one line and move the Windows rationale to the Windows-patches prose. |
||
|
|
||
| The entire archive is loaded once via `sea.getRawAsset('__pkg_archive__')` which returns a zero-copy `ArrayBuffer` reference to the executable's memory-mapped region. Individual files are extracted via `Buffer.subarray(offset, offset + length)` using the manifest's `offsets` map, then cached in a `Map` on first access. String results (when `encoding` is specified) are derived directly from the archive view; Buffer results are copied to prevent callers from corrupting the shared archive memory. | ||
|
|
||
|
|
@@ -467,7 +468,7 @@ This keeps the VFS setup, shared patches, worker interception, and diagnostics a | |
|
|
||
| ## Shared Runtime Code | ||
|
|
||
| `prelude/bootstrap-shared.js` (~438 lines) contains runtime patches used by both bootstraps: | ||
| `prelude/bootstrap-shared.js` (~791 lines) contains runtime patches used by both bootstraps: | ||
|
|
||
| ### Injection Mechanisms | ||
|
|
||
|
|
@@ -502,6 +503,12 @@ This keeps the VFS setup, shared patches, worker interception, and diagnostics a | |
| - Set `PKG_EXECPATH` env var so child processes can detect they were spawned from a packaged app | ||
| - Replace references to `node`, `process.argv[0]`, or the entrypoint with `process.execPath` (the actual executable) | ||
|
|
||
| **`makeSymlinkResolver(symlinks, sep)`** — Builds the symlink resolver used by **both** modes: the traditional bootstrap (`findVirtualFileSystemKeyAndFollowLinks`) and the SEA provider (`SEAProvider._resolveSymlink`). It returns a function mapping a virtual path onto what its symlinks point at, walking parent components the way POSIX does — so a link at `node_modules/@scope/lib` also resolves `node_modules/@scope/lib/package.json` (#295). | ||
|
|
||
| An empty `symlinks` record yields the identity function, so a symlink-free binary pays nothing. Otherwise the resolver precomputes which path depths can host a symlink key and memoises each key's fully resolved target — the memo is keyed by manifest entry, not by the caller's path, so it stays bounded by the manifest however many paths are looked up. A chain longer than `MAX_SYMLINK_DEPTH` (or a cycle in a hand-edited manifest — the walker cannot emit one) raises `ELOOP` rather than hanging startup. | ||
|
|
||
| Matching is **longest-prefix-wins**, not first-match-in-insertion-order: when both `<dir>/lib` and `<dir>/lib/sub` are keys, the deeper one describes the whole chain while the shallower one would strand the walk on a path the archive has no entry for. That differs from POSIX's leftmost-first walk, and the two agree only because every target the walker records is already a full realpath (`toNormalizedRealPath`), so no component of a target can itself be a key. | ||
|
|
||
| **`setupProcessPkg(entrypoint)`** — Creates the `process.pkg` compatibility object with `entrypoint`, `defaultEntrypoint`, and `path.resolve()`. | ||
|
|
||
| **`installDiagnostic(snapshotPrefix)`** — Installs runtime diagnostics triggered by the `DEBUG_PKG` environment variable. Available in both traditional and SEA modes. The implementation lives in `prelude/bootstrap-shared.js` and is always present in the runtime bootstrap, but it is **only invoked when the binary was built with `--debug` / `-d`** — release builds omit the entrypoint call, so the diagnostic handler never runs and cannot expose the VFS tree contents. | ||
|
|
@@ -619,11 +626,11 @@ With `node:vfs` and `"useVfs": true` in the SEA config, assets will be auto-moun | |
|
|
||
| | File | Lines | Purpose | | ||
| | -------------------------------- | ----- | -------------------------------------------------------------------------------------------- | | ||
| | `prelude/bootstrap.js` | ~1970 | Traditional runtime bootstrap (fs/module/process patching) | | ||
| | `prelude/bootstrap-shared.js` | ~486 | Shared runtime patches (dlopen, child_process, process.pkg, diagnostics) | | ||
| | `prelude/bootstrap.js` | ~1949 | Traditional runtime bootstrap (fs/module/process patching) | | ||
| | `prelude/bootstrap-shared.js` | ~791 | Shared runtime patches (dlopen, child_process, process.pkg, diagnostics, symlink resolution) | | ||
| | `prelude/sea-bootstrap.js` | ~74 | CJS wrapper: Module.runMain() (CJS) or vm.Script + USE_MAIN_CONTEXT_DEFAULT_LOADER (ESM/TLA) | | ||
| | `prelude/sea-bootstrap-core.js` | ~121 | Shared setup: VFS, patches, worker interception, diagnostics, perf start | | ||
| | `prelude/sea-vfs-setup.js` | ~469 | SEA VFS core: SEAProvider, archive loading, VFS mount, Windows patches | | ||
| | `prelude/sea-vfs-setup.js` | ~574 | SEA VFS core: SEAProvider, archive loading, VFS mount, Windows patches | | ||
| | `prelude/sea-worker-entry.js` | ~11 | Worker thread entry: requires sea-vfs-setup.js for VFS in workers | | ||
| | `scripts/build-sea-bootstrap.js` | ~50 | Build script: 2-step esbuild bundling (worker string + CJS main) | | ||
| | `lib/index.ts` | ~704 | CLI entry point, mode routing | | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -632,6 +632,160 @@ function installDiagnostic(snapshotPrefix) { | |
| } | ||
| } | ||
|
|
||
| // ///////////////////////////////////////////////////////////////// | ||
| // SYMLINK PROCESSING ////////////////////////////////////////////// | ||
| // ///////////////////////////////////////////////////////////////// | ||
|
|
||
| // Matches the typical Linux SYMLOOP_MAX. | ||
| var MAX_SYMLINK_DEPTH = 40; | ||
|
|
||
| // libuv's errno numbers differ on Windows (uv/errno.h). Negated at use. | ||
| var ERRNO = (function () { | ||
| var windows = process.platform === 'win32'; | ||
| return { | ||
| ENOTDIR: windows ? 4052 : 20, | ||
| ENOENT: windows ? 4058 : 2, | ||
| EISDIR: windows ? 4068 : 21, | ||
| ELOOP: windows ? 4067 : 40, | ||
| }; | ||
| })(); | ||
|
|
||
| /** | ||
| * Error shape for a snapshot path: code, platform errno, syscall, path and the | ||
| * `pkg` marker. Callers keep their own message — classic mode's ENOENT carries | ||
| * pkg's "recompile adding it as asset" guidance. | ||
| */ | ||
| function makeFsError(message, code, syscall, path_) { | ||
| var err = new Error(message); | ||
| err.code = code; | ||
| err.errno = -ERRNO[code]; | ||
| if (syscall !== undefined) err.syscall = syscall; | ||
| err.path = path_; | ||
| err.pkg = true; | ||
| return err; | ||
| } | ||
|
|
||
| // Memo value for a key whose resolution is still on the stack: hitting it | ||
| // again means a cycle. | ||
| var RESOLVING = {}; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The Why: This is about half the resolver, and it adds non-reentrant closure state to code that runs on every snapshot fs call. Five unit tests exist only to pin it, and Readability flagged it as hard to verify cold. Fix: Consider recursing with |
||
|
|
||
| /** | ||
| * Build a symlink resolver over a manifest's symlinks record. | ||
| * | ||
| * Maps a virtual path onto what its symlinks point at, following parent | ||
| * components the way POSIX does: a link at `node_modules/@x/y` also resolves | ||
| * `node_modules/@x/y/package.json` (#295). Runs before every snapshot fs call | ||
| * (~30K at startup on a large project). Not reentrant: hop accounting lives in | ||
| * the closure. | ||
| */ | ||
| function makeSymlinkResolver(symlinks, sep) { | ||
| var keys = Object.keys(symlinks || {}); | ||
|
|
||
| // A symlink-free binary pays nothing. | ||
| if (keys.length === 0) { | ||
| return function (p) { | ||
| return p; | ||
| }; | ||
| } | ||
|
|
||
| // Links sit at a few depths, so the scan below only slices at those. | ||
| var depthHasKey = []; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Once a binary has any symlinks, every lookup slices and hashes at each depth that holds a key (about 4–8 per path in pnpm trees), even for paths with no symlinked component. A hit is scanned twice ( Why: About 150–250K short-lived strings over ~30K Fix: Leave it unless profiling shows a cost. If it does, a |
||
| var maxDepth = 0; | ||
| for (var i = 0; i < keys.length; i++) { | ||
| var depth = 0; | ||
| var at = keys[i].indexOf(sep, 1); | ||
| while (at > 0) { | ||
| depth++; | ||
| at = keys[i].indexOf(sep, at + 1); | ||
| } | ||
| depthHasKey[depth] = true; | ||
| if (depth > maxDepth) maxDepth = depth; | ||
| } | ||
|
|
||
| // Symlink key -> { target, cost }. Keyed by manifest entry, not caller path, | ||
| // so it stays bounded by the manifest. | ||
| var resolved = new Map(); | ||
|
|
||
| // High-water hop count of the resolution in flight. A memo hit charges the | ||
| // hops its chain took, so the depth bound does not depend on lookup order. | ||
| var deepest = 0; | ||
|
|
||
| function eloop(origin) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Why: Code that branches on Fix: Accept and document it, or let callers pass the user-facing path and syscall (classic: |
||
| return makeFsError( | ||
| "ELOOP: too many symbolic links encountered, stat '" + origin + "'", | ||
| 'ELOOP', | ||
| 'stat', | ||
| origin, | ||
| ); | ||
| } | ||
|
|
||
| function follow(key, origin, hops) { | ||
| var cached = resolved.get(key); | ||
| if (cached !== undefined) { | ||
| if (cached === RESOLVING) throw eloop(origin); | ||
| var reached = hops + cached.cost; | ||
| if (reached > MAX_SYMLINK_DEPTH) throw eloop(origin); | ||
| if (reached > deepest) deepest = reached; | ||
| return cached.target; | ||
| } | ||
| resolved.set(key, RESOLVING); | ||
| // Measure this key's subtree alone, then fold it back into the caller's. | ||
| var outer = deepest; | ||
| deepest = hops; | ||
| var target; | ||
| try { | ||
| target = resolve(symlinks[key], origin, hops + 1); | ||
| } catch (e) { | ||
| // A leftover sentinel would fail later lookups that fit the bound. | ||
| resolved.delete(key); | ||
| throw e; | ||
| } | ||
| resolved.set(key, { target: target, cost: deepest - hops }); | ||
| if (outer > deepest) deepest = outer; | ||
| return target; | ||
| } | ||
|
|
||
| function resolve(p, origin, hops) { | ||
| if (hops > MAX_SYMLINK_DEPTH) throw eloop(origin); | ||
| if (hops > deepest) deepest = hops; | ||
|
|
||
| // Longest prefix wins: `<dir>/lib` and `<dir>/lib/sub` can both be keys, | ||
| // and only the deeper one describes the whole chain. This matches POSIX | ||
| // because the walker records full realpaths as targets | ||
| // (`toNormalizedRealPath` in lib/walker.ts). | ||
| if (typeof symlinks[p] === 'string') return follow(p, origin, hops); | ||
|
|
||
| var bestPos = -1; | ||
| var bestKey = null; | ||
| var pos = p.indexOf(sep, 1); | ||
| var depth = 0; | ||
| while (pos > 0 && depth <= maxDepth) { | ||
| if (depthHasKey[depth]) { | ||
| var prefix = p.slice(0, pos); | ||
| // typeof: `constructor` and friends are inherited, non-string values. | ||
| if (typeof symlinks[prefix] === 'string') { | ||
| bestPos = pos; | ||
| bestKey = prefix; | ||
| } | ||
| } | ||
| pos = p.indexOf(sep, pos + 1); | ||
| depth++; | ||
| } | ||
|
|
||
| if (bestKey === null) return p; | ||
|
|
||
| var target = follow(bestKey, origin, hops); | ||
| var rest = target.endsWith(sep) ? p.slice(bestPos + 1) : p.slice(bestPos); | ||
| // The remainder may hold links of its own. | ||
| return resolve(target + rest, origin, hops + 1); | ||
| } | ||
|
|
||
| return function resolveKey(p) { | ||
| deepest = 0; | ||
| return resolve(p, p, 0); | ||
| }; | ||
| } | ||
|
|
||
| module.exports = { | ||
| pkgCacheDir: pkgCacheDir, | ||
| patchDlopen: patchDlopen, | ||
|
|
@@ -642,4 +796,7 @@ module.exports = { | |
| COMPRESS_NONE: COMPRESS_NONE, | ||
| pickDecompressorSync: pickDecompressorSync, | ||
| pickDecompressorAsync: pickDecompressorAsync, | ||
| makeSymlinkResolver: makeSymlinkResolver, | ||
| ERRNO: ERRNO, | ||
| makeFsError: makeFsError, | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -231,25 +231,13 @@ function toOriginal(fShort) { | |
| .join(path.sep); | ||
| } | ||
|
|
||
| const symlinksEntries = Object.entries(SYMLINKS); | ||
|
|
||
| // separator for substitution depends on platform; | ||
| const sepsep = DOCOMPRESS ? separator : path.sep; | ||
|
|
||
| const resolveSymlink = REQUIRE_SHARED.makeSymlinkResolver(SYMLINKS, sepsep); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In classic mode, ELOOP from Why: This only happens with a hand-edited manifest, and the old loop hung forever in that case, so it is still an improvement. Fix: Catch ELOOP in |
||
|
|
||
| function findVirtualFileSystemKeyAndFollowLinks(path_) { | ||
| let vfsKey = findVirtualFileSystemKey(path_, path.sep); | ||
| let needToSubstitute = true; | ||
| while (needToSubstitute) { | ||
| needToSubstitute = false; | ||
| for (const [k, v] of symlinksEntries) { | ||
| if (vfsKey.startsWith(`${k}${sepsep}`) || vfsKey === k) { | ||
| vfsKey = vfsKey.replace(k, v); | ||
| needToSubstitute = true; | ||
| break; | ||
| } | ||
| } | ||
| } | ||
| return vfsKey; | ||
| return resolveSymlink(findVirtualFileSystemKey(path_, path.sep)); | ||
| } | ||
|
|
||
| function realpathFromSnapshot(path_) { | ||
|
|
@@ -521,9 +509,9 @@ function payloadFileSync(pointer) { | |
| const windows = process.platform === 'win32'; | ||
|
|
||
| const docks = {}; | ||
| const ENOTDIR = windows ? 4052 : 20; | ||
| const ENOENT = windows ? 4058 : 2; | ||
| const EISDIR = windows ? 4068 : 21; | ||
| // One errno table for both preludes — see bootstrap-shared.js. | ||
| const makeFsError = REQUIRE_SHARED.makeFsError; | ||
| const ENOENT = REQUIRE_SHARED.ERRNO.ENOENT; | ||
|
|
||
| function assertEncoding(encoding) { | ||
| if (encoding && !Buffer.isEncoding(encoding)) { | ||
|
|
@@ -536,35 +524,36 @@ function payloadFileSync(pointer) { | |
| return typeof cb === 'function' ? cb : rethrow; | ||
| } | ||
|
|
||
| // The messages stay traditional-mode's own — this one carries pkg's | ||
| // "recompile adding it as asset" guidance and test-50-not-found-wording | ||
| // asserts it. Only the shape is shared. | ||
| function error_ENOENT(fileOrDirectory, path_) { | ||
| const error = new Error( | ||
| return makeFsError( | ||
| `${fileOrDirectory} '${stripSnapshot(path_)}' ` + | ||
| `was not included into executable at compilation stage. ` + | ||
| `Please recompile adding it as asset or script.`, | ||
| 'ENOENT', | ||
| undefined, | ||
| path_, | ||
| ); | ||
| error.errno = -ENOENT; | ||
| error.code = 'ENOENT'; | ||
| error.path = path_; | ||
| error.pkg = true; | ||
| return error; | ||
| } | ||
|
|
||
| function error_EISDIR(path_) { | ||
| const error = new Error('EISDIR: illegal operation on a directory, read'); | ||
| error.errno = -EISDIR; | ||
| error.code = 'EISDIR'; | ||
| error.path = path_; | ||
| error.pkg = true; | ||
| return error; | ||
| return makeFsError( | ||
| 'EISDIR: illegal operation on a directory, read', | ||
| 'EISDIR', | ||
| 'read', | ||
| path_, | ||
| ); | ||
| } | ||
|
|
||
| function error_ENOTDIR(path_) { | ||
| const error = new Error(`ENOTDIR: not a directory, scandir '${path_}'`); | ||
| error.errno = -ENOTDIR; | ||
| error.code = 'ENOTDIR'; | ||
| error.path = path_; | ||
| error.pkg = true; | ||
| return error; | ||
| return makeFsError( | ||
| `ENOTDIR: not a directory, scandir '${path_}'`, | ||
| 'ENOTDIR', | ||
| 'scandir', | ||
| path_, | ||
| ); | ||
| } | ||
|
|
||
| // /////////////////////////////////////////////////////////////// | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With vfs 0.3.3,
fs.readlinkSyncis answered throughfindVFSForRealpath, soSEAProvider.readlinkSyncis never reached fromfs. With the newrealpathSync,readlinkon a non-link now returns the path itself instead of EINVAL, and on a link chain it returns the final target instead of the next hop. Before this PR it threw ENOENT. This row still says "Return symlink target from manifest".Why: The table hides a behaviour change that ships until #310 lands.
Fix: Say this method is not on the
fs.readlinkSyncpath (or that #310 fixes it), and add one line to the PR body.