|
22 | 22 | tempfile::NamedTempFile, |
23 | 23 | }; |
24 | 24 |
|
| 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 | + |
25 | 77 | /// Deploys a Next.js SSR app using standalone output mode. |
26 | 78 | /// |
27 | 79 | /// 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 |
160 | 212 | None |
161 | 213 | }; |
162 | 214 |
|
| 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 | + |
163 | 240 | // ── Step 4: record deployment as Started ───────────────────────────────── |
164 | 241 |
|
165 | 242 | 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 |
247 | 324 | remote_rel: String::new(), |
248 | 325 | // pnpm leaves symlinked package entries in standalone output. The |
249 | 326 | // 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. |
251 | 329 | copy_links: true, |
252 | 330 | // The server copy of ecosystem.config.cjs (or .js) is operator-managed |
253 | 331 | // runtime config and must survive deploys. Without this protection, |
@@ -698,3 +776,54 @@ async fn mark_failed( |
698 | 776 | .await; |
699 | 777 | } |
700 | 778 | } |
| 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