Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
e5c0d12
fix(sea): files inside symlinks are not resolved correctly (#295)
mpotthoff Aug 20, 2026
3534931
Address review comments
mpotthoff Aug 25, 2026
99e724b
refactor(prelude): bound symlink memo and shorten the resolution walk
robertsLando Sep 4, 2026
875eede
fix(prelude): keep exact symlink entries ahead of their symlinked parent
robertsLando Sep 4, 2026
9fec501
fix(sea): resolve symlinks in realpath, which also unbreaks readlink
robertsLando Sep 4, 2026
8d09411
fix(prelude): match the deepest symlink key and charge cached hops
robertsLando Sep 7, 2026
034a106
fix(prelude): report symlinks as links in readdir withFileTypes
robertsLando Sep 7, 2026
e8263d9
test(#295): build the symlink fixture at test time so Windows runs it
robertsLando Sep 7, 2026
afce27a
docs: sync the two bootstrap-shared.js line counts
robertsLando Sep 7, 2026
961543f
fix(prelude): service symlinks in classic-mode readlink and lstat
robertsLando Sep 7, 2026
71d7070
fix: address review round 1 on symlink resolution
robertsLando Sep 18, 2026
22a9355
fix(sea): report symlinks in lstat, readlink and readdir
robertsLando Sep 18, 2026
e2a33c0
fix: route ELOOP through callbacks, share the readlink contract
robertsLando Sep 18, 2026
6fefaec
chore: require @roberts_lando/vfs ^0.3.4 for the lstat/readlink routing
robertsLando Sep 18, 2026
a40f9cc
fix: close the remaining ELOOP call sites and share the parent walk
robertsLando Sep 18, 2026
58c78c0
refactor(prelude): one error shape, one link-target policy, one ELOOP…
robertsLando Sep 18, 2026
7f3578c
chore: install @roberts_lando/vfs from the PR commit until it releases
robertsLando Sep 18, 2026
10f47ae
fix(prelude): give the identity resolver a .parent
robertsLando Sep 18, 2026
013dcf9
refactor(prelude): narrow #296 to symlink resolution
robertsLando Oct 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,8 @@ examples/express/node_modules
yarn-error.log
tsconfig.tsbuildinfo
prelude/sea-bootstrap.bundle.js

# Built by test-99-#295/main.js at test time, removed again afterwards
test/test-99-#295/lib
test/test-99-#295/reallib/inner.js
test/test-99-#295/linkinfo.json
33 changes: 20 additions & 13 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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()` |

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor Design/API

With vfs 0.3.3, fs.readlinkSync is answered through findVFSForRealpath, so SEAProvider.readlinkSync is never reached from fs. With the new realpathSync, readlink on 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.readlinkSync path (or that #310 fixes it), and add one line to the PR body.

| `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) |

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor Design/API

The C:\snapshot\... conversion is described as part of SEAProvider.realpathSync, but the code patches VirtualFileSystem.prototype.realpathSync, next to the existing shouldHandle/resolvePath win32 patches. The ~300-character row also re-pads every row of the table.

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.

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 |
Expand Down
157 changes: 157 additions & 0 deletions prelude/bootstrap-shared.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {};

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Major Simplicity

The resolved memo, the RESOLVING sentinel, follow(), the deepest high-water mark, per-key cost billing and the try/catch that drops the sentinel exist to keep a memoised walk order-independent under ELOOP. Walker.appendSymlink stores a full realpath as every target, so a real manifest only has one-hop chains and no cycles. The memo saves one hash lookup per hop, and the billing fixes a problem the memo itself creates.

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 symlinks[key] + rest and keeping the plain hops > MAX_SYMLINK_DEPTH check, which already turns a hand-edited cycle into ELOOP. That saves about 35 prelude lines and about 70 test lines. An earlier round added the memo on purpose (4fcf2cb), so this is your call.


/**
* 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 = [];

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor Performance

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 (resolve(target + rest)).

Why: About 150–250K short-lived strings over ~30K internalModuleStat calls. This is estimated from reading the code, not measured. Simplicity argues the opposite: drop the depthHasKey precompute as premature.

Fix: Leave it unless profiling shows a cost. If it does, a Set of the first path segments of every key gives a cheap skip.

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) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor Operability

eloop() always reports syscall: 'stat' and uses the internal key as path and in the message. Under DOCOMPRESS that key is dictionary-compressed, and on win32 SEA it is the POSIX /snapshot/... form. A readFileSync/readdirSync ELOOP also reports stat.

Why: Code that branches on err.syscall, and anyone reading the log, gets the wrong call and a mangled path. Only reachable with a hand-edited manifest.

Fix: Accept and document it, or let callers pass the user-facing path and syscall (classic: toOriginal).

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,
Expand All @@ -642,4 +796,7 @@ module.exports = {
COMPRESS_NONE: COMPRESS_NONE,
pickDecompressorSync: pickDecompressorSync,
pickDecompressorAsync: pickDecompressorAsync,
makeSymlinkResolver: makeSymlinkResolver,
ERRNO: ERRNO,
makeFsError: makeFsError,
};
61 changes: 25 additions & 36 deletions prelude/bootstrap.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor Correctness

In classic mode, ELOOP from resolveSymlink can now escape existsFromSnapshot (existsSync/exists), fs.internalModuleStat, accessFromSnapshot and _compile. Real existsSync returns false, and internalModuleStat should return a negative errno. SEA mode is safe because vfs wraps both in try/catch.

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 existsFromSnapshot and fs.internalModuleStat, returning false / -ERRNO.ELOOP.


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_) {
Expand Down Expand Up @@ -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)) {
Expand All @@ -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_,
);
}

// ///////////////////////////////////////////////////////////////
Expand Down
Loading