fix(build): tolerate same-realpath symlink / stale-typed dest in assembleStandalone (#10776)

Under heavy concurrent build I/O, the bulk .build/next/standalone -> outDir tree
copy can already have carried a prior pass's result into a
NATIVE_ASSET_ENTRIES/EXTRA_MODULE_ENTRIES dest before that entry's own copy runs
(an absolute pnpm-store symlink resolving to the exact same realpath as src, or a
stale node of a different type). fs.cpSync/fs.cp refuse to overwrite either case
even with force:true, throwing ERR_FS_CP_EINVAL ("src and dest cannot be the
same") or ERR_FS_CP_DIR_TO_NON_DIR/ERR_FS_CP_NON_DIR_TO_DIR — non-deterministically
crashing the build:release/build:cli deploy pipeline on whichever entry the race
happened to hit that run.

Adds resolvesToSamePath/clearStaleDest guards to all four copy call sites (the two
sync loops in copyNativeAssetsAndExtraModules, repairEmptyExternalPackageDirs, and
the async syncNativeAssetsToDir/syncExtraModulesToDir twins) so a dest already
pointing at src is skipped and any other stale occupant is cleared before the
fresh copy.

Co-authored-by: Markus Hartung <mail@hartmark.se>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-08-19 17:58:58 -03:00
committed by GitHub
parent 3c9cb21cca
commit 3d7ed7aa87
3 changed files with 130 additions and 4 deletions

View File

@@ -0,0 +1 @@
- fix(build): tolerate a same-realpath symlink or stale-typed dest in the standalone bundle assembler, fixing non-deterministic `ERR_FS_CP_EINVAL`/`ERR_FS_CP_DIR_TO_NON_DIR` crashes under heavy concurrent build I/O

View File

@@ -347,7 +347,10 @@ async function syncNativeAssetsToDir(projectRoot, outDir, fsImpl, log) {
if (!(await exists(sourcePath))) continue;
const destinationPath = path.join(outDir, ...entry.dest);
if (path.resolve(sourcePath) === path.resolve(destinationPath)) continue;
// See resolvesToSamePath/clearStaleDest (sync copy path, same module) — the same
// ERR_FS_CP_EINVAL/ERR_FS_CP_DIR_TO_NON_DIR races apply to fsImpl.cp here.
if (resolvesToSamePath(sourcePath, destinationPath)) continue;
clearStaleDest(destinationPath);
const mkdir =
typeof fsImpl.mkdir === "function" ? fsImpl.mkdir.bind(fsImpl) : fs.mkdir.bind(fs);
@@ -385,7 +388,8 @@ async function syncExtraModulesToDir(projectRoot, outDir, fsImpl, log) {
if (!(await exists(sourcePath))) continue;
const destPath = path.join(outDir, ...entry.dest);
if (path.resolve(sourcePath) === path.resolve(destPath)) continue;
if (resolvesToSamePath(sourcePath, destPath)) continue;
clearStaleDest(destPath);
const mkdir =
typeof fsImpl.mkdir === "function" ? fsImpl.mkdir.bind(fsImpl) : fs.mkdir.bind(fs);
@@ -534,6 +538,46 @@ function copyStaticAndPublic({ distDir, relDistDir, projectRoot, resolvedOutDir
}
}
/**
* Two independent copy passes assemble a bundle: the bulk "standalone -> outDir" tree
* copy (step 1 of assembleStandalone) can already have carried a prior entry's result
* into `dest` (e.g. an absolute pnpm-store symlink, or a directory) BEFORE this entry's
* own copy runs. `fs.cpSync`/`fs.cp` refuse to overwrite in two such cases even with
* `force: true`:
* - dest already resolves (via symlink chain) to the exact same real path as src ->
* ERR_FS_CP_EINVAL "src and dest cannot be the same".
* - dest exists with a different node type than src (file/symlink vs directory) ->
* ERR_FS_CP_DIR_TO_NON_DIR / ERR_FS_CP_NON_DIR_TO_DIR.
* Under heavy concurrent build I/O this manifested non-deterministically across
* different EXTRA_MODULE_ENTRIES/NATIVE_ASSET_ENTRIES on every retry. Resolve both
* cases up front: skip entirely when dest is already the right target, otherwise clear
* whatever stale node occupies dest (via lstat, so it also removes a broken symlink)
* so the fresh copy always lands cleanly.
*
* @param {string} src
* @param {string} dest
* @returns {boolean} true when dest already IS src's target and no copy is needed
*/
function resolvesToSamePath(src, dest) {
if (path.resolve(src) === path.resolve(dest)) return true;
if (!fsSync.existsSync(dest)) return false;
try {
return fsSync.realpathSync(src) === fsSync.realpathSync(dest);
} catch {
return false;
}
}
/** @see resolvesToSamePath — clears whatever stale node sits at `dest` before a copy. */
function clearStaleDest(dest) {
try {
fsSync.lstatSync(dest);
} catch {
return;
}
fsSync.rmSync(dest, { recursive: true, force: true });
}
/**
* Copy native assets (better-sqlite3 and TPROXY) and extra runtime modules/sidecars
* (wreq-js, pino, migrations, MITM server, helper scripts, sqlite-vec platform packages, …)
@@ -547,7 +591,8 @@ function copyNativeAssetsAndExtraModules(projectRoot, resolvedOutDir) {
const src = path.join(projectRoot, ...asset.src);
if (!fsSync.existsSync(src)) continue;
const dest = path.join(resolvedOutDir, ...asset.dest);
if (path.resolve(src) === path.resolve(dest)) continue;
if (resolvesToSamePath(src, dest)) continue;
clearStaleDest(dest);
fsSync.mkdirSync(path.dirname(dest), { recursive: true });
fsSync.cpSync(src, dest, { recursive: true, force: true });
console.log(`[assembleStandalone] Copied native asset: ${asset.label}`);
@@ -557,7 +602,8 @@ function copyNativeAssetsAndExtraModules(projectRoot, resolvedOutDir) {
const src = path.join(projectRoot, ...mod.src);
if (!fsSync.existsSync(src)) continue;
const dest = path.join(resolvedOutDir, ...mod.dest);
if (path.resolve(src) === path.resolve(dest)) continue;
if (resolvesToSamePath(src, dest)) continue;
clearStaleDest(dest);
fsSync.mkdirSync(path.dirname(dest), { recursive: true });
fsSync.cpSync(src, dest, { recursive: true, force: true });
console.log(`[assembleStandalone] Synced module: ${mod.label}`);
@@ -617,6 +663,12 @@ function repairEmptyExternalPackageDirs(projectRoot, resolvedOutDir) {
continue;
}
if (!sourceStat.isDirectory()) continue;
// See resolvesToSamePath/clearStaleDest above: bundlePkgDir can itself be a
// symlink to sourcePkgDir's realpath whose target momentarily read as empty
// under heavy concurrent build I/O (a transient readdirSync race, not a real
// hollow placeholder), or a stale non-directory node from an earlier pass.
if (resolvesToSamePath(sourcePkgDir, bundlePkgDir)) continue;
clearStaleDest(bundlePkgDir);
fsSync.cpSync(sourcePkgDir, bundlePkgDir, { recursive: true, force: true });
summary.repaired += 1;

View File

@@ -216,3 +216,76 @@ test("every relative import of standalone-server-ws.mjs is shipped into the bund
}
fs.rmSync(tmp, { recursive: true, force: true });
});
// Regression guard (deploy 2026-08-19): under heavy concurrent build I/O the bulk
// "standalone -> outDir" tree copy can already have carried a prior pass's result into
// an EXTRA_MODULE_ENTRIES/NATIVE_ASSET_ENTRIES `dest` BEFORE that entry's own copy runs
// — either an absolute symlink resolving to the exact same real path as `src` (a pnpm
// store layout), or a stale node of a different type (file/symlink vs directory). Node's
// fs.cpSync/fs.cp refuse both cases even with force:true, throwing ERR_FS_CP_EINVAL
// ("src and dest cannot be the same") or ERR_FS_CP_DIR_TO_NON_DIR/ERR_FS_CP_NON_DIR_TO_DIR
// respectively, crashing every one of copyNativeAssetsAndExtraModules,
// repairEmptyExternalPackageDirs, syncNativeAssetsToDir, and syncExtraModulesToDir.
test("copy passes tolerate a dest that already resolves to src, or a stale-typed dest", async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "assemble-race-"));
const projectRoot = path.join(tmp, "src-root");
seedSidecarSources(projectRoot);
// Case 1 (sync path): dest already an absolute symlink resolving to src's realpath —
// simulates the wreq-js entry after the .build/next/standalone bulk copy already
// carried an absolute symlink over from an earlier standalone build.
const distDir = path.join(projectRoot, ".build/next");
fs.mkdirSync(path.join(distDir, "standalone"), { recursive: true });
fs.writeFileSync(path.join(distDir, "standalone", "server.js"), "// server");
const outSync = path.join(tmp, "out-sync");
fs.mkdirSync(path.join(outSync, "node_modules"), { recursive: true });
fs.symlinkSync(
path.join(projectRoot, "node_modules/wreq-js"),
path.join(outSync, "node_modules/wreq-js")
);
// Case 2 (sync path): dest already a plain FILE where src is a directory —
// simulates @swc/helpers landing as a stray file from an unrelated earlier copy.
fs.mkdirSync(path.join(outSync, "node_modules/@swc"), { recursive: true });
fs.writeFileSync(path.join(outSync, "node_modules/@swc/helpers"), "stale file, not a dir");
assert.doesNotThrow(() => {
assembleStandalone({
distDir,
outDir: outSync,
projectRoot,
sanitizePaths: false,
copyNatives: true,
});
}, "assembleStandalone must not throw on a same-realpath symlink or a stale-typed dest");
assert.ok(
fs.existsSync(path.join(outSync, "node_modules/wreq-js/rust/lib.so")),
"wreq-js content reachable through the pre-existing symlink"
);
assert.ok(
fs.statSync(path.join(outSync, "node_modules/@swc/helpers")).isDirectory(),
"the stale file at @swc/helpers was replaced by the real directory"
);
assert.ok(
fs.existsSync(path.join(outSync, "node_modules/@swc/helpers/package.json")),
"@swc/helpers content copied after clearing the stale file"
);
// Case 3 (async path): same real-path-symlink collision hits syncStandaloneExtraModules.
const outAsync = path.join(tmp, "out-async");
fs.mkdirSync(path.join(outAsync, "node_modules"), { recursive: true });
fs.symlinkSync(
path.join(projectRoot, "node_modules/sql.js"),
path.join(outAsync, "node_modules/sql.js")
);
await assert.doesNotReject(
() => syncStandaloneExtraModules(projectRoot, fs.promises, { log() {} }, outAsync),
"syncStandaloneExtraModules must not throw on a same-realpath symlink"
);
assert.ok(
fs.existsSync(path.join(outAsync, "node_modules/sql.js/dist/sql-wasm.js")),
"sql.js content reachable through the pre-existing symlink"
);
fs.rmSync(tmp, { recursive: true, force: true });
});