Skip to content

Commit e894c6a

Browse files
setoelkahficlaude
andcommitted
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) <noreply@anthropic.com>
1 parent 32132b0 commit e894c6a

2 files changed

Lines changed: 146 additions & 10 deletions

File tree

.agents/skills/smbcloud-deploy-nextjs/SKILL.md

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -449,14 +449,20 @@ find .next/standalone/ -type l ! -exec test -e {} \; -print
449449
450450
Fix:
451451
452-
- Remove the dangling link(s) before upload:
452+
- The CLI now handles this automatically. `process_deploy_nextjs_ssr` runs a
453+
step 3b (`prune_dangling_symlinks`) after the build and before the standalone
454+
rsync: it walks `.next/standalone/` and deletes any symlink whose target does
455+
not exist (real dirs are traversed; symlinked dirs are never followed). When
456+
it removes anything it prints `Pruned N dangling symlink(s) …`. No manual step
457+
is needed on current `smbcloud-cli`.
458+
- Older `smb` (≤ 0.4.7) lacks the prune. On those, remove the link(s) before
459+
upload and re-run the deploy — but the rebuild regenerates them, so it must
460+
happen after `next build` and before rsync:
453461
`find .next/standalone/ -type l ! -exec test -e {} \; -delete`
454-
then re-run the deploy. (The CLI rebuilds on every run, so this must happen
455-
after `next build` and before rsync — when driving the deploy manually,
456-
delete then run the three rsyncs yourself.)
457-
- Proper fix belongs in the CLI: prune dangling symlinks (or use
458-
`--copy-unsafe-links` semantics that tolerate them) before the standalone
459-
rsync. Until then, the manual `find … -delete` is the workaround.
462+
Driving the deploy by hand, delete then run the three rsyncs yourself. A
463+
repo-local equivalent is a `postbuild` script that runs the same `find …
464+
-delete`, since `smb` invokes `pnpm build` (which fires `postbuild`) in the
465+
right window.
460466
461467
### Misleading deploy output
462468
@@ -527,8 +533,9 @@ Use the smallest checks that prove the contract.
527533
- keeping an old git `post-receive` hook and expecting it to manage SSR deploys
528534
- `alias`-ing `/_next/static` for a nested (monorepo) standalone build — static
529535
is under `<app>/<source>/.next/static`, so the alias 404s; proxy everything instead
530-
- leaving a dangling pnpm symlink in `.next/standalone/` — `rsync --copy-links`
531-
fails with status 23; `find … -type l ! -exec test -e {} \; -delete` first
536+
- assuming a dangling pnpm symlink in `.next/standalone/` still breaks the
537+
deploy — current `smbcloud-cli` prunes them in step 3b before the
538+
`rsync --copy-links` upload; only pre-prune manually on `smb` ≤ 0.4.7
532539
- giving two hostnames one `server` block without a shared SAN cert
533540
- using static-file Nginx config for an SSR app
534541
- rsyncing `.next/standalone/` without `--copy-links` and shipping broken `pnpm` symlinks to the server

crates/cli/src/deploy/process_deploy_nextjs_ssr.rs

Lines changed: 130 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,58 @@ use {
2222
tempfile::NamedTempFile,
2323
};
2424

25+
/// Recursively delete symlinks under `root` whose target does not exist,
26+
/// returning the number removed.
27+
///
28+
/// Next.js standalone output on pnpm can contain compat symlinks pointing at a
29+
/// package version that was never traced into the bundle — e.g.
30+
/// `node_modules/.pnpm/node_modules/semver -> ../semver@6.3.1/node_modules/semver`
31+
/// while only `semver@7.x` is actually written. The link's target is missing,
32+
/// so it dangles. We upload `.next/standalone/` with `rsync --copy-links`, which
33+
/// follows every symlink; a dangling one is an IO error → rsync exits 23 and
34+
/// aborts. The dangling package is not part of the traced runtime, so dropping
35+
/// the link is safe. See the `smbcloud-deploy-nextjs` skill for the write-up.
36+
///
37+
/// Real (non-symlink) directories are traversed; a symlinked directory is
38+
/// treated as a leaf and never followed, which also avoids symlink cycles.
39+
fn prune_dangling_symlinks(root: &std::path::Path) -> std::io::Result<usize> {
40+
let meta = std::fs::symlink_metadata(root)?;
41+
42+
if meta.file_type().is_symlink() {
43+
// A symlink at the walk root: drop it only if it dangles, never follow.
44+
if !root.exists() {
45+
std::fs::remove_file(root)?;
46+
return Ok(1);
47+
}
48+
return Ok(0);
49+
}
50+
51+
if !meta.is_dir() {
52+
return Ok(0);
53+
}
54+
55+
let mut removed = 0;
56+
for entry in std::fs::read_dir(root)? {
57+
let entry = entry?;
58+
// `read_dir` file types do not follow symlinks: a link reports as a
59+
// symlink here, and only real directories report as directories.
60+
let file_type = entry.file_type()?;
61+
let path = entry.path();
62+
63+
if file_type.is_symlink() {
64+
// `exists()` follows the link; `false` means the target is missing.
65+
if !path.exists() {
66+
std::fs::remove_file(&path)?;
67+
removed += 1;
68+
}
69+
} else if file_type.is_dir() {
70+
removed += prune_dangling_symlinks(&path)?;
71+
}
72+
}
73+
74+
Ok(removed)
75+
}
76+
2577
/// Deploys a Next.js SSR app using standalone output mode.
2678
///
2779
/// 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
160212
None
161213
};
162214

215+
// ── Step 3b: prune dangling symlinks from the standalone tree ─────────────
216+
//
217+
// The standalone upload uses `rsync --copy-links` (see the Transfer for
218+
// `.next/standalone/`), which follows every symlink. pnpm can leave compat
219+
// symlinks whose target was never traced into the bundle; following one is
220+
// an IO error that makes rsync exit 23 and abort the whole deploy. Drop them
221+
// now — after the build, before upload — since they are not runtime files.
222+
match prune_dangling_symlinks(standalone_path) {
223+
Ok(0) => {}
224+
Ok(n) => println!(
225+
"{}",
226+
succeed_message(&format!(
227+
"Pruned {} dangling symlink{} from .next/standalone before upload.",
228+
n,
229+
if n == 1 { "" } else { "s" }
230+
))
231+
),
232+
Err(e) => {
233+
return Err(anyhow!(fail_message(&format!(
234+
"Failed to prune dangling symlinks from .next/standalone: {}",
235+
e
236+
))));
237+
}
238+
}
239+
163240
// ── Step 4: record deployment as Started ─────────────────────────────────
164241

165242
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
247324
remote_rel: String::new(),
248325
// pnpm leaves symlinked package entries in standalone output. The
249326
// server only receives this tree, so those symlinks must be
250-
// dereferenced during upload.
327+
// dereferenced during upload. Dangling links (targets never traced
328+
// into the bundle) are pruned in step 3b so this does not exit 23.
251329
copy_links: true,
252330
// The server copy of ecosystem.config.cjs (or .js) is operator-managed
253331
// runtime config and must survive deploys. Without this protection,
@@ -698,3 +776,54 @@ async fn mark_failed(
698776
.await;
699777
}
700778
}
779+
780+
#[cfg(all(test, unix))]
781+
mod tests {
782+
use {super::prune_dangling_symlinks, std::os::unix::fs::symlink, tempfile::tempdir};
783+
784+
/// Mirrors the real failure: a pnpm compat link points at a version dir that
785+
/// was never traced into the bundle, so it dangles and must be pruned, while
786+
/// a valid link and real files are left untouched.
787+
#[test]
788+
fn prunes_dangling_but_keeps_valid_links_and_files() {
789+
let root = tempdir().unwrap();
790+
let base = root.path();
791+
792+
// Nested real dir mirroring node_modules/.pnpm/node_modules/
793+
let nm = base.join("node_modules/.pnpm/node_modules");
794+
std::fs::create_dir_all(&nm).unwrap();
795+
796+
// A traced package dir + a valid symlink to it.
797+
let real_pkg = base.join("node_modules/.pnpm/semver@7.7.3/node_modules/semver");
798+
std::fs::create_dir_all(&real_pkg).unwrap();
799+
std::fs::write(real_pkg.join("index.js"), "module.exports = {}").unwrap();
800+
let valid_link = nm.join("valid");
801+
symlink("../semver@7.7.3/node_modules/semver", &valid_link).unwrap();
802+
803+
// The dangling compat link: target version was never written.
804+
let dangling = nm.join("semver");
805+
symlink("../semver@6.3.1/node_modules/semver", &dangling).unwrap();
806+
807+
// A plain file that must survive.
808+
let keeper = base.join("server.js");
809+
std::fs::write(&keeper, "// server").unwrap();
810+
811+
let removed = prune_dangling_symlinks(base).unwrap();
812+
813+
assert_eq!(removed, 1, "exactly the dangling link should be removed");
814+
assert!(!dangling.exists(), "dangling link is gone");
815+
assert!(
816+
std::fs::symlink_metadata(&valid_link).is_ok(),
817+
"valid link is preserved"
818+
);
819+
assert!(keeper.exists(), "real files are untouched");
820+
}
821+
822+
#[test]
823+
fn clean_tree_removes_nothing() {
824+
let root = tempdir().unwrap();
825+
std::fs::create_dir_all(root.path().join("a/b")).unwrap();
826+
std::fs::write(root.path().join("a/b/f.txt"), "x").unwrap();
827+
assert_eq!(prune_dangling_symlinks(root.path()).unwrap(), 0);
828+
}
829+
}

0 commit comments

Comments
 (0)