|
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. This |
| 39 | +/// relies on pnpm's layout, where every package directory is a real directory |
| 40 | +/// reachable directly in the walk (symlinks only ever point *into* it): a |
| 41 | +/// dangling link nested under a real dir is always visited and pruned. A |
| 42 | +/// dangling link reachable *only* through a valid directory symlink would be |
| 43 | +/// skipped, but rsync would follow the same valid link and hit it — that shape |
| 44 | +/// does not occur in standalone output. |
| 45 | +fn prune_dangling_symlinks(root: &std::path::Path) -> std::io::Result<usize> { |
| 46 | + let meta = std::fs::symlink_metadata(root)?; |
| 47 | + |
| 48 | + if meta.file_type().is_symlink() { |
| 49 | + // A symlink at the walk root: drop it only if it dangles, never follow. |
| 50 | + // `try_exists` follows the link and reports Ok(false) only when the |
| 51 | + // target is genuinely absent; a real IO error propagates instead of |
| 52 | + // being misread as "dangling". |
| 53 | + if !root.try_exists()? { |
| 54 | + std::fs::remove_file(root)?; |
| 55 | + return Ok(1); |
| 56 | + } |
| 57 | + return Ok(0); |
| 58 | + } |
| 59 | + |
| 60 | + if !meta.is_dir() { |
| 61 | + return Ok(0); |
| 62 | + } |
| 63 | + |
| 64 | + let mut removed = 0; |
| 65 | + for entry in std::fs::read_dir(root)? { |
| 66 | + let entry = entry?; |
| 67 | + // `read_dir` file types do not follow symlinks: a link reports as a |
| 68 | + // symlink here, and only real directories report as directories. |
| 69 | + let file_type = entry.file_type()?; |
| 70 | + let path = entry.path(); |
| 71 | + |
| 72 | + if file_type.is_symlink() { |
| 73 | + // `try_exists` follows the link; Ok(false) means the target is |
| 74 | + // missing (dangling). An IO error propagates rather than deleting a |
| 75 | + // link we merely failed to stat. |
| 76 | + if !path.try_exists()? { |
| 77 | + std::fs::remove_file(&path)?; |
| 78 | + removed += 1; |
| 79 | + } |
| 80 | + } else if file_type.is_dir() { |
| 81 | + removed += prune_dangling_symlinks(&path)?; |
| 82 | + } |
| 83 | + } |
| 84 | + |
| 85 | + Ok(removed) |
| 86 | +} |
| 87 | + |
25 | 88 | /// Deploys a Next.js SSR app using standalone output mode. |
26 | 89 | /// |
27 | 90 | /// Requires `output: 'standalone'` in next.config.js. This produces a |
@@ -160,6 +223,32 @@ pub async fn process_deploy_nextjs_ssr(env: Environment, config: Config) -> Resu |
160 | 223 | None |
161 | 224 | }; |
162 | 225 |
|
| 226 | + // ── Step 3b: prune dangling symlinks from the standalone tree ───────────── |
| 227 | + // |
| 228 | + // The standalone upload uses `rsync --copy-links` (see the Transfer for |
| 229 | + // `.next/standalone/`), which follows every symlink. pnpm can leave compat |
| 230 | + // symlinks whose target was never traced into the bundle; following one is |
| 231 | + // an IO error that makes rsync exit 23 and abort the whole deploy. Drop them |
| 232 | + // now — after the build, before upload — since they are not runtime files. |
| 233 | + match prune_dangling_symlinks(standalone_path) { |
| 234 | + Ok(0) => {} |
| 235 | + Ok(n) => println!( |
| 236 | + "{} {}", |
| 237 | + succeed_symbol(), |
| 238 | + succeed_message(&format!( |
| 239 | + "Pruned {} dangling symlink{} from .next/standalone before upload.", |
| 240 | + n, |
| 241 | + if n == 1 { "" } else { "s" } |
| 242 | + )) |
| 243 | + ), |
| 244 | + Err(e) => { |
| 245 | + return Err(anyhow!(fail_message(&format!( |
| 246 | + "Failed to prune dangling symlinks from .next/standalone: {}", |
| 247 | + e |
| 248 | + )))); |
| 249 | + } |
| 250 | + } |
| 251 | + |
163 | 252 | // ── Step 4: record deployment as Started ───────────────────────────────── |
164 | 253 |
|
165 | 254 | let deploy_ref = Utc::now().format("%Y%m%dT%H%M%SZ").to_string(); |
@@ -281,7 +370,8 @@ pub async fn process_deploy_nextjs_ssr(env: Environment, config: Config) -> Resu |
281 | 370 | remote_rel: String::new(), |
282 | 371 | // pnpm leaves symlinked package entries in standalone output. The |
283 | 372 | // server only receives this tree, so those symlinks must be |
284 | | - // dereferenced during upload. |
| 373 | + // dereferenced during upload. Dangling links (targets never traced |
| 374 | + // into the bundle) are pruned in step 3b so this does not exit 23. |
285 | 375 | copy_links: true, |
286 | 376 | // The server copy of ecosystem.config.cjs (or .js) is operator-managed |
287 | 377 | // runtime config and must survive deploys. Without this protection, |
@@ -735,3 +825,94 @@ async fn mark_failed( |
735 | 825 | .await; |
736 | 826 | } |
737 | 827 | } |
| 828 | + |
| 829 | +#[cfg(all(test, unix))] |
| 830 | +mod tests { |
| 831 | + use {super::prune_dangling_symlinks, std::os::unix::fs::symlink, tempfile::tempdir}; |
| 832 | + |
| 833 | + /// Mirrors the real failure: a pnpm compat link points at a version dir that |
| 834 | + /// was never traced into the bundle, so it dangles and must be pruned, while |
| 835 | + /// a valid link and real files are left untouched. |
| 836 | + #[test] |
| 837 | + fn prunes_dangling_but_keeps_valid_links_and_files() { |
| 838 | + let root = tempdir().unwrap(); |
| 839 | + let base = root.path(); |
| 840 | + |
| 841 | + // Nested real dir mirroring node_modules/.pnpm/node_modules/ |
| 842 | + let nm = base.join("node_modules/.pnpm/node_modules"); |
| 843 | + std::fs::create_dir_all(&nm).unwrap(); |
| 844 | + |
| 845 | + // A traced package dir + a valid symlink to it. |
| 846 | + let real_pkg = base.join("node_modules/.pnpm/semver@7.7.3/node_modules/semver"); |
| 847 | + std::fs::create_dir_all(&real_pkg).unwrap(); |
| 848 | + std::fs::write(real_pkg.join("index.js"), "module.exports = {}").unwrap(); |
| 849 | + let valid_link = nm.join("valid"); |
| 850 | + symlink("../semver@7.7.3/node_modules/semver", &valid_link).unwrap(); |
| 851 | + |
| 852 | + // The dangling compat link: target version was never written. |
| 853 | + let dangling = nm.join("semver"); |
| 854 | + symlink("../semver@6.3.1/node_modules/semver", &dangling).unwrap(); |
| 855 | + |
| 856 | + // A plain file that must survive. |
| 857 | + let keeper = base.join("server.js"); |
| 858 | + std::fs::write(&keeper, "// server").unwrap(); |
| 859 | + |
| 860 | + let removed = prune_dangling_symlinks(base).unwrap(); |
| 861 | + |
| 862 | + assert_eq!(removed, 1, "exactly the dangling link should be removed"); |
| 863 | + assert!(!dangling.exists(), "dangling link is gone"); |
| 864 | + assert!( |
| 865 | + std::fs::symlink_metadata(&valid_link).is_ok(), |
| 866 | + "valid link is preserved" |
| 867 | + ); |
| 868 | + assert!(keeper.exists(), "real files are untouched"); |
| 869 | + } |
| 870 | + |
| 871 | + #[test] |
| 872 | + fn clean_tree_removes_nothing() { |
| 873 | + let root = tempdir().unwrap(); |
| 874 | + std::fs::create_dir_all(root.path().join("a/b")).unwrap(); |
| 875 | + std::fs::write(root.path().join("a/b/f.txt"), "x").unwrap(); |
| 876 | + assert_eq!(prune_dangling_symlinks(root.path()).unwrap(), 0); |
| 877 | + } |
| 878 | + |
| 879 | + /// A valid symlink *to a directory* is treated as a leaf and never followed. |
| 880 | + /// The link here points back at the walk root, so following it would recurse |
| 881 | + /// forever; terminating (and leaving the link in place) proves it is not |
| 882 | + /// followed — the same property that keeps pnpm's symlink graph cycle-free. |
| 883 | + #[test] |
| 884 | + fn does_not_follow_valid_directory_symlink() { |
| 885 | + let root = tempdir().unwrap(); |
| 886 | + let base = root.path(); |
| 887 | + |
| 888 | + std::fs::write(base.join("server.js"), "// server").unwrap(); |
| 889 | + // A valid directory symlink that points back at the root: following it |
| 890 | + // would loop. `read_dir` reports it as a symlink, so the leaf branch |
| 891 | + // keeps it without descending. |
| 892 | + let dir_link = base.join("cycle"); |
| 893 | + symlink(".", &dir_link).unwrap(); |
| 894 | + |
| 895 | + let removed = prune_dangling_symlinks(base).unwrap(); |
| 896 | + |
| 897 | + assert_eq!(removed, 0, "no dangling links, nothing removed"); |
| 898 | + assert!( |
| 899 | + std::fs::symlink_metadata(&dir_link).is_ok(), |
| 900 | + "the valid directory symlink itself is preserved" |
| 901 | + ); |
| 902 | + } |
| 903 | + |
| 904 | + /// The walk root being a dangling symlink is handled by the top-level branch: |
| 905 | + /// it is removed and counted. |
| 906 | + #[test] |
| 907 | + fn dangling_symlink_at_root_is_removed() { |
| 908 | + let root = tempdir().unwrap(); |
| 909 | + let link = root.path().join("root_link"); |
| 910 | + symlink("./missing_target", &link).unwrap(); |
| 911 | + |
| 912 | + assert_eq!(prune_dangling_symlinks(&link).unwrap(), 1); |
| 913 | + assert!( |
| 914 | + std::fs::symlink_metadata(&link).is_err(), |
| 915 | + "the dangling root symlink is gone" |
| 916 | + ); |
| 917 | + } |
| 918 | +} |
0 commit comments