From e894c6afe8f3e8469b3a8bad3d1c9f35c7debb5a Mon Sep 17 00:00:00 2001 From: Seto Elkahfi Date: Sun, 5 Jul 2026 20:32:59 +0200 Subject: [PATCH 1/2] fix(deploy): prune dangling standalone symlinks before rsync The nextjs-ssr deploy uploads .next/standalone with `rsync --copy-links`, so rsync follows every symlink. On pnpm monorepos, Next's standalone tracing can leave a compat symlink pointing at a version it never wrote into the bundle, like .pnpm/node_modules/semver -> ../semver@6.3.1/... when only semver@7.x was traced. The link dangles, rsync fails trying to follow it, exits 23, and the deploy aborts with an empty stderr. Add a step 3b that walks .next/standalone after the build and deletes any symlink whose target is missing, before the rsync runs. It recurses into real directories but never follows a symlinked one, so there are no cycles. The dangling package isn't part of the traced runtime, so removing the link is safe. It prints how many it removed. Replaces the manual find/delete workaround for every pnpm monorepo. The smbcloud-deploy-nextjs skill is updated to match. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../skills/smbcloud-deploy-nextjs/SKILL.md | 25 ++-- .../src/deploy/process_deploy_nextjs_ssr.rs | 131 +++++++++++++++++- 2 files changed, 146 insertions(+), 10 deletions(-) diff --git a/.agents/skills/smbcloud-deploy-nextjs/SKILL.md b/.agents/skills/smbcloud-deploy-nextjs/SKILL.md index 1ea3d06..28c39c5 100644 --- a/.agents/skills/smbcloud-deploy-nextjs/SKILL.md +++ b/.agents/skills/smbcloud-deploy-nextjs/SKILL.md @@ -449,14 +449,20 @@ find .next/standalone/ -type l ! -exec test -e {} \; -print Fix: -- Remove the dangling link(s) before upload: +- The CLI now handles this automatically. `process_deploy_nextjs_ssr` runs a + step 3b (`prune_dangling_symlinks`) after the build and before the standalone + rsync: it walks `.next/standalone/` and deletes any symlink whose target does + not exist (real dirs are traversed; symlinked dirs are never followed). When + it removes anything it prints `Pruned N dangling symlink(s) …`. No manual step + is needed on current `smbcloud-cli`. +- Older `smb` (≤ 0.4.7) lacks the prune. On those, remove the link(s) before + upload and re-run the deploy — but the rebuild regenerates them, so it must + happen after `next build` and before rsync: `find .next/standalone/ -type l ! -exec test -e {} \; -delete` - then re-run the deploy. (The CLI rebuilds on every run, so this must happen - after `next build` and before rsync — when driving the deploy manually, - delete then run the three rsyncs yourself.) -- Proper fix belongs in the CLI: prune dangling symlinks (or use - `--copy-unsafe-links` semantics that tolerate them) before the standalone - rsync. Until then, the manual `find … -delete` is the workaround. + Driving the deploy by hand, delete then run the three rsyncs yourself. A + repo-local equivalent is a `postbuild` script that runs the same `find … + -delete`, since `smb` invokes `pnpm build` (which fires `postbuild`) in the + right window. ### Misleading deploy output @@ -527,8 +533,9 @@ Use the smallest checks that prove the contract. - keeping an old git `post-receive` hook and expecting it to manage SSR deploys - `alias`-ing `/_next/static` for a nested (monorepo) standalone build — static is under `//.next/static`, so the alias 404s; proxy everything instead -- leaving a dangling pnpm symlink in `.next/standalone/` — `rsync --copy-links` - fails with status 23; `find … -type l ! -exec test -e {} \; -delete` first +- assuming a dangling pnpm symlink in `.next/standalone/` still breaks the + deploy — current `smbcloud-cli` prunes them in step 3b before the + `rsync --copy-links` upload; only pre-prune manually on `smb` ≤ 0.4.7 - giving two hostnames one `server` block without a shared SAN cert - using static-file Nginx config for an SSR app - rsyncing `.next/standalone/` without `--copy-links` and shipping broken `pnpm` symlinks to the server diff --git a/crates/cli/src/deploy/process_deploy_nextjs_ssr.rs b/crates/cli/src/deploy/process_deploy_nextjs_ssr.rs index 0a279eb..eca2da8 100644 --- a/crates/cli/src/deploy/process_deploy_nextjs_ssr.rs +++ b/crates/cli/src/deploy/process_deploy_nextjs_ssr.rs @@ -22,6 +22,58 @@ use { tempfile::NamedTempFile, }; +/// Recursively delete symlinks under `root` whose target does not exist, +/// returning the number removed. +/// +/// Next.js standalone output on pnpm can contain compat symlinks pointing at a +/// package version that was never traced into the bundle — e.g. +/// `node_modules/.pnpm/node_modules/semver -> ../semver@6.3.1/node_modules/semver` +/// while only `semver@7.x` is actually written. The link's target is missing, +/// so it dangles. We upload `.next/standalone/` with `rsync --copy-links`, which +/// follows every symlink; a dangling one is an IO error → rsync exits 23 and +/// aborts. The dangling package is not part of the traced runtime, so dropping +/// the link is safe. See the `smbcloud-deploy-nextjs` skill for the write-up. +/// +/// Real (non-symlink) directories are traversed; a symlinked directory is +/// treated as a leaf and never followed, which also avoids symlink cycles. +fn prune_dangling_symlinks(root: &std::path::Path) -> std::io::Result { + let meta = std::fs::symlink_metadata(root)?; + + if meta.file_type().is_symlink() { + // A symlink at the walk root: drop it only if it dangles, never follow. + if !root.exists() { + std::fs::remove_file(root)?; + return Ok(1); + } + return Ok(0); + } + + if !meta.is_dir() { + return Ok(0); + } + + let mut removed = 0; + for entry in std::fs::read_dir(root)? { + let entry = entry?; + // `read_dir` file types do not follow symlinks: a link reports as a + // symlink here, and only real directories report as directories. + let file_type = entry.file_type()?; + let path = entry.path(); + + if file_type.is_symlink() { + // `exists()` follows the link; `false` means the target is missing. + if !path.exists() { + std::fs::remove_file(&path)?; + removed += 1; + } + } else if file_type.is_dir() { + removed += prune_dangling_symlinks(&path)?; + } + } + + Ok(removed) +} + /// Deploys a Next.js SSR app using standalone output mode. /// /// Requires `output: 'standalone'` in next.config.js. This produces a @@ -160,6 +212,31 @@ pub async fn process_deploy_nextjs_ssr(env: Environment, config: Config) -> Resu None }; + // ── Step 3b: prune dangling symlinks from the standalone tree ───────────── + // + // The standalone upload uses `rsync --copy-links` (see the Transfer for + // `.next/standalone/`), which follows every symlink. pnpm can leave compat + // symlinks whose target was never traced into the bundle; following one is + // an IO error that makes rsync exit 23 and abort the whole deploy. Drop them + // now — after the build, before upload — since they are not runtime files. + match prune_dangling_symlinks(standalone_path) { + Ok(0) => {} + Ok(n) => println!( + "{}", + succeed_message(&format!( + "Pruned {} dangling symlink{} from .next/standalone before upload.", + n, + if n == 1 { "" } else { "s" } + )) + ), + Err(e) => { + return Err(anyhow!(fail_message(&format!( + "Failed to prune dangling symlinks from .next/standalone: {}", + e + )))); + } + } + // ── Step 4: record deployment as Started ───────────────────────────────── let deploy_ref = Utc::now().format("%Y%m%dT%H%M%SZ").to_string(); @@ -247,7 +324,8 @@ pub async fn process_deploy_nextjs_ssr(env: Environment, config: Config) -> Resu remote_rel: String::new(), // pnpm leaves symlinked package entries in standalone output. The // server only receives this tree, so those symlinks must be - // dereferenced during upload. + // dereferenced during upload. Dangling links (targets never traced + // into the bundle) are pruned in step 3b so this does not exit 23. copy_links: true, // The server copy of ecosystem.config.cjs (or .js) is operator-managed // runtime config and must survive deploys. Without this protection, @@ -698,3 +776,54 @@ async fn mark_failed( .await; } } + +#[cfg(all(test, unix))] +mod tests { + use {super::prune_dangling_symlinks, std::os::unix::fs::symlink, tempfile::tempdir}; + + /// Mirrors the real failure: a pnpm compat link points at a version dir that + /// was never traced into the bundle, so it dangles and must be pruned, while + /// a valid link and real files are left untouched. + #[test] + fn prunes_dangling_but_keeps_valid_links_and_files() { + let root = tempdir().unwrap(); + let base = root.path(); + + // Nested real dir mirroring node_modules/.pnpm/node_modules/ + let nm = base.join("node_modules/.pnpm/node_modules"); + std::fs::create_dir_all(&nm).unwrap(); + + // A traced package dir + a valid symlink to it. + let real_pkg = base.join("node_modules/.pnpm/semver@7.7.3/node_modules/semver"); + std::fs::create_dir_all(&real_pkg).unwrap(); + std::fs::write(real_pkg.join("index.js"), "module.exports = {}").unwrap(); + let valid_link = nm.join("valid"); + symlink("../semver@7.7.3/node_modules/semver", &valid_link).unwrap(); + + // The dangling compat link: target version was never written. + let dangling = nm.join("semver"); + symlink("../semver@6.3.1/node_modules/semver", &dangling).unwrap(); + + // A plain file that must survive. + let keeper = base.join("server.js"); + std::fs::write(&keeper, "// server").unwrap(); + + let removed = prune_dangling_symlinks(base).unwrap(); + + assert_eq!(removed, 1, "exactly the dangling link should be removed"); + assert!(!dangling.exists(), "dangling link is gone"); + assert!( + std::fs::symlink_metadata(&valid_link).is_ok(), + "valid link is preserved" + ); + assert!(keeper.exists(), "real files are untouched"); + } + + #[test] + fn clean_tree_removes_nothing() { + let root = tempdir().unwrap(); + std::fs::create_dir_all(root.path().join("a/b")).unwrap(); + std::fs::write(root.path().join("a/b/f.txt"), "x").unwrap(); + assert_eq!(prune_dangling_symlinks(root.path()).unwrap(), 0); + } +} From 907c9ca3da5de4f7806471c0010e99d63583dd94 Mon Sep 17 00:00:00 2001 From: Seto Elkahfi Date: Wed, 8 Jul 2026 21:40:10 +0200 Subject: [PATCH 2/2] fix(deploy): use try_exists for prune, add cycle/root symlink tests Review follow-ups on the dangling-symlink prune: - Swap exists() for try_exists()? in both branches so a stat error on a symlink target propagates instead of being misread as "dangling" and deleting a link we merely failed to stat. - Note in the docstring that the walk depends on pnpm's layout, where every package dir is a real directory reachable directly (so nested dangling links are always visited). - Print the success symbol on the prune line to match the other step output. - Add a cycle-safety test (a dir symlink pointing back at the root must not be followed) and a root-is-dangling-symlink test. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/deploy/process_deploy_nextjs_ssr.rs | 62 +++++++++++++++++-- 1 file changed, 57 insertions(+), 5 deletions(-) diff --git a/crates/cli/src/deploy/process_deploy_nextjs_ssr.rs b/crates/cli/src/deploy/process_deploy_nextjs_ssr.rs index eca2da8..25d722b 100644 --- a/crates/cli/src/deploy/process_deploy_nextjs_ssr.rs +++ b/crates/cli/src/deploy/process_deploy_nextjs_ssr.rs @@ -35,13 +35,22 @@ use { /// the link is safe. See the `smbcloud-deploy-nextjs` skill for the write-up. /// /// Real (non-symlink) directories are traversed; a symlinked directory is -/// treated as a leaf and never followed, which also avoids symlink cycles. +/// treated as a leaf and never followed, which also avoids symlink cycles. This +/// relies on pnpm's layout, where every package directory is a real directory +/// reachable directly in the walk (symlinks only ever point *into* it): a +/// dangling link nested under a real dir is always visited and pruned. A +/// dangling link reachable *only* through a valid directory symlink would be +/// skipped, but rsync would follow the same valid link and hit it — that shape +/// does not occur in standalone output. fn prune_dangling_symlinks(root: &std::path::Path) -> std::io::Result { let meta = std::fs::symlink_metadata(root)?; if meta.file_type().is_symlink() { // A symlink at the walk root: drop it only if it dangles, never follow. - if !root.exists() { + // `try_exists` follows the link and reports Ok(false) only when the + // target is genuinely absent; a real IO error propagates instead of + // being misread as "dangling". + if !root.try_exists()? { std::fs::remove_file(root)?; return Ok(1); } @@ -61,8 +70,10 @@ fn prune_dangling_symlinks(root: &std::path::Path) -> std::io::Result { let path = entry.path(); if file_type.is_symlink() { - // `exists()` follows the link; `false` means the target is missing. - if !path.exists() { + // `try_exists` follows the link; Ok(false) means the target is + // missing (dangling). An IO error propagates rather than deleting a + // link we merely failed to stat. + if !path.try_exists()? { std::fs::remove_file(&path)?; removed += 1; } @@ -222,7 +233,8 @@ pub async fn process_deploy_nextjs_ssr(env: Environment, config: Config) -> Resu match prune_dangling_symlinks(standalone_path) { Ok(0) => {} Ok(n) => println!( - "{}", + "{} {}", + succeed_symbol(), succeed_message(&format!( "Pruned {} dangling symlink{} from .next/standalone before upload.", n, @@ -826,4 +838,44 @@ mod tests { std::fs::write(root.path().join("a/b/f.txt"), "x").unwrap(); assert_eq!(prune_dangling_symlinks(root.path()).unwrap(), 0); } + + /// A valid symlink *to a directory* is treated as a leaf and never followed. + /// The link here points back at the walk root, so following it would recurse + /// forever; terminating (and leaving the link in place) proves it is not + /// followed — the same property that keeps pnpm's symlink graph cycle-free. + #[test] + fn does_not_follow_valid_directory_symlink() { + let root = tempdir().unwrap(); + let base = root.path(); + + std::fs::write(base.join("server.js"), "// server").unwrap(); + // A valid directory symlink that points back at the root: following it + // would loop. `read_dir` reports it as a symlink, so the leaf branch + // keeps it without descending. + let dir_link = base.join("cycle"); + symlink(".", &dir_link).unwrap(); + + let removed = prune_dangling_symlinks(base).unwrap(); + + assert_eq!(removed, 0, "no dangling links, nothing removed"); + assert!( + std::fs::symlink_metadata(&dir_link).is_ok(), + "the valid directory symlink itself is preserved" + ); + } + + /// The walk root being a dangling symlink is handled by the top-level branch: + /// it is removed and counted. + #[test] + fn dangling_symlink_at_root_is_removed() { + let root = tempdir().unwrap(); + let link = root.path().join("root_link"); + symlink("./missing_target", &link).unwrap(); + + assert_eq!(prune_dangling_symlinks(&link).unwrap(), 1); + assert!( + std::fs::symlink_metadata(&link).is_err(), + "the dangling root symlink is gone" + ); + } }