From 3d7ed7aa87a316e392bb0d9bfb62ba1fe51dc43d Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Wed, 19 Aug 2026 17:58:58 -0300 Subject: [PATCH] fix(build): tolerate same-realpath symlink / stale-typed dest in assembleStandalone (#10776) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../fixes/assemble-standalone-cpsync-race.md | 1 + scripts/build/assembleStandalone.mjs | 60 ++++++++++++++- tests/unit/build/assemble-standalone.test.ts | 73 +++++++++++++++++++ 3 files changed, 130 insertions(+), 4 deletions(-) create mode 100644 changelog.d/fixes/assemble-standalone-cpsync-race.md diff --git a/changelog.d/fixes/assemble-standalone-cpsync-race.md b/changelog.d/fixes/assemble-standalone-cpsync-race.md new file mode 100644 index 0000000000..82fabbcad6 --- /dev/null +++ b/changelog.d/fixes/assemble-standalone-cpsync-race.md @@ -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 diff --git a/scripts/build/assembleStandalone.mjs b/scripts/build/assembleStandalone.mjs index 069bd13f6d..ccdd61a654 100644 --- a/scripts/build/assembleStandalone.mjs +++ b/scripts/build/assembleStandalone.mjs @@ -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; diff --git a/tests/unit/build/assemble-standalone.test.ts b/tests/unit/build/assemble-standalone.test.ts index 14c7d890b9..14a8de854b 100644 --- a/tests/unit/build/assemble-standalone.test.ts +++ b/tests/unit/build/assemble-standalone.test.ts @@ -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 }); +});