From bd22a71ed3a31238bb5d6ffbeb6ed89370be9f2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Tue, 12 May 2026 16:54:12 +0200 Subject: [PATCH 1/2] Add Linux XFS loop storage fallback Use native reflinks first, then fall back on Linux to one shared reflink-capable XFS image mounted through direct loop ioctls and mount(2). Embed the compressed XFS template and route mounted branch storage through per-site directories inside the shared volume. --- Cargo.lock | 1 + crates/forkpress-cli/src/app.rs | 68 +- crates/forkpress-core/src/lib.rs | 106 +++ crates/forkpress-storage/Cargo.toml | 1 + .../assets/linux-xfs-reflink-template.img.gz | Bin 0 -> 8338978 bytes crates/forkpress-storage/src/lib.rs | 868 +++++++++++++++++- tests/cow/e2e.sh | 2 +- 7 files changed, 1000 insertions(+), 46 deletions(-) create mode 100644 crates/forkpress-storage/assets/linux-xfs-reflink-template.img.gz diff --git a/Cargo.lock b/Cargo.lock index dbf74d9b..f3bb0fdd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -372,6 +372,7 @@ name = "forkpress-storage" version = "0.1.13-cow.1" dependencies = [ "anyhow", + "flate2", "forkpress-core", "forkpress-runtime", "libc", diff --git a/crates/forkpress-cli/src/app.rs b/crates/forkpress-cli/src/app.rs index 00a7eded..060ad89c 100644 --- a/crates/forkpress-cli/src/app.rs +++ b/crates/forkpress-cli/src/app.rs @@ -55,6 +55,15 @@ use forkpress_storage::{ resolve_cow_merge_conflict, review_cow_merge_audit_record, show_cow_branch, write_cow_branch_list, write_cow_strategy_notes, }; +use forkpress_storage::{ + CowSiteInit, compact_macos_apfs_sparsebundle_file_view, cow_branch_names, cow_branch_root, + create_cow_branch, delete_cow_branch, detach_linux_xfs_loop_file_view, + detach_macos_apfs_sparsebundle_file_view, ensure_cow_branch_exists, ensure_cow_file_view_ready, + ensure_cow_main_branch, lock_cow_lifecycle, lock_cow_operations, prepare_cow_file_view, + print_cow_storage_status, print_linux_xfs_loop_storage_status, print_macos_cow_storage_status, + probe_reflink_dir, reset_cow_branch, show_cow_branch, write_cow_branch_list, + write_cow_strategy_notes, +}; #[cfg(feature = "dev-experiments")] use forkpress_storage::{copy_tree_cow, plain_branch_names}; #[cfg(test)] @@ -939,6 +948,11 @@ fn doctor_storage_command(args: DoctorStorageArgs) -> Result { " measurement: `du` can overcount APFS clone sharing; compare `df -h {}` before/after branch creation", layout.macos_cow_mount.display() ); + } else if file_view == FileViewStrategy::LinuxXfsLoop { + println!( + " measurement: `du` can overcount XFS reflink sharing; compare `df -h {}` before/after branch creation", + layout.linux_xfs_mount.display() + ); } } } @@ -970,7 +984,17 @@ fn doctor_storage_command(args: DoctorStorageArgs) -> Result { ); } - #[cfg(not(any(target_os = "macos", target_os = "windows")))] + #[cfg(target_os = "linux")] + { + println!(" Linux XFS loop volume: available through direct loop/mount syscalls"); + println!(" image: {}", layout.linux_xfs_image.display()); + println!(" mount: {}", layout.linux_xfs_mount.display()); + println!( + " recommendation: forkpress init will mount one shared XFS volume for all ForkPress sites if this process has loop and mount privileges" + ); + } + + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] { println!(" recommendation: file-copy materialization"); } @@ -1010,6 +1034,9 @@ fn storage_status_command(args: StorageStatusArgs) -> Result { Some(FileViewStrategy::MacosApfsSparsebundle) => { print_macos_cow_storage_status(&layout)?; } + Some(FileViewStrategy::LinuxXfsLoop) => { + print_linux_xfs_loop_storage_status(&layout)?; + } _ => { if layout.macos_cow_image.exists() || layout.macos_cow_mount.exists() { print_macos_cow_storage_status(&layout)?; @@ -1043,6 +1070,14 @@ fn storage_mount_command(args: StorageMountArgs) -> Result { ); println!("Branch roots: {}", layout.cow_branches_dir.display()); } + FileViewStrategy::LinuxXfsLoop => { + println!( + "forkpress: shared Linux XFS COW storage mounted at {}", + layout.linux_xfs_mount.display() + ); + println!("Branch roots: {}", layout.cow_branches_dir.display()); + println!("Storage roots: {}", layout.linux_xfs_branches_dir.display()); + } FileViewStrategy::Reflink | FileViewStrategy::Copy => { println!( "forkpress: storage file view \"{}\" does not use a detachable mount", @@ -1111,21 +1146,47 @@ fn detach_storage_for_layout_if_present( timeout: Duration, ) -> Result { let manifest = read_site_manifest(layout)?; + let has_linux_xfs = manifest.as_ref().and_then(|manifest| manifest.file_view) + == Some(FileViewStrategy::LinuxXfsLoop); let has_macos_cow = manifest.as_ref().and_then(|manifest| manifest.file_view) == Some(FileViewStrategy::MacosApfsSparsebundle) || layout.macos_cow_image.exists() || layout.macos_cow_mount.exists(); - if !has_macos_cow { + if !has_macos_cow && !has_linux_xfs { return Ok(false); } with_stopped_cow_server_for_storage(layout, keep_server, timeout, || { - detach_macos_apfs_sparsebundle_file_view(layout, force, true) + if has_linux_xfs { + ensure_no_other_linux_xfs_servers(layout)?; + detach_linux_xfs_loop_file_view(layout, force, true) + } else { + detach_macos_apfs_sparsebundle_file_view(layout, force, true) + } })?; Ok(true) } +fn ensure_no_other_linux_xfs_servers(layout: &Layout) -> Result<()> { + for record in live_server_records()? { + if record.work_dir == layout.work_dir { + continue; + } + let other_layout = Layout::new(record.work_dir.clone())?; + if read_site_manifest(&other_layout)?.and_then(|manifest| manifest.file_view) + == Some(FileViewStrategy::LinuxXfsLoop) + { + bail!( + "shared Linux XFS COW storage is still used by server pid {} at {}. Stop all ForkPress sites before detaching the shared volume.", + record.pid, + other_layout.work_dir.display() + ); + } + } + Ok(()) +} + fn with_stopped_cow_server_for_storage( layout: &Layout, keep_server: bool, @@ -4020,6 +4081,7 @@ fn start_cow_php_server( .unwrap_or(FileViewStrategy::Copy); let storage_branches_dir = match file_view { FileViewStrategy::MacosApfsSparsebundle => layout.macos_cow_branches_dir.clone(), + FileViewStrategy::LinuxXfsLoop => layout.linux_xfs_branches_dir.clone(), FileViewStrategy::Reflink | FileViewStrategy::Copy => layout.cow_branches_dir.clone(), }; diff --git a/crates/forkpress-core/src/lib.rs b/crates/forkpress-core/src/lib.rs index 5fd86459..1670128b 100644 --- a/crates/forkpress-core/src/lib.rs +++ b/crates/forkpress-core/src/lib.rs @@ -25,6 +25,7 @@ pub enum StorageStrategy { pub enum FileViewStrategy { Reflink, MacosApfsSparsebundle, + LinuxXfsLoop, Copy, } @@ -33,6 +34,7 @@ impl FileViewStrategy { match self { Self::Reflink => "reflink", Self::MacosApfsSparsebundle => "macos-apfs-sparsebundle", + Self::LinuxXfsLoop => "linux-xfs-loop", Self::Copy => "file-copy", } } @@ -41,6 +43,7 @@ impl FileViewStrategy { match value.trim() { "reflink" | "clonefile" | "ficlone" => Ok(Self::Reflink), "macos-apfs-sparsebundle" | "apfs-sparsebundle" => Ok(Self::MacosApfsSparsebundle), + "linux-xfs-loop" | "xfs-loop" | "linux-xfs" => Ok(Self::LinuxXfsLoop), "file-copy" | "copy" => Ok(Self::Copy), other => bail!("unknown file_view strategy in site manifest: {other}"), } @@ -165,6 +168,16 @@ pub struct Layout { pub macos_cow_mount: PathBuf, #[cfg_attr(not(target_os = "macos"), allow(dead_code))] pub macos_cow_branches_dir: PathBuf, + #[cfg_attr(not(target_os = "linux"), allow(dead_code))] + pub linux_xfs_dir: PathBuf, + #[cfg_attr(not(target_os = "linux"), allow(dead_code))] + pub linux_xfs_image: PathBuf, + #[cfg_attr(not(target_os = "linux"), allow(dead_code))] + pub linux_xfs_mount: PathBuf, + #[cfg_attr(not(target_os = "linux"), allow(dead_code))] + pub linux_xfs_site_dir: PathBuf, + #[cfg_attr(not(target_os = "linux"), allow(dead_code))] + pub linux_xfs_branches_dir: PathBuf, #[cfg(feature = "dev-experiments")] pub cas_dir: PathBuf, #[cfg(feature = "dev-experiments")] @@ -217,6 +230,11 @@ impl Layout { }; let cow_branch_list = cow_dir.join("branches.txt"); let cow_git_dir = cow_dir.join("git"); + let linux_xfs_dir = forkpress_data_dir(&work_dir).join("linux-xfs"); + let linux_xfs_mount = linux_xfs_dir.join("mount"); + let linux_xfs_site_dir = linux_xfs_mount + .join("sites") + .join(linux_xfs_site_slug(&project_dir, &work_dir)); Ok(Self { project_dir, @@ -232,6 +250,11 @@ impl Layout { macos_cow_image: work_dir.join("macos-cow/branches.sparsebundle"), macos_cow_mount: work_dir.join("macos-cow/mount"), macos_cow_branches_dir: work_dir.join("macos-cow/mount/branches"), + linux_xfs_dir: linux_xfs_dir.clone(), + linux_xfs_image: linux_xfs_dir.join("forkpress-branches.xfs"), + linux_xfs_mount, + linux_xfs_branches_dir: linux_xfs_site_dir.join("branches"), + linux_xfs_site_dir, #[cfg(feature = "dev-experiments")] cas_dir: work_dir.join("cas"), #[cfg(feature = "dev-experiments")] @@ -258,6 +281,66 @@ impl Layout { } } +fn forkpress_data_dir(work_dir: &Path) -> PathBuf { + if let Some(path) = non_empty_env_path("FORKPRESS_DATA_DIR") { + return path; + } + if let Some(path) = non_empty_env_path("XDG_DATA_HOME") { + return path.join("forkpress"); + } + if let Some(path) = non_empty_env_path("HOME") { + return path.join(".local/share/forkpress"); + } + work_dir.join("data") +} + +fn non_empty_env_path(name: &str) -> Option { + let value = std::env::var_os(name)?; + if value.is_empty() { + None + } else { + Some(PathBuf::from(value)) + } +} + +fn linux_xfs_site_slug(project_dir: &Path, work_dir: &Path) -> String { + let name = project_dir + .file_name() + .and_then(|name| name.to_str()) + .map(sanitize_linux_xfs_slug_name) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| "site".to_string()); + format!( + "{name}-{}", + fnv1a_hex(work_dir.as_os_str().as_encoded_bytes()) + ) +} + +fn sanitize_linux_xfs_slug_name(value: &str) -> String { + value + .chars() + .filter_map(|ch| { + if ch.is_ascii_alphanumeric() { + Some(ch.to_ascii_lowercase()) + } else if ch == '-' || ch == '_' { + Some(ch) + } else { + None + } + }) + .take(40) + .collect() +} + +fn fnv1a_hex(bytes: &[u8]) -> String { + let mut hash = 0xcbf29ce484222325u64; + for byte in bytes { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x100000001b3); + } + format!("{hash:016x}") +} + pub fn read_site_manifest(layout: &Layout) -> Result> { if !layout.site_manifest.is_file() { return Ok(None); @@ -408,6 +491,10 @@ mod tests { let manifest = SiteManifest::parse("strategy = \"cow\"\nfile_view = \"copy\"\n").unwrap(); assert_eq!(manifest.file_view, Some(FileViewStrategy::Copy)); + + let manifest = + SiteManifest::parse("strategy = \"cow\"\nfile_view = \"linux-xfs\"\n").unwrap(); + assert_eq!(manifest.file_view, Some(FileViewStrategy::LinuxXfsLoop)); } #[test] @@ -430,6 +517,25 @@ mod tests { assert_eq!(parsed.file_view, Some(FileViewStrategy::Reflink)); } + #[test] + fn linux_xfs_site_slug_is_stable_and_ascii() { + let slug = linux_xfs_site_slug( + Path::new("/tmp/Client Site!"), + Path::new("/tmp/Client Site!/.forkpress"), + ); + assert!(slug.starts_with("clientsite-")); + assert!(slug.chars().all(|ch| { + ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_' + })); + assert_eq!( + slug, + linux_xfs_site_slug( + Path::new("/tmp/Client Site!"), + Path::new("/tmp/Client Site!/.forkpress") + ) + ); + } + #[test] fn cow_branch_names_are_dns_label_safe() { assert!(validate_branch_name("feature-1").is_ok()); diff --git a/crates/forkpress-storage/Cargo.toml b/crates/forkpress-storage/Cargo.toml index 0e331247..9946f37b 100644 --- a/crates/forkpress-storage/Cargo.toml +++ b/crates/forkpress-storage/Cargo.toml @@ -8,6 +8,7 @@ default = [] [dependencies] anyhow = "1.0" +flate2 = "1.1" forkpress-core = { path = "../forkpress-core" } forkpress-runtime = { path = "../forkpress-runtime" } libc = "0.2" diff --git a/crates/forkpress-storage/assets/linux-xfs-reflink-template.img.gz b/crates/forkpress-storage/assets/linux-xfs-reflink-template.img.gz new file mode 100644 index 0000000000000000000000000000000000000000..bcbb3251e46139bce0714f6f217b74847c6fa7f3 GIT binary patch literal 8338978 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
    ;dKO!N`ElIoC%?u30t5&UAV7cs0RjXF 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
      ;dKO!N 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 literal 0 HcmV?d00001 diff --git a/crates/forkpress-storage/src/lib.rs b/crates/forkpress-storage/src/lib.rs index ba10e79c..e2e7558b 100644 --- a/crates/forkpress-storage/src/lib.rs +++ b/crates/forkpress-storage/src/lib.rs @@ -1,15 +1,17 @@ use anyhow::{Context, Result, anyhow, bail}; -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "linux", target_os = "macos"))] use std::ffi::CString; use std::ffi::{OsStr, OsString}; use std::fs::{self, File, OpenOptions}; -#[cfg(target_os = "windows")] +#[cfg(any(target_os = "linux", target_os = "windows"))] use std::io::{Read, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; #[cfg(target_os = "macos")] use std::process::Command; use std::time::{SystemTime, UNIX_EPOCH}; +#[cfg(target_os = "linux")] +use flate2::read::GzDecoder; #[cfg(target_os = "macos")] use forkpress_core::absolutize; use forkpress_core::{ @@ -47,18 +49,22 @@ streams, SQL COW views, tombstones, triggers, or per-branch table prefixes. Branch creation uses the file view recorded in `.forkpress/site.toml`. ForkPress first tries materialized COW branch directories with host filesystem clone primitives (Linux `FICLONE`, macOS `clonefile`, Windows ReFS block -clone). On macOS, if the current location cannot clone files, ForkPress creates -a rootless APFS sparsebundle under `.forkpress/macos-cow`, mounts it at -`.forkpress/macos-cow/mount`, and links each public branch directory, such as -`./main`, into that APFS volume. On Windows, put the site on a ReFS Dev Drive -created by `ForkPressSetup.exe`. A regular full copy is only the last-resort -file view on platforms where ForkPress can make that tradeoff explicit. - -APFS clone sharing is not visible to tools that add up path sizes. `du`, Finder, -and many disk analyzers can count shared clone extents once for every branch, so -a COW branch can appear to consume another full WordPress tree. To inspect -physical growth on macOS, compare `df -h .forkpress/macos-cow/mount` before and -after branch creation, or inspect the allocated size of +clone). On Linux, if the current location cannot clone files, ForkPress writes +a sparse XFS image under the user's ForkPress data directory, attaches it to a +loop device, mounts one shared XFS volume for all ForkPress sites, and links +each public branch directory, such as `./main`, into that volume. On macOS, if +the current location cannot clone files, ForkPress creates a rootless APFS +sparsebundle under `.forkpress/macos-cow`, mounts it at +`.forkpress/macos-cow/mount`, and links public branches into that APFS volume. +On Windows, put the site on a ReFS Dev Drive created by `ForkPressSetup.exe`. +A regular full copy is only the last-resort file view on platforms where +ForkPress can make that tradeoff explicit. + +APFS/XFS clone sharing is not visible to tools that add up path sizes. `du`, +Finder, and many disk analyzers can count shared clone extents once for every +branch, so a COW branch can appear to consume another full WordPress tree. To +inspect physical growth on macOS, compare `df -h .forkpress/macos-cow/mount` +before and after branch creation, or inspect the allocated size of `.forkpress/macos-cow/branches.sparsebundle`. Manage mount-backed COW storage through ForkPress: @@ -93,19 +99,24 @@ pub fn print_cow_storage_status( file_view: Option, ) -> Result<()> { println!(" project: {}", layout.project_dir.display()); - let sparsebundle_detached = file_view == Some(FileViewStrategy::MacosApfsSparsebundle) - && !layout.macos_cow_branches_dir.exists(); - if sparsebundle_detached { - println!(" branches: unavailable while sparsebundle is detached"); + let mount_backed_detached = file_view + .map(|file_view| cow_file_view_detached(layout, file_view)) + .unwrap_or(false); + let detached_label = if file_view == Some(FileViewStrategy::MacosApfsSparsebundle) { + "sparsebundle" + } else { + "mount-backed storage" + }; + if mount_backed_detached { + println!(" branches: unavailable while {detached_label} is detached"); } else { println!(" branches: {}", cow_branch_names(layout)?.len()); } println!(" public: {}", layout.cow_branches_dir.display()); - if file_view == Some(FileViewStrategy::MacosApfsSparsebundle) { - println!(" storage: {}", layout.macos_cow_branches_dir.display()); - } else { - println!(" storage: {}", layout.cow_branches_dir.display()); - } + println!( + " storage: {}", + cow_file_view_storage_dir(layout, file_view).display() + ); println!( " lock: {}", layout.cow_dir.join("operations.lock").display() @@ -115,8 +126,8 @@ pub fn print_cow_storage_status( layout.cow_dir.join("lifecycle.lock").display() ); - if sparsebundle_detached { - println!(" leftovers: unavailable while sparsebundle is detached"); + if mount_backed_detached { + println!(" leftovers: unavailable while {detached_label} is detached"); } else { let leftovers = cow_stale_operation_entries(layout)?; if leftovers.is_empty() { @@ -135,6 +146,24 @@ pub fn print_cow_storage_status( Ok(()) } +fn cow_file_view_detached(layout: &Layout, file_view: FileViewStrategy) -> bool { + match file_view { + FileViewStrategy::MacosApfsSparsebundle => !layout.macos_cow_branches_dir.exists(), + FileViewStrategy::LinuxXfsLoop => !layout.linux_xfs_branches_dir.exists(), + FileViewStrategy::Reflink | FileViewStrategy::Copy => false, + } +} + +fn cow_file_view_storage_dir(layout: &Layout, file_view: Option) -> &Path { + match file_view { + Some(FileViewStrategy::MacosApfsSparsebundle) => &layout.macos_cow_branches_dir, + Some(FileViewStrategy::LinuxXfsLoop) => &layout.linux_xfs_branches_dir, + Some(FileViewStrategy::Reflink) | Some(FileViewStrategy::Copy) | None => { + &layout.cow_branches_dir + } + } +} + #[cfg(target_os = "macos")] pub fn print_macos_cow_storage_status(layout: &Layout) -> Result<()> { println!(" image: {}", layout.macos_cow_image.display()); @@ -163,6 +192,34 @@ pub fn print_macos_cow_storage_status(layout: &Layout) -> Result<()> { Ok(()) } +#[cfg(target_os = "linux")] +pub fn print_linux_xfs_loop_storage_status(layout: &Layout) -> Result<()> { + println!(" image: {}", layout.linux_xfs_image.display()); + println!(" mount: {}", layout.linux_xfs_mount.display()); + match linux_xfs_mount_info(&layout.linux_xfs_mount)? { + Some(info) => { + println!(" attached: yes"); + println!(" device: {}", info.device); + } + None => { + println!(" attached: no"); + println!( + " attach: forkpress storage mount --work-dir {}", + shell_quote_path(&layout.work_dir) + ); + } + } + Ok(()) +} + +#[cfg(not(target_os = "linux"))] +pub fn print_linux_xfs_loop_storage_status(layout: &Layout) -> Result<()> { + println!(" image: {}", layout.linux_xfs_image.display()); + println!(" mount: {}", layout.linux_xfs_mount.display()); + println!(" attached: not available on this OS"); + Ok(()) +} + pub fn prepare_cow_file_view(layout: &Layout) -> Result { fs::create_dir_all(&layout.cow_dir) .with_context(|| format!("failed to create {}", layout.cow_dir.display()))?; @@ -177,6 +234,16 @@ pub fn prepare_cow_file_view(layout: &Layout) -> Result { } } + #[cfg(target_os = "linux")] + { + if std::env::var_os("FORKPRESS_FORCE_LINUX_XFS_LOOP").is_some() { + if let Err(err) = prepare_linux_xfs_loop_file_view(layout) { + return Err(cleanup_failed_linux_xfs_loop_prepare(layout, err)?); + } + return Ok(FileViewStrategy::LinuxXfsLoop); + } + } + if probe_reflink_dir(&layout.cow_branches_dir)? { return Ok(FileViewStrategy::Reflink); } @@ -199,6 +266,18 @@ pub fn prepare_cow_file_view(layout: &Layout) -> Result { } } + #[cfg(target_os = "linux")] + { + match prepare_linux_xfs_loop_file_view(layout) { + Ok(()) => return Ok(FileViewStrategy::LinuxXfsLoop), + Err(err) => { + let err = cleanup_failed_linux_xfs_loop_prepare(layout, err)?; + eprintln!("forkpress: Linux XFS loop COW setup failed: {err:#}"); + eprintln!("forkpress: falling back to full file-copy materialization"); + } + } + } + #[cfg(not(target_os = "windows"))] { Ok(FileViewStrategy::Copy) @@ -232,6 +311,9 @@ pub fn ensure_cow_file_view_available(layout: &Layout, file_view: FileViewStrate FileViewStrategy::MacosApfsSparsebundle => { ensure_macos_apfs_sparsebundle_file_view(layout)?; } + FileViewStrategy::LinuxXfsLoop => { + ensure_linux_xfs_loop_file_view(layout)?; + } } Ok(()) } @@ -1170,6 +1252,9 @@ pub fn cow_stale_operation_entries(layout: &Layout) -> Result> { if layout.macos_cow_branches_dir.exists() { roots.push(layout.macos_cow_branches_dir.clone()); } + if layout.linux_xfs_branches_dir.exists() { + roots.push(layout.linux_xfs_branches_dir.clone()); + } let mut entries = Vec::new(); for root in roots { @@ -1352,6 +1437,14 @@ pub fn compact_macos_apfs_sparsebundle_file_view(layout: &Layout) -> Result<()> compact_macos_apfs_sparsebundle_file_view_impl(layout) } +pub fn detach_linux_xfs_loop_file_view( + layout: &Layout, + force: bool, + print_remove_site_hint: bool, +) -> Result<()> { + detach_linux_xfs_loop_file_view_impl(layout, force, print_remove_site_hint) +} + fn cow_branch_copies_require_cow(layout: &Layout) -> Result { Ok(read_site_manifest(layout)? .and_then(|manifest| manifest.file_view) @@ -1362,6 +1455,7 @@ fn cow_branch_copies_require_cow(layout: &Layout) -> Result { fn cow_branch_storage_root(layout: &Layout, branch: &str, file_view: FileViewStrategy) -> PathBuf { match file_view { FileViewStrategy::MacosApfsSparsebundle => layout.macos_cow_branches_dir.join(branch), + FileViewStrategy::LinuxXfsLoop => layout.linux_xfs_branches_dir.join(branch), FileViewStrategy::Reflink | FileViewStrategy::Copy => cow_branch_root(layout, branch), } } @@ -1386,14 +1480,28 @@ fn ensure_cow_public_branch_root( file_view: FileViewStrategy, ) -> Result { let public_root = cow_branch_root(layout, branch); - if file_view == FileViewStrategy::MacosApfsSparsebundle { - ensure_macos_cow_public_branch_link(&public_root, storage_root)?; + match file_view { + FileViewStrategy::MacosApfsSparsebundle => { + ensure_mount_backed_cow_public_branch_link( + &public_root, + storage_root, + "APFS sparsebundle", + )?; + } + FileViewStrategy::LinuxXfsLoop => { + ensure_mount_backed_cow_public_branch_link(&public_root, storage_root, "XFS loop")?; + } + FileViewStrategy::Reflink | FileViewStrategy::Copy => {} } Ok(public_root) } -#[cfg(target_os = "macos")] -fn ensure_macos_cow_public_branch_link(public_root: &Path, storage_root: &Path) -> Result<()> { +#[cfg(unix)] +fn ensure_mount_backed_cow_public_branch_link( + public_root: &Path, + storage_root: &Path, + label: &str, +) -> Result<()> { use std::os::unix::fs::symlink; match fs::symlink_metadata(public_root) { @@ -1415,8 +1523,9 @@ fn ensure_macos_cow_public_branch_link(public_root: &Path, storage_root: &Path) return Ok(()); } bail!( - "{} already exists; cannot link it to APFS sparsebundle storage at {}", + "{} already exists; cannot link it to {} storage at {}", public_root.display(), + label, storage_root.display() ); } @@ -1440,9 +1549,13 @@ fn ensure_macos_cow_public_branch_link(public_root: &Path, storage_root: &Path) }) } -#[cfg(not(target_os = "macos"))] -fn ensure_macos_cow_public_branch_link(_public_root: &Path, _storage_root: &Path) -> Result<()> { - bail!("macOS APFS sparsebundle file view is only available on macOS") +#[cfg(not(unix))] +fn ensure_mount_backed_cow_public_branch_link( + _public_root: &Path, + _storage_root: &Path, + label: &str, +) -> Result<()> { + bail!("{label} file view requires Unix symlinks") } #[cfg(target_os = "macos")] @@ -1527,6 +1640,474 @@ fn ensure_macos_apfs_sparsebundle_file_view(_layout: &Layout) -> Result<()> { bail!("macOS APFS sparsebundle file view is only available on macOS") } +#[cfg(target_os = "linux")] +fn prepare_linux_xfs_loop_file_view( + layout: &Layout, +) -> std::result::Result<(), LinuxXfsLoopPrepareError> { + let mut mounted_here = false; + let result = (|| -> Result<()> { + if layout.cow_branches_dir == layout.cow_dir.join("branches") + && layout.cow_branches_dir.exists() + && is_empty_dir(&layout.cow_branches_dir)? + { + fs::remove_dir(&layout.cow_branches_dir).with_context(|| { + format!( + "failed to remove empty {}", + layout.cow_branches_dir.display() + ) + })?; + } + ensure_linux_xfs_loop_file_view_impl(layout, Some(&mut mounted_here))?; + if !probe_reflink_dir(&layout.linux_xfs_branches_dir)? { + bail!( + "mounted Linux XFS loop volume does not support reflinks at {}", + layout.linux_xfs_branches_dir.display() + ); + } + Ok(()) + })(); + + result.map_err(|source| LinuxXfsLoopPrepareError { + source, + mounted_here, + }) +} + +#[cfg(target_os = "linux")] +fn ensure_linux_xfs_loop_file_view(layout: &Layout) -> Result<()> { + ensure_linux_xfs_loop_file_view_impl(layout, None) +} + +#[cfg(target_os = "linux")] +fn ensure_linux_xfs_loop_file_view_impl( + layout: &Layout, + mut mounted_here: Option<&mut bool>, +) -> Result<()> { + fs::create_dir_all(&layout.linux_xfs_dir) + .with_context(|| format!("failed to create {}", layout.linux_xfs_dir.display()))?; + let _lock = lock_linux_xfs_global_storage(layout)?; + + ensure_linux_xfs_image(layout)?; + if linux_xfs_mount_info(&layout.linux_xfs_mount)?.is_none() { + mount_linux_xfs_image(layout)?; + if let Some(mounted_here) = &mut mounted_here { + **mounted_here = true; + } + } + + fs::create_dir_all(&layout.linux_xfs_branches_dir).with_context(|| { + format!( + "failed to create {}", + layout.linux_xfs_branches_dir.display() + ) + })?; + link_cow_branches_to_mount_backed_storage(layout, &layout.linux_xfs_branches_dir, "XFS loop") +} + +#[cfg(not(target_os = "linux"))] +fn ensure_linux_xfs_loop_file_view(_layout: &Layout) -> Result<()> { + bail!("Linux XFS loop file view is only available on Linux") +} + +#[cfg(target_os = "linux")] +fn ensure_linux_xfs_image(layout: &Layout) -> Result<()> { + if layout.linux_xfs_image.exists() { + return Ok(()); + } + + write_sparse_gzip_template(&layout.linux_xfs_image, LINUX_XFS_REFLINK_TEMPLATE_GZ).with_context( + || { + format!( + "failed to write XFS image template to {}", + layout.linux_xfs_image.display() + ) + }, + ) +} + +#[cfg(target_os = "linux")] +fn write_sparse_gzip_template(path: &Path, template_gz: &[u8]) -> Result<()> { + let parent = path + .parent() + .ok_or_else(|| anyhow!("image path has no parent: {}", path.display()))?; + fs::create_dir_all(parent).with_context(|| format!("failed to create {}", parent.display()))?; + let tmp = parent.join(format!( + ".forkpress-xfs-image-{}-{}.tmp", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )); + + let result = (|| -> Result<()> { + let mut output = OpenOptions::new() + .write(true) + .create_new(true) + .open(&tmp) + .with_context(|| format!("failed to create {}", tmp.display()))?; + let mut input = GzDecoder::new(template_gz); + let mut buffer = vec![0u8; 64 * 1024]; + let mut written = 0u64; + loop { + let len = input + .read(&mut buffer) + .context("failed to inflate embedded XFS image template")?; + if len == 0 { + break; + } + if buffer[..len].iter().all(|byte| *byte == 0) { + output + .seek(SeekFrom::Current(len as i64)) + .with_context(|| format!("failed to seek in {}", tmp.display()))?; + } else { + output + .write_all(&buffer[..len]) + .with_context(|| format!("failed to write {}", tmp.display()))?; + } + written += len as u64; + } + output + .set_len(written) + .with_context(|| format!("failed to size {}", tmp.display()))?; + output + .sync_all() + .with_context(|| format!("failed to sync {}", tmp.display()))?; + fs::rename(&tmp, path).with_context(|| format!("failed to publish {}", path.display()))?; + Ok(()) + })(); + + if result.is_err() { + let _ = fs::remove_file(&tmp); + } + result +} + +#[cfg(target_os = "linux")] +fn mount_linux_xfs_image(layout: &Layout) -> Result<()> { + fs::create_dir_all(&layout.linux_xfs_mount) + .with_context(|| format!("failed to create {}", layout.linux_xfs_mount.display()))?; + let loop_device = attach_linux_loop_device(&layout.linux_xfs_image)?; + let mount_result = linux_mount_xfs_device(&loop_device.path, &layout.linux_xfs_mount); + if let Err(err) = mount_result { + let _ = loop_device.detach(); + return Err(err).with_context(|| { + format!( + "failed to mount XFS image {} at {}", + layout.linux_xfs_image.display(), + layout.linux_xfs_mount.display() + ) + }); + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn attach_linux_loop_device(image: &Path) -> Result { + use std::os::fd::AsRawFd; + use std::os::unix::fs::OpenOptionsExt; + + let control = OpenOptions::new() + .read(true) + .write(true) + .custom_flags(libc::O_CLOEXEC) + .open("/dev/loop-control") + .context( + "failed to open /dev/loop-control; Linux XFS loop storage requires loop-device access", + )?; + let number = unsafe { libc::ioctl(control.as_raw_fd(), LOOP_CTL_GET_FREE) }; + if number < 0 { + return Err(std::io::Error::last_os_error()) + .context("failed to allocate a free loop device"); + } + + let path = PathBuf::from(format!("/dev/loop{number}")); + let loop_file = OpenOptions::new() + .read(true) + .write(true) + .custom_flags(libc::O_CLOEXEC) + .open(&path) + .with_context(|| format!("failed to open {}", path.display()))?; + let image_file = OpenOptions::new() + .read(true) + .write(true) + .custom_flags(libc::O_CLOEXEC) + .open(image) + .with_context(|| format!("failed to open {}", image.display()))?; + + let rc = unsafe { libc::ioctl(loop_file.as_raw_fd(), LOOP_SET_FD, image_file.as_raw_fd()) }; + if rc != 0 { + return Err(std::io::Error::last_os_error()).with_context(|| { + format!("failed to attach {} to {}", image.display(), path.display()) + }); + } + + let device = LinuxLoopDevice { + path, + file: loop_file, + }; + if let Err(err) = device.set_autoclear(image) { + let _ = device.detach(); + return Err(err); + } + Ok(device) +} + +#[cfg(target_os = "linux")] +fn linux_mount_xfs_device(device: &Path, mount: &Path) -> Result<()> { + use std::os::unix::ffi::OsStrExt; + + let source = CString::new(device.as_os_str().as_bytes()) + .with_context(|| format!("{} contains an interior NUL byte", device.display()))?; + let target = CString::new(mount.as_os_str().as_bytes()) + .with_context(|| format!("{} contains an interior NUL byte", mount.display()))?; + let fs_type = CString::new("xfs").unwrap(); + let data = CString::new("nouuid").unwrap(); + let flags = libc::MS_NOATIME; + let rc = unsafe { + libc::mount( + source.as_ptr(), + target.as_ptr(), + fs_type.as_ptr(), + flags, + data.as_ptr().cast(), + ) + }; + if rc == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()).context("mount(2) failed for XFS loop volume") + } +} + +#[cfg(target_os = "linux")] +#[derive(Debug, Clone)] +pub struct LinuxXfsMountInfo { + pub device: String, +} + +#[cfg(target_os = "linux")] +fn linux_xfs_mount_info(mount: &Path) -> Result> { + if !mount.exists() { + return Ok(None); + } + + let mount = fs::canonicalize(mount).unwrap_or_else(|_| mount.to_path_buf()); + let mountinfo = fs::read_to_string("/proc/self/mountinfo") + .context("failed to read /proc/self/mountinfo")?; + for line in mountinfo.lines() { + let Some(info) = parse_linux_mountinfo_line(line) else { + continue; + }; + let mounted_on = PathBuf::from(info.mount_point); + let mounted_on = fs::canonicalize(&mounted_on).unwrap_or(mounted_on); + if mounted_on == mount && info.fs_type == "xfs" { + return Ok(Some(LinuxXfsMountInfo { + device: info.source, + })); + } + } + Ok(None) +} + +#[cfg(target_os = "linux")] +fn parse_linux_mountinfo_line(line: &str) -> Option { + let fields: Vec<&str> = line.split_whitespace().collect(); + let separator = fields.iter().position(|field| *field == "-")?; + if separator < 5 || fields.len() <= separator + 2 { + return None; + } + Some(ParsedLinuxMountInfo { + mount_point: unescape_linux_mountinfo_field(fields[4]), + fs_type: fields[separator + 1].to_string(), + source: unescape_linux_mountinfo_field(fields[separator + 2]), + }) +} + +#[cfg(target_os = "linux")] +#[derive(Debug, Clone, PartialEq, Eq)] +struct ParsedLinuxMountInfo { + mount_point: String, + fs_type: String, + source: String, +} + +#[cfg(target_os = "linux")] +fn unescape_linux_mountinfo_field(value: &str) -> String { + let mut out = Vec::with_capacity(value.len()); + let bytes = value.as_bytes(); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'\\' + && index + 3 < bytes.len() + && bytes[index + 1].is_ascii_digit() + && bytes[index + 2].is_ascii_digit() + && bytes[index + 3].is_ascii_digit() + { + let decoded = (bytes[index + 1] - b'0') * 64 + + (bytes[index + 2] - b'0') * 8 + + (bytes[index + 3] - b'0'); + out.push(decoded); + index += 4; + } else { + out.push(bytes[index]); + index += 1; + } + } + String::from_utf8_lossy(&out).into_owned() +} + +#[cfg(target_os = "linux")] +const LINUX_XFS_REFLINK_TEMPLATE_GZ: &[u8] = + include_bytes!("../assets/linux-xfs-reflink-template.img.gz"); + +#[cfg(target_os = "linux")] +const LOOP_SET_FD: libc::Ioctl = 0x4C00; +#[cfg(target_os = "linux")] +const LOOP_CLR_FD: libc::Ioctl = 0x4C01; +#[cfg(target_os = "linux")] +const LOOP_SET_STATUS64: libc::Ioctl = 0x4C04; +#[cfg(target_os = "linux")] +const LOOP_CTL_GET_FREE: libc::Ioctl = 0x4C82; +#[cfg(target_os = "linux")] +const LO_FLAGS_AUTOCLEAR: u32 = 4; + +#[cfg(target_os = "linux")] +struct LinuxXfsLoopPrepareError { + source: anyhow::Error, + mounted_here: bool, +} + +#[cfg(target_os = "linux")] +#[repr(C)] +#[derive(Clone, Copy)] +struct LoopInfo64 { + lo_device: u64, + lo_inode: u64, + lo_rdevice: u64, + lo_offset: u64, + lo_sizelimit: u64, + lo_number: u32, + lo_encrypt_type: u32, + lo_encrypt_key_size: u32, + lo_flags: u32, + lo_file_name: [u8; 64], + lo_crypt_name: [u8; 64], + lo_encrypt_key: [u8; 32], + lo_init: [u64; 2], +} + +#[cfg(target_os = "linux")] +impl Default for LoopInfo64 { + fn default() -> Self { + Self { + lo_device: 0, + lo_inode: 0, + lo_rdevice: 0, + lo_offset: 0, + lo_sizelimit: 0, + lo_number: 0, + lo_encrypt_type: 0, + lo_encrypt_key_size: 0, + lo_flags: 0, + lo_file_name: [0; 64], + lo_crypt_name: [0; 64], + lo_encrypt_key: [0; 32], + lo_init: [0; 2], + } + } +} + +#[cfg(target_os = "linux")] +struct LinuxLoopDevice { + path: PathBuf, + file: File, +} + +#[cfg(target_os = "linux")] +impl LinuxLoopDevice { + fn set_autoclear(&self, image: &Path) -> Result<()> { + use std::os::fd::AsRawFd; + + let mut info = LoopInfo64 { + lo_flags: LO_FLAGS_AUTOCLEAR, + ..LoopInfo64::default() + }; + copy_loop_file_name(image, &mut info.lo_file_name); + let rc = unsafe { + libc::ioctl( + self.file.as_raw_fd(), + LOOP_SET_STATUS64, + &info as *const LoopInfo64, + ) + }; + if rc == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + .with_context(|| format!("failed to set loop flags on {}", self.path.display())) + } + } + + fn detach(&self) -> Result<()> { + use std::os::fd::AsRawFd; + + let rc = unsafe { libc::ioctl(self.file.as_raw_fd(), LOOP_CLR_FD) }; + if rc == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + .with_context(|| format!("failed to detach {}", self.path.display())) + } + } +} + +#[cfg(target_os = "linux")] +fn copy_loop_file_name(image: &Path, out: &mut [u8; 64]) { + use std::os::unix::ffi::OsStrExt; + + out.fill(0); + let bytes = image.as_os_str().as_bytes(); + let len = bytes.len().min(out.len().saturating_sub(1)); + out[..len].copy_from_slice(&bytes[..len]); +} + +#[cfg(target_os = "linux")] +struct LinuxXfsGlobalLock { + file: File, +} + +#[cfg(target_os = "linux")] +impl Drop for LinuxXfsGlobalLock { + fn drop(&mut self) { + use std::os::fd::AsRawFd; + unsafe { + libc::flock(self.file.as_raw_fd(), libc::LOCK_UN); + } + } +} + +#[cfg(target_os = "linux")] +fn lock_linux_xfs_global_storage(layout: &Layout) -> Result { + use std::os::fd::AsRawFd; + + fs::create_dir_all(&layout.linux_xfs_dir) + .with_context(|| format!("failed to create {}", layout.linux_xfs_dir.display()))?; + let path = layout.linux_xfs_dir.join("setup.lock"); + let file = OpenOptions::new() + .create(true) + .read(true) + .write(true) + .truncate(false) + .open(&path) + .with_context(|| format!("failed to open {}", path.display()))?; + let status = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) }; + if status != 0 { + bail!("failed to lock {}", path.display()); + } + Ok(LinuxXfsGlobalLock { file }) +} + #[derive(Debug, Clone)] #[cfg(target_os = "macos")] struct MacosMountInfo { @@ -1696,6 +2277,112 @@ fn compact_macos_apfs_sparsebundle_file_view_impl(_layout: &Layout) -> Result<() bail!("macOS APFS sparsebundle compact is only available on macOS") } +#[cfg(target_os = "linux")] +fn detach_linux_xfs_loop_file_view_impl( + layout: &Layout, + force: bool, + print_remove_site_hint: bool, +) -> Result<()> { + let _lock = lock_linux_xfs_global_storage(layout)?; + let Some(info) = linux_xfs_mount_info(&layout.linux_xfs_mount)? else { + println!( + "forkpress: shared Linux XFS COW storage is already detached at {}", + layout.linux_xfs_mount.display() + ); + println!( + "Attach: forkpress storage mount --work-dir {}", + shell_quote_path(&layout.work_dir) + ); + return Ok(()); + }; + + unmount_linux_xfs_loop_file_view(layout, force, &info)?; + + println!( + "forkpress: detached shared Linux XFS COW storage mounted at {}", + layout.linux_xfs_mount.display() + ); + if print_remove_site_hint { + println!("Remove site: rm -rf {}", shell_quote_path(&layout.work_dir)); + } + println!( + "Attach again: forkpress storage mount --work-dir {}", + shell_quote_path(&layout.work_dir) + ); + Ok(()) +} + +#[cfg(target_os = "linux")] +fn cleanup_failed_linux_xfs_loop_prepare( + layout: &Layout, + err: LinuxXfsLoopPrepareError, +) -> Result { + if err.mounted_here { + let _lock = lock_linux_xfs_global_storage(layout)?; + if let Some(info) = linux_xfs_mount_info(&layout.linux_xfs_mount)? { + unmount_linux_xfs_loop_file_view(layout, false, &info).with_context(|| { + format!( + "failed to detach partial Linux XFS COW storage at {} after setup failure", + layout.linux_xfs_mount.display() + ) + })?; + } + } + Ok(err.source) +} + +#[cfg(target_os = "linux")] +fn unmount_linux_xfs_loop_file_view( + layout: &Layout, + force: bool, + info: &LinuxXfsMountInfo, +) -> Result<()> { + let target = CString::new(path_bytes(&layout.linux_xfs_mount)).with_context(|| { + format!( + "{} contains an interior NUL byte", + layout.linux_xfs_mount.display() + ) + })?; + let flags = if force { libc::MNT_FORCE } else { 0 }; + let rc = unsafe { libc::umount2(target.as_ptr(), flags) }; + if rc != 0 { + let err = std::io::Error::last_os_error(); + if err.raw_os_error() == Some(libc::EBUSY) { + bail!( + "shared Linux XFS COW storage is still busy at {}.\nStop other ForkPress sites using linux-xfs-loop storage, close terminals/editors using that path, or inspect open files with:\n lsof +D {}\nThen run:\n forkpress storage detach --work-dir {}{}", + layout.linux_xfs_mount.display(), + shell_quote_path(&layout.linux_xfs_mount), + shell_quote_path(&layout.work_dir), + if force { "" } else { " --force" } + ); + } + return Err(err).with_context(|| { + format!( + "failed to unmount shared Linux XFS COW storage {} mounted from {}", + layout.linux_xfs_mount.display(), + info.device + ) + }); + } + + if linux_xfs_mount_info(&layout.linux_xfs_mount)?.is_some() { + bail!( + "umount2 reported success, but COW storage is still attached at {}", + layout.linux_xfs_mount.display() + ); + } + Ok(()) +} + +#[cfg(not(target_os = "linux"))] +fn detach_linux_xfs_loop_file_view_impl( + _layout: &Layout, + _force: bool, + _print_remove_site_hint: bool, +) -> Result<()> { + bail!("Linux XFS loop detach is only available on Linux") +} + #[cfg(target_os = "macos")] fn run_hdiutil(args: impl IntoIterator) -> Result<()> { let output = hdiutil_output(args)?; @@ -1737,6 +2424,19 @@ fn hdiutil_failure_message(output: &std::process::Output) -> String { #[cfg(target_os = "macos")] fn link_cow_branches_to_macos_cow(layout: &Layout) -> Result<()> { + link_cow_branches_to_mount_backed_storage( + layout, + &layout.macos_cow_branches_dir, + "APFS sparsebundle", + ) +} + +#[cfg(unix)] +fn link_cow_branches_to_mount_backed_storage( + layout: &Layout, + storage_branches_dir: &Path, + label: &str, +) -> Result<()> { use std::os::unix::fs::symlink; if layout.cow_branches_dir != layout.cow_dir.join("branches") { @@ -1754,21 +2454,22 @@ fn link_cow_branches_to_macos_cow(layout: &Layout) -> Result<()> { layout.cow_branches_dir.display() ) })?; - if target == layout.macos_cow_branches_dir { + if target == storage_branches_dir { return Ok(()); } bail!( "{} already points to {}; expected {}", layout.cow_branches_dir.display(), target.display(), - layout.macos_cow_branches_dir.display() + storage_branches_dir.display() ); } Ok(meta) if meta.is_dir() => { if !is_empty_dir(&layout.cow_branches_dir)? { bail!( - "{} already contains branch data and cannot be replaced with APFS sparsebundle storage", - layout.cow_branches_dir.display() + "{} already contains branch data and cannot be replaced with {} storage", + layout.cow_branches_dir.display(), + label ); } fs::remove_dir(&layout.cow_branches_dir).with_context(|| { @@ -1790,16 +2491,25 @@ fn link_cow_branches_to_macos_cow(layout: &Layout) -> Result<()> { } } - symlink(&layout.macos_cow_branches_dir, &layout.cow_branches_dir).with_context(|| { + symlink(storage_branches_dir, &layout.cow_branches_dir).with_context(|| { format!( "failed to link {} -> {}", layout.cow_branches_dir.display(), - layout.macos_cow_branches_dir.display() + storage_branches_dir.display() ) }) } -#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +#[cfg(not(unix))] +fn link_cow_branches_to_mount_backed_storage( + _layout: &Layout, + _storage_branches_dir: &Path, + label: &str, +) -> Result<()> { + bail!("{label} file view requires Unix symlinks") +} + +#[cfg_attr(not(any(target_os = "macos", target_os = "linux")), allow(dead_code))] fn is_empty_dir(path: &Path) -> Result { let mut entries = fs::read_dir(path).with_context(|| format!("failed to read {}", path.display()))?; @@ -2143,12 +2853,12 @@ fn try_clone_file(_source: &Path, _dest: &Path) -> Result<()> { bail!("platform file clone unsupported") } -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "linux", target_os = "macos"))] fn shell_quote_path(path: &std::path::Path) -> String { shell_quote(&path.to_string_lossy()) } -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "linux", target_os = "macos"))] fn shell_quote(value: &str) -> String { if value .chars() @@ -2159,6 +2869,13 @@ fn shell_quote(value: &str) -> String { format!("'{}'", value.replace('\'', "'\\''")) } +#[cfg(target_os = "linux")] +fn path_bytes(path: &Path) -> &[u8] { + use std::os::unix::ffi::OsStrExt; + + path.as_os_str().as_bytes() +} + #[cfg(test)] mod tests { use super::*; @@ -2228,4 +2945,71 @@ mod tests { #[cfg(not(target_os = "windows"))] assert_eq!(bytes, b"canonical"); } + + #[test] + fn cow_file_view_storage_dir_selects_physical_mount_roots() { + let root = std::env::temp_dir().join(format!( + "forkpress-storage-dir-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let layout = Layout::new(root.join(".forkpress")).unwrap(); + + assert_eq!( + cow_file_view_storage_dir(&layout, Some(FileViewStrategy::Reflink)), + layout.cow_branches_dir.as_path() + ); + assert_eq!( + cow_file_view_storage_dir(&layout, Some(FileViewStrategy::MacosApfsSparsebundle)), + layout.macos_cow_branches_dir.as_path() + ); + assert_eq!( + cow_file_view_storage_dir(&layout, Some(FileViewStrategy::LinuxXfsLoop)), + layout.linux_xfs_branches_dir.as_path() + ); + } + + #[test] + #[cfg(unix)] + fn mount_backed_public_branch_link_points_to_storage_root() { + let root = std::env::temp_dir().join(format!( + "forkpress-storage-link-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let public_root = root.join("main"); + let storage_root = root.join("storage/main"); + fs::create_dir_all(&storage_root).unwrap(); + + ensure_mount_backed_cow_public_branch_link(&public_root, &storage_root, "test").unwrap(); + + assert_eq!(fs::read_link(&public_root).unwrap(), storage_root); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + #[cfg(target_os = "linux")] + fn linux_mountinfo_parser_decodes_mount_source_and_type() { + let line = "42 23 7:1 / /tmp/forkpress\\040mount rw,nosuid - xfs /dev/loop7 rw,attr2"; + let parsed = parse_linux_mountinfo_line(line).unwrap(); + assert_eq!(parsed.mount_point, "/tmp/forkpress mount"); + assert_eq!(parsed.fs_type, "xfs"); + assert_eq!(parsed.source, "/dev/loop7"); + } + + #[test] + #[cfg(target_os = "linux")] + fn loop_file_name_is_nul_terminated_after_truncation() { + let long_path = PathBuf::from(format!("/{}", "a".repeat(128))); + let mut out = [0xff; 64]; + copy_loop_file_name(&long_path, &mut out); + assert_eq!(out[63], 0); + assert_eq!(out[62], b'a'); + } } diff --git a/tests/cow/e2e.sh b/tests/cow/e2e.sh index 9f0b9bd9..f9c655f8 100755 --- a/tests/cow/e2e.sh +++ b/tests/cow/e2e.sh @@ -233,7 +233,7 @@ test -d "$WORK/.forkpress" test -d "$WORK/main" test -f "$WORK/main/wp-load.php" test ! -e "$WORK/.forkpress/cow/branches/main" -grep -E 'file_view = "(reflink|file-copy|macos-apfs-sparsebundle)"' "$WORK_DIR/site.toml" >/dev/null +grep -E 'file_view = "(reflink|file-copy|macos-apfs-sparsebundle|linux-xfs-loop)"' "$WORK_DIR/site.toml" >/dev/null grep -F 'strategy = "cow"' "$WORK_DIR/site.toml" >/dev/null "$BIN" doctor storage --work-dir "$WORK_DIR" > "$TMP/storage-doctor.out" grep -F "ForkPress storage capability report" "$TMP/storage-doctor.out" >/dev/null From d76d151aa827bd5f647666cb0996b228c3b60836 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Fri, 15 May 2026 15:02:49 +0200 Subject: [PATCH 2/2] Fix Linux XFS detach cleanup Keep automatic stop paths from detaching the shared XFS volume while another Linux XFS site is still running. Detect interrupted XFS setup from the mounted per-site directory even when site.toml is missing, and print deletion cleanup that includes the hidden per-site XFS data directory. --- README.md | 3 + crates/forkpress-cli/src/app.rs | 155 +++++++++++++++++++++++----- crates/forkpress-storage/src/lib.rs | 57 +++++++++- docs/storage/linux.md | 5 + docs/storage/overview.md | 4 + 5 files changed, 193 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index addd9108..9719d855 100644 --- a/README.md +++ b/README.md @@ -316,6 +316,9 @@ forkpress stop On Linux XFS-loop sites, `forkpress storage detach` unmounts the shared volume only after other running ForkPress servers using that storage view are stopped. +Before deleting a Linux XFS-loop site, remove the hidden per-site directory +inside the shared mount too. `forkpress storage detach` prints the exact +`rm -rf` command while the volume is still attached. ## Logs diff --git a/crates/forkpress-cli/src/app.rs b/crates/forkpress-cli/src/app.rs index 060ad89c..16a88587 100644 --- a/crates/forkpress-cli/src/app.rs +++ b/crates/forkpress-cli/src/app.rs @@ -47,22 +47,13 @@ use forkpress_server::{ use forkpress_storage::{ CowMergeAuditQuery, CowSiteInit, RemoteBranchOptions, RemoteSiteAdd, add_remote_site, branch_remote_site, compact_macos_apfs_sparsebundle_file_view, cow_branch_names, - cow_branch_root, create_cow_branch, delete_cow_branch, + cow_branch_root, create_cow_branch, delete_cow_branch, detach_linux_xfs_loop_file_view, detach_macos_apfs_sparsebundle_file_view, ensure_cow_branch_exists, ensure_cow_file_view_ready, ensure_cow_main_branch, inspect_cow_merge_audit, list_remote_sites, lock_cow_lifecycle, lock_cow_operations, merge_cow_branch, prepare_cow_file_view, print_cow_storage_status, - print_macos_cow_storage_status, probe_reflink_dir, probe_remote_site, reset_cow_branch, - resolve_cow_merge_conflict, review_cow_merge_audit_record, show_cow_branch, - write_cow_branch_list, write_cow_strategy_notes, -}; -use forkpress_storage::{ - CowSiteInit, compact_macos_apfs_sparsebundle_file_view, cow_branch_names, cow_branch_root, - create_cow_branch, delete_cow_branch, detach_linux_xfs_loop_file_view, - detach_macos_apfs_sparsebundle_file_view, ensure_cow_branch_exists, ensure_cow_file_view_ready, - ensure_cow_main_branch, lock_cow_lifecycle, lock_cow_operations, prepare_cow_file_view, - print_cow_storage_status, print_linux_xfs_loop_storage_status, print_macos_cow_storage_status, - probe_reflink_dir, reset_cow_branch, show_cow_branch, write_cow_branch_list, - write_cow_strategy_notes, + print_linux_xfs_loop_storage_status, print_macos_cow_storage_status, probe_reflink_dir, + probe_remote_site, reset_cow_branch, resolve_cow_merge_conflict, review_cow_merge_audit_record, + show_cow_branch, write_cow_branch_list, write_cow_strategy_notes, }; #[cfg(feature = "dev-experiments")] use forkpress_storage::{copy_tree_cow, plain_branch_names}; @@ -1097,6 +1088,7 @@ fn storage_detach_command(args: StorageDetachArgs) -> Result { args.force, args.keep_server, Duration::from_secs(args.timeout), + DetachStorageMode::Explicit, )? { println!( "forkpress: no detachable storage found for {}", @@ -1144,10 +1136,10 @@ fn detach_storage_for_layout_if_present( force: bool, keep_server: bool, timeout: Duration, + mode: DetachStorageMode, ) -> Result { let manifest = read_site_manifest(layout)?; - let has_linux_xfs = manifest.as_ref().and_then(|manifest| manifest.file_view) - == Some(FileViewStrategy::LinuxXfsLoop); + let has_linux_xfs = has_linux_xfs_detachable_storage(layout, manifest.as_ref()); let has_macos_cow = manifest.as_ref().and_then(|manifest| manifest.file_view) == Some(FileViewStrategy::MacosApfsSparsebundle) || layout.macos_cow_image.exists() @@ -1159,8 +1151,22 @@ fn detach_storage_for_layout_if_present( with_stopped_cow_server_for_storage(layout, keep_server, timeout, || { if has_linux_xfs { - ensure_no_other_linux_xfs_servers(layout)?; - detach_linux_xfs_loop_file_view(layout, force, true) + if let Some(record) = other_linux_xfs_server(layout)? { + if should_detach_linux_xfs_storage(mode, Some(&record))? { + detach_linux_xfs_loop_file_view(layout, force, true) + } else { + println!( + "forkpress: leaving shared Linux XFS COW storage mounted at {} because server pid {} at {} is still using it", + layout.linux_xfs_mount.display(), + record.pid, + record.work_dir.display() + ); + Ok(()) + } + } else { + should_detach_linux_xfs_storage(mode, None)?; + detach_linux_xfs_loop_file_view(layout, force, true) + } } else { detach_macos_apfs_sparsebundle_file_view(layout, force, true) } @@ -1168,23 +1174,61 @@ fn detach_storage_for_layout_if_present( Ok(true) } -fn ensure_no_other_linux_xfs_servers(layout: &Layout) -> Result<()> { +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum DetachStorageMode { + Automatic, + Explicit, +} + +fn has_linux_xfs_detachable_storage(layout: &Layout, manifest: Option<&SiteManifest>) -> bool { + has_linux_xfs_detachable_storage_state( + manifest, + layout.linux_xfs_site_dir.exists(), + layout.linux_xfs_branches_dir.exists(), + ) +} + +fn has_linux_xfs_detachable_storage_state( + manifest: Option<&SiteManifest>, + linux_xfs_site_dir_exists: bool, + linux_xfs_branches_dir_exists: bool, +) -> bool { + manifest.and_then(|manifest| manifest.file_view) == Some(FileViewStrategy::LinuxXfsLoop) + || linux_xfs_site_dir_exists + || linux_xfs_branches_dir_exists +} + +fn other_linux_xfs_server(layout: &Layout) -> Result> { for record in live_server_records()? { if record.work_dir == layout.work_dir { continue; } let other_layout = Layout::new(record.work_dir.clone())?; - if read_site_manifest(&other_layout)?.and_then(|manifest| manifest.file_view) - == Some(FileViewStrategy::LinuxXfsLoop) - { - bail!( - "shared Linux XFS COW storage is still used by server pid {} at {}. Stop all ForkPress sites before detaching the shared volume.", - record.pid, - other_layout.work_dir.display() - ); + let manifest = read_site_manifest(&other_layout)?; + if has_linux_xfs_detachable_storage(&other_layout, manifest.as_ref()) { + return Ok(Some(record)); } } - Ok(()) + Ok(None) +} + +fn should_detach_linux_xfs_storage( + mode: DetachStorageMode, + other: Option<&ServerRecord>, +) -> Result { + if let Some(record) = other { + return match mode { + DetachStorageMode::Automatic => Ok(false), + DetachStorageMode::Explicit => { + bail!( + "shared Linux XFS COW storage is still used by server pid {} at {}. Stop all ForkPress sites before detaching the shared volume.", + record.pid, + record.work_dir.display() + ); + } + }; + } + Ok(true) } fn with_stopped_cow_server_for_storage( @@ -1616,6 +1660,54 @@ mod storage_strategy_tests { assert!(args.keep_server); } + #[test] + fn linux_xfs_detachable_storage_is_detected_without_manifest() { + assert!(!has_linux_xfs_detachable_storage_state(None, false, false)); + assert!(has_linux_xfs_detachable_storage_state(None, true, false)); + assert!(has_linux_xfs_detachable_storage_state(None, false, true)); + + let reflink_manifest = + SiteManifest::new(StorageStrategy::Cow).with_file_view(FileViewStrategy::Reflink); + assert!(!has_linux_xfs_detachable_storage_state( + Some(&reflink_manifest), + false, + false + )); + + let linux_xfs_manifest = + SiteManifest::new(StorageStrategy::Cow).with_file_view(FileViewStrategy::LinuxXfsLoop); + assert!(has_linux_xfs_detachable_storage_state( + Some(&linux_xfs_manifest), + false, + false + )); + } + + #[test] + fn automatic_linux_xfs_detach_leaves_shared_mount_for_other_servers() { + let record = ServerRecord { + pid: 12345, + child_pid: None, + work_dir: PathBuf::from("/tmp/forkpress-other/.forkpress"), + host: "127.0.0.1".to_string(), + port: 18080, + root_host: "wp.localhost".to_string(), + log: PathBuf::from("/tmp/forkpress-other/.forkpress/logs/forkpress-server.log"), + }; + + assert!( + !should_detach_linux_xfs_storage(DetachStorageMode::Automatic, Some(&record)).unwrap() + ); + assert!(should_detach_linux_xfs_storage(DetachStorageMode::Automatic, None).unwrap()); + + let err = should_detach_linux_xfs_storage(DetachStorageMode::Explicit, Some(&record)) + .unwrap_err(); + assert!( + err.to_string() + .contains("Stop all ForkPress sites before detaching the shared volume") + ); + } + #[cfg(unix)] #[test] fn cow_storage_lifecycle_waits_for_background_start_lock() { @@ -2013,7 +2105,13 @@ fn start_command(args: StartArgs) -> Result { drop(php); drop(_registration); - detach_storage_for_layout_if_present(&layout, false, false, Duration::from_secs(10))?; + detach_storage_for_layout_if_present( + &layout, + false, + false, + Duration::from_secs(10), + DetachStorageMode::Automatic, + )?; Ok(0) } @@ -2240,6 +2338,7 @@ fn server_stop_command(args: ServerStopArgs) -> Result { args.force, false, Duration::from_secs(args.timeout), + DetachStorageMode::Automatic, )?; } diff --git a/crates/forkpress-storage/src/lib.rs b/crates/forkpress-storage/src/lib.rs index e2e7558b..b37cd2ad 100644 --- a/crates/forkpress-storage/src/lib.rs +++ b/crates/forkpress-storage/src/lib.rs @@ -2296,15 +2296,16 @@ fn detach_linux_xfs_loop_file_view_impl( return Ok(()); }; + if print_remove_site_hint { + println!("{}", linux_xfs_remove_site_hint(layout)); + } + unmount_linux_xfs_loop_file_view(layout, force, &info)?; println!( "forkpress: detached shared Linux XFS COW storage mounted at {}", layout.linux_xfs_mount.display() ); - if print_remove_site_hint { - println!("Remove site: rm -rf {}", shell_quote_path(&layout.work_dir)); - } println!( "Attach again: forkpress storage mount --work-dir {}", shell_quote_path(&layout.work_dir) @@ -2312,6 +2313,30 @@ fn detach_linux_xfs_loop_file_view_impl( Ok(()) } +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +fn linux_xfs_remove_site_hint(layout: &Layout) -> String { + let mut paths = vec![layout.work_dir.clone()]; + if let Ok(branches) = cow_branch_names(layout) { + for branch in branches { + let public_root = cow_branch_root(layout, &branch); + if !paths.iter().any(|path| path == &public_root) { + paths.push(public_root); + } + } + } + if !paths.iter().any(|path| path == &layout.linux_xfs_site_dir) { + paths.push(layout.linux_xfs_site_dir.clone()); + } + + let paths = paths + .iter() + .map(|path| shell_quote_path(path)) + .collect::>() + .join(" "); + format!("Remove site before detaching shared storage:\n rm -rf {paths}") +} + #[cfg(target_os = "linux")] fn cleanup_failed_linux_xfs_loop_prepare( layout: &Layout, @@ -2972,6 +2997,32 @@ mod tests { ); } + #[test] + #[cfg(any(target_os = "linux", target_os = "macos"))] + fn linux_xfs_remove_site_hint_includes_hidden_site_dir() { + let root = std::env::temp_dir().join(format!( + "forkpress-xfs-remove-hint-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let layout = Layout::new(root.join(".forkpress")).unwrap(); + let main = cow_branch_root(&layout, "main"); + fs::create_dir_all(&main).unwrap(); + fs::write(main.join("wp-load.php"), b"