mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-20 06:02:14 +03:00
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:
committed by
GitHub
parent
3c9cb21cca
commit
3d7ed7aa87
1
changelog.d/fixes/assemble-standalone-cpsync-race.md
Normal file
1
changelog.d/fixes/assemble-standalone-cpsync-race.md
Normal 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
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user