diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7942f824..84e67400 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -91,6 +91,36 @@ jobs: - name: Integration tests (repo mount) run: unset HF_TOKEN && cargo test --release --test repo_ops -- --test-threads=1 --nocapture + smoke-test-windows: + name: Smoke Tests (Windows NFS) + runs-on: windows-2022 + needs: lint-test + env: + HF_TOKEN: ${{ secrets.HF_TOKEN }} + HF_ENDPOINT: https://huggingface.co + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@1.95.0 + with: + targets: x86_64-pc-windows-msvc + + - uses: Swatinem/rust-cache@v2 + with: + key: smoke-windows + + - name: Enable Client for NFS feature + shell: powershell + run: Install-WindowsFeature -Name NFS-Client + + - name: Build hf-mount-nfs + shell: powershell + run: cargo build --release --no-default-features --features nfs --bin hf-mount-nfs + + - name: Integration tests (NFS, bucket lifecycle) + shell: powershell + run: cargo test --release --no-default-features --features nfs --test nfs_ops -- --test-threads=1 --nocapture + fsx: name: fsx (data integrity) runs-on: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a05f68cb..4aabccb1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -224,6 +224,46 @@ jobs: name: macos-${{ matrix.arch }} path: dist/ + build-windows: + name: Build Windows (${{ matrix.arch }}) + needs: [bump] + if: | + always() && + (startsWith(github.ref, 'refs/tags/') || needs.bump.result == 'success') + runs-on: windows-2022 + strategy: + matrix: + include: + - arch: x86_64 + target: x86_64-pc-windows-msvc + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.bump.outputs.new_tag || github.ref }} + + - uses: dtolnay/rust-toolchain@1.95.0 + with: + targets: ${{ matrix.target }} + + - uses: Swatinem/rust-cache@v2 + with: + key: release-${{ matrix.target }} + + - name: Build + # No vendored-openssl: native-tls uses Schannel on Windows, no OpenSSL dependency. + run: cargo build --release --target ${{ matrix.target }} --no-default-features --features nfs --bin hf-mount-nfs + + - name: Package + shell: bash + run: | + mkdir -p dist + cp target/${{ matrix.target }}/release/hf-mount-nfs.exe dist/hf-mount-nfs-${{ matrix.arch }}-windows.exe + + - uses: actions/upload-artifact@v4 + with: + name: windows-${{ matrix.arch }} + path: dist/ + # Compute the docker tag base + whether to push :latest. A bumped # release wins, then a manually-pushed tag, else fall back to sha-. # `latest` only on stable releases (tag matches `v\d` and has no `-`). @@ -263,18 +303,19 @@ jobs: release: name: Create Release - needs: [bump, build-linux, build-macos] + needs: [bump, build-linux, build-macos, build-windows] if: | always() && needs.build-linux.result == 'success' && needs.build-macos.result == 'success' && + needs.build-windows.result == 'success' && (startsWith(github.ref, 'refs/tags/') || needs.bump.result == 'success') runs-on: ubuntu-22.04 steps: - uses: actions/download-artifact@v4 with: path: artifacts - pattern: '{linux-*,macos-*}' + pattern: '{linux-*,macos-*,windows-*}' merge-multiple: true - name: List artifacts diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml new file mode 100644 index 00000000..e8894557 --- /dev/null +++ b/.github/workflows/windows-build.yml @@ -0,0 +1,44 @@ +name: Windows Build + +on: + pull_request: + paths: + - "src/**" + - "Cargo.toml" + - "Cargo.lock" + - ".github/workflows/windows-build.yml" + workflow_dispatch: + +env: + CARGO_TERM_COLOR: always + +jobs: + build: + name: Build hf-mount-nfs.exe + runs-on: windows-2022 + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@1.95.0 + with: + targets: x86_64-pc-windows-msvc + + - uses: Swatinem/rust-cache@v2 + with: + key: windows-build + + - name: Build hf-mount-nfs + # No vendored-openssl: native-tls uses Schannel on Windows, no OpenSSL dependency. + run: cargo build --release --target x86_64-pc-windows-msvc --no-default-features --features nfs --bin hf-mount-nfs + + - name: Stage artifacts + shell: bash + run: | + mkdir -p dist + cp target/x86_64-pc-windows-msvc/release/hf-mount-nfs.exe dist/ + + - uses: actions/upload-artifact@v4 + with: + name: hf-mount-windows-x86_64 + path: dist/ + if-no-files-found: error diff --git a/Cargo.lock b/Cargo.lock index f241e7e5..cb8254d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1481,8 +1481,7 @@ dependencies = [ [[package]] name = "nfsserve" version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef1424b6d88c60a091931392970999ea3ceab132d968e6a5545c770fad5b97d7" +source = "git+https://github.com/huggingface/nfsserve.git?branch=feat%2Fportmap-listener#bbfcce6322183b4a75b3931c9ab2a5b78b550401" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 1fbb4586..fb355786 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,14 +21,13 @@ bytes = "1" chrono = "0.4" clap = { version = "4", features = ["derive", "env"] } ctrlc = { version = "3", features = ["termination"] } -fuser = { version = "0.17", optional = true } futures = "0.3" libc = "0.2" nfsserve = { version = "0.11", optional = true } reqwest = { version = "0.12", features = ["json", "stream"] } serde = { version = "1", features = ["derive"] } serde_json = "1" -tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal", "process"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } ulid = "1" @@ -40,6 +39,12 @@ fuse = ["dep:fuser"] nfs = ["dep:nfsserve"] vendored-openssl = ["openssl-sys/vendored"] +# fuser is Unix-only (Linux/macOS FUSE bindings). Gating it as a Unix-target +# dep means the `fuse` feature is effectively unavailable on Windows; the NFS +# backend (`hf-mount-nfs`) is the supported path on Windows. +[target.'cfg(unix)'.dependencies] +fuser = { version = "0.17", optional = true } + [dependencies.openssl-sys] version = "0.9" optional = true @@ -68,5 +73,8 @@ tempfile = "3" # Pin fuser to the HF fork until cberner/fuser merges Session::from_fds # (sidecar with externally pre-cloned fds — see #94). +# Pin nfsserve to the HF fork until the portmap_listener PR is merged & released +# (Windows NFS clients need a portmapper on 127.0.0.1:111 — see nfsserve#44). [patch.crates-io] fuser = { git = "https://github.com/huggingface/fuser.git", branch = "sidecar-multi-fd" } +nfsserve = { git = "https://github.com/huggingface/nfsserve.git", branch = "feat/portmap-listener" } diff --git a/README.md b/README.md index 9cfb532f..7e9ebdf8 100644 --- a/README.md +++ b/README.md @@ -60,22 +60,38 @@ The NFS backend has no system dependencies. For FUSE: **macOS**: install [macFUSE](https://osxfuse.github.io/) (`brew install macfuse`, requires reboot on first install) +**Windows**: NFS backend only (FUSE is Unix-only). Enable the "Client for NFS" feature: + +```powershell +# Windows Server +Install-WindowsFeature -Name NFS-Client + +# Windows 10 / 11 +Enable-WindowsOptionalFeature -Online -FeatureName ServicesForNFS-ClientOnly,ClientForNFS-Infrastructure -All +``` + +`hf-mount-nfs.exe` must run as Administrator (it binds the privileged portmapper port 111). To make admin-mounted drives visible to non-admin Explorer / apps, set `EnableLinkedConnections` and reboot ([MS doc](https://learn.microsoft.com/en-US/troubleshoot/windows-client/networking/mapped-drives-not-available-from-elevated-command)): + +```cmd +reg add HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System /v EnableLinkedConnections /t REG_DWORD /d 1 /f +``` + ### Build from source Requires Rust 1.89+. ```bash -# NFS only (no system deps, works everywhere) +# NFS only (no system deps, works everywhere including Windows) cargo build --release --features nfs -# FUSE (requires macFUSE on macOS, fuse3 on Linux) +# FUSE (requires macFUSE on macOS, fuse3 on Linux; not available on Windows) cargo build --release --features fuse # All backends cargo build --release --features fuse,nfs ``` -Binaries: `target/release/hf-mount`, `target/release/hf-mount-nfs`, `target/release/hf-mount-fuse` +Binaries: `target/release/hf-mount`, `target/release/hf-mount-nfs`, `target/release/hf-mount-fuse`. On Windows only `hf-mount-nfs.exe` is produced. ## Best for / Not for diff --git a/src/bin/hf-mount-fuse.rs b/src/bin/hf-mount-fuse.rs index a6f8d20e..d0bef054 100644 --- a/src/bin/hf-mount-fuse.rs +++ b/src/bin/hf-mount-fuse.rs @@ -1,8 +1,18 @@ +#[cfg(not(unix))] +fn main() { + eprintln!("hf-mount-fuse is Unix-only (FUSE). Use hf-mount-nfs.exe on Windows."); + std::process::exit(1); +} + +#[cfg(unix)] use tracing::info; +#[cfg(unix)] use hf_mount::fuse::mount_fuse; +#[cfg(unix)] use hf_mount::setup::setup; +#[cfg(unix)] fn main() { let s = setup(false); let mut daemon_guard = hf_mount::daemon::DaemonGuard::from_env(); diff --git a/src/bin/hf-mount.rs b/src/bin/hf-mount.rs index bb9db775..c98d8fe9 100644 --- a/src/bin/hf-mount.rs +++ b/src/bin/hf-mount.rs @@ -1,3 +1,5 @@ +#![cfg_attr(not(unix), allow(dead_code, unused_imports))] + use std::path::PathBuf; use clap::Parser; @@ -30,6 +32,13 @@ enum Command { Status, } +#[cfg(not(unix))] +fn main() { + eprintln!("hf-mount daemon controller is Unix-only. On Windows, run hf-mount-nfs.exe directly."); + std::process::exit(1); +} + +#[cfg(unix)] fn main() { let cli = Cli::parse(); @@ -110,6 +119,7 @@ fn main() { /// Replace the current process with the backend binary via execvp. /// Passes the ready-notification fd via the HF_MOUNT_DAEMON_FD env var. +#[cfg(unix)] fn exec_backend(backend: &std::path::Path, args: &[String], guard: &hf_mount::daemon::DaemonGuard) -> std::io::Error { use std::os::unix::process::CommandExt; diff --git a/src/lib.rs b/src/lib.rs index 2be82716..1af09cc6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,8 +1,21 @@ pub mod cached_xet_client; +#[cfg(unix)] pub mod daemon; +#[cfg(not(unix))] +pub mod daemon { + //! Windows stub: only the surface used by hf-mount-nfs. The full daemon + //! controller (hf-mount) is Unix-only and not built on Windows. + pub struct DaemonGuard; + impl DaemonGuard { + pub fn from_env() -> Option { + None + } + pub fn notify_ready(&mut self) {} + } +} pub mod error; pub mod file_cache; -#[cfg(feature = "fuse")] +#[cfg(all(unix, feature = "fuse"))] pub mod fuse; pub mod hub_api; #[cfg(feature = "nfs")] diff --git a/src/nfs.rs b/src/nfs.rs index 55678cf2..235178ee 100644 --- a/src/nfs.rs +++ b/src/nfs.rs @@ -516,6 +516,56 @@ pub async fn mount_nfs( } } + // Windows NFS client (Services for NFS / Client for NFS feature). + // The mount point must be a drive letter (e.g. "Z:") or an empty NTFS dir. + // Windows mount.exe can't bypass portmapper, so spawn nfsserve's + // `portmap_listener` on 127.0.0.1:111 to map NFS/MOUNT v3 to the actual + // server port. Requires Administrator (port 111 is privileged). + #[cfg(windows)] + let portmapper_handle = nfsserve::portmap_listener::spawn("127.0.0.1:111".parse().unwrap(), port) + .await + .map_err(|e| { + std::io::Error::other(format!( + "failed to bind portmapper on 127.0.0.1:111: {e} (Administrator required, or another portmap is running)" + )) + })?; + #[cfg(windows)] + let skip_auto_mount = std::env::var_os("HF_MOUNT_SKIP_AUTO_MOUNT").is_some(); + #[cfg(not(windows))] + let skip_auto_mount = false; + #[cfg(windows)] + { + let _ = actimeo; // mount.exe has no actimeo equivalent. + let mut opts = String::from("nolock,anon,mtype=hard,rsize=32,wsize=32,timeout=60"); + if read_only { + opts.push_str(",ro"); + } + let share = "\\\\127.0.0.1\\!"; + let cmd = format!("mount.exe -o {opts} {share} {mount_point_str}"); + if skip_auto_mount { + info!( + "HF_MOUNT_SKIP_AUTO_MOUNT set — server + portmapper running, mount.exe NOT invoked.\n\ + Run manually in another admin shell:\n {cmd}" + ); + } else { + info!("Running: {cmd}"); + let output = tokio::process::Command::new("mount") + .args(["-o", &opts, share, mount_point_str]) + .output() + .await?; + if !output.status.success() { + server_handle.abort(); + portmapper_handle.abort(); + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + return Err(std::io::Error::other(format!( + "mount.exe failed with {} (is the 'Client for NFS' feature enabled? is the process running as Administrator?): cmd=`{cmd}` stdout={stdout} stderr={stderr}", + output.status + ))); + } + } + } + info!("NFS mount active at {}", mount_point_str); // Signal the parent process that the mount is live (daemon mode). @@ -528,9 +578,15 @@ pub async fn mount_nfs( // handle_forever() is an infinite accept() loop that never returns on its own. // On Linux, `umount` doesn't always send the UMNT RPC, so we also poll // /proc/mounts as a fallback to detect when the mount disappears. + // SIGTERM future: real signal listener on Unix, never-completing on Windows. + #[cfg(unix)] let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()).expect("Failed to register SIGTERM"); - tokio::pin!(server_handle); + #[cfg(unix)] + let sigterm_fut = async move { sigterm.recv().await }; + #[cfg(not(unix))] + let sigterm_fut = std::future::pending::>(); + tokio::pin!(server_handle, sigterm_fut); loop { tokio::select! { msg = mount_rx.recv() => { @@ -551,13 +607,13 @@ pub async fn mount_nfs( unmount_nfs(mount_point_str); break; } - _ = sigterm.recv() => { + _ = &mut sigterm_fut => { info!("Received SIGTERM, unmounting..."); unmount_nfs(mount_point_str); break; } _ = tokio::time::sleep(std::time::Duration::from_secs(2)) => { - if !is_mounted(mount_point_str) { + if !skip_auto_mount && !is_mounted(mount_point_str) { info!("NFS mount disappeared, shutting down"); break; } @@ -565,6 +621,13 @@ pub async fn mount_nfs( } } + // Stop the NFS server task explicitly (dropping the JoinHandle does not + // cancel a tokio task — the server would keep accept()ing on its socket). + server_handle.abort(); + let _ = server_handle.await; + #[cfg(windows)] + portmapper_handle.abort(); + // Drain handle pool: flush and release all cached handles before VFS shutdown. let entries = pool_for_shutdown.lock().expect("handle_pool poisoned").drain(); for (ino, file_handle) in entries { @@ -751,31 +814,14 @@ fn system_time_to_nfstime(t: SystemTime) -> nfstime3 { /// Check if a path is still an active mount point. fn unmount_nfs(mount_point: &str) { - use std::ffi::CString; - - // Try libc unmount first (no external process dependency). - if let Ok(c_path) = CString::new(mount_point) { - #[cfg(target_os = "linux")] - { - if unsafe { libc::umount2(c_path.as_ptr(), libc::MNT_DETACH) } == 0 { - return; - } - } - #[cfg(target_os = "macos")] - { - if unsafe { libc::unmount(c_path.as_ptr(), libc::MNT_FORCE) } == 0 { - return; - } - } - } - - // Fallback: external command. - #[cfg(target_os = "macos")] - if let Err(e) = std::process::Command::new("umount").arg(mount_point).status() { - tracing::warn!("NFS unmount fallback failed for {}: {}", mount_point, e); - } #[cfg(target_os = "linux")] { + use std::ffi::CString; + if let Ok(c_path) = CString::new(mount_point) + && unsafe { libc::umount2(c_path.as_ptr(), libc::MNT_DETACH) } == 0 + { + return; + } let result = if unsafe { libc::getuid() } == 0 { std::process::Command::new("umount").arg(mount_point).status() } else { @@ -787,6 +833,26 @@ fn unmount_nfs(mount_point: &str) { tracing::warn!("NFS unmount fallback failed for {}: {}", mount_point, e); } } + + #[cfg(target_os = "macos")] + { + use std::ffi::CString; + if let Ok(c_path) = CString::new(mount_point) + && unsafe { libc::unmount(c_path.as_ptr(), libc::MNT_FORCE) } == 0 + { + return; + } + if let Err(e) = std::process::Command::new("umount").arg(mount_point).status() { + tracing::warn!("NFS unmount fallback failed for {}: {}", mount_point, e); + } + } + + // `umount.exe` ships with the Windows NFS client. `-f` forces unmount + // even if handles are still open. + #[cfg(windows)] + if let Err(e) = std::process::Command::new("umount").args(["-f", mount_point]).status() { + tracing::warn!("NFS unmount fallback failed for {}: {}", mount_point, e); + } } fn is_mounted(path: &str) -> bool { @@ -796,6 +862,12 @@ fn is_mounted(path: &str) -> bool { .map(|s| s.lines().any(|line| line.split_whitespace().nth(1) == Some(path))) .unwrap_or(false) } + #[cfg(windows)] + { + // Best-effort: the drive letter / mount path disappears from the FS namespace + // when the NFS mount is torn down. metadata() succeeds while it's live. + std::fs::metadata(path).is_ok() + } #[cfg(target_os = "macos")] { // On macOS, check via statfs: a mounted NFS will have f_fstypename = "nfs" diff --git a/src/setup.rs b/src/setup.rs index 69cd0121..4399ad3d 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -409,13 +409,13 @@ pub fn build_with_runtime( None }; - let uid = options.uid.unwrap_or_else(|| unsafe { libc::getuid() }); - let gid = options.gid.unwrap_or_else(|| unsafe { libc::getgid() }); + let uid = options.uid.unwrap_or_else(default_uid); + let gid = options.gid.unwrap_or_else(default_gid); - // Ignore EEXIST: the directory may already exist from a previous (possibly - // stale) mount. FUSE/NFS will fail at mount time if it's actually busy. + // Ignore AlreadyExists: the directory may already exist from a previous + // (possibly stale) mount. FUSE/NFS will fail at mount time if it's actually busy. if let Err(e) = std::fs::create_dir_all(&mount_point) - && e.raw_os_error() != Some(libc::EEXIST) + && e.kind() != std::io::ErrorKind::AlreadyExists { panic!("Failed to create mount point {:?}: {e}", mount_point); } @@ -516,22 +516,48 @@ pub fn setup(is_nfs: bool) -> MountSetup { /// Try to raise the soft file descriptor limit to avoid "Too many open files" /// errors during large batch operations. Most FUSE/NFS filesystems do this. +/// No-op on Windows (handle limits are per-process and effectively unbounded). pub fn raise_fd_limit() { - const TARGET_NOFILE: u64 = 65536; - let mut rlim = libc::rlimit { - rlim_cur: 0, - rlim_max: 0, - }; - // SAFETY: rlim is a plain C struct, getrlimit/setrlimit are standard POSIX. - if unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut rlim) } != 0 || rlim.rlim_cur >= TARGET_NOFILE { - return; - } - rlim.rlim_cur = TARGET_NOFILE.min(rlim.rlim_max); - if unsafe { libc::setrlimit(libc::RLIMIT_NOFILE, &rlim) } != 0 { - eprintln!("warning: failed to raise file descriptor limit to {TARGET_NOFILE}"); + #[cfg(unix)] + { + const TARGET_NOFILE: u64 = 65536; + let mut rlim = libc::rlimit { + rlim_cur: 0, + rlim_max: 0, + }; + // SAFETY: rlim is a plain C struct, getrlimit/setrlimit are standard POSIX. + if unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut rlim) } != 0 || rlim.rlim_cur >= TARGET_NOFILE { + return; + } + rlim.rlim_cur = TARGET_NOFILE.min(rlim.rlim_max); + if unsafe { libc::setrlimit(libc::RLIMIT_NOFILE, &rlim) } != 0 { + eprintln!("warning: failed to raise file descriptor limit to {TARGET_NOFILE}"); + } } } +#[cfg(unix)] +fn default_uid() -> u32 { + // SAFETY: getuid is a thread-safe POSIX call with no preconditions. + unsafe { libc::getuid() } +} + +#[cfg(unix)] +fn default_gid() -> u32 { + // SAFETY: getgid is a thread-safe POSIX call with no preconditions. + unsafe { libc::getgid() } +} + +#[cfg(not(unix))] +fn default_uid() -> u32 { + 0 +} + +#[cfg(not(unix))] +fn default_gid() -> u32 { + 0 +} + fn build_cas_config( ctx: &XetContext, runtime: &tokio::runtime::Handle, diff --git a/src/virtual_fs/mod.rs b/src/virtual_fs/mod.rs index eb7bfa22..d1b9e377 100644 --- a/src/virtual_fs/mod.rs +++ b/src/virtual_fs/mod.rs @@ -1,6 +1,5 @@ use std::collections::{HashMap, VecDeque}; use std::fs::{File, OpenOptions}; -use std::os::unix::io::AsRawFd; use std::path::PathBuf; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, OnceLock, RwLock, Weak}; @@ -23,6 +22,38 @@ use inode::{InodeEntry, InodeKind, InodeTable}; use prefetch::{FetchPlan, PrefetchState}; use staging::StagingCoordinator; +// ── Cross-platform positional I/O ────────────────────────────────────── + +/// Read at a fixed offset without moving the file's seek cursor. Thread-safe +/// (atomic offset). Maps to pread(2) on Unix, ReadFile-with-OVERLAPPED on Windows. +fn read_at(file: &File, buf: &mut [u8], offset: u64) -> std::io::Result { + #[cfg(unix)] + { + use std::os::unix::fs::FileExt; + file.read_at(buf, offset) + } + #[cfg(windows)] + { + use std::os::windows::fs::FileExt; + file.seek_read(buf, offset) + } +} + +/// Write at a fixed offset without moving the file's seek cursor. Thread-safe. +/// Maps to pwrite(2) on Unix, WriteFile-with-OVERLAPPED on Windows. +fn write_at(file: &File, buf: &[u8], offset: u64) -> std::io::Result { + #[cfg(unix)] + { + use std::os::unix::fs::FileExt; + file.write_at(buf, offset) + } + #[cfg(windows)] + { + use std::os::windows::fs::FileExt; + file.seek_write(buf, offset) + } +} + // ── Constants ────────────────────────────────────────────────────────── /// Block size reported in stat(2) for `st_blocks` calculation. @@ -1808,24 +1839,14 @@ impl VirtualFs { match read_target { ReadTarget::LocalFd(file) => { - let file_descriptor = file.as_raw_fd(); let mut buf = BytesMut::zeroed(size as usize); - // SAFETY: fd is valid (Arc keeps it alive), buf is correctly sized. - // pread is thread-safe (atomic offset, no shared seek cursor). - let n = unsafe { - libc::pread( - file_descriptor, - buf.as_mut_ptr() as *mut libc::c_void, - size as usize, - offset as i64, - ) - }; - if n < 0 { - Err(std::io::Error::last_os_error().raw_os_error().unwrap_or(libc::EIO)) - } else { - buf.truncate(n as usize); - let eof = (n as u32) < size; - Ok((buf.freeze(), eof)) + match read_at(&file, &mut buf, offset) { + Ok(n) => { + buf.truncate(n); + let eof = (n as u32) < size; + Ok((buf.freeze(), eof)) + } + Err(e) => Err(e.raw_os_error().unwrap_or(libc::EIO)), } } ReadTarget::Remote { prefetch } => { @@ -1947,20 +1968,8 @@ impl VirtualFs { }; match target { - WriteTarget::Local { file, ino: handle_ino } => { - let file_descriptor = file.as_raw_fd(); - let n = unsafe { - libc::pwrite( - file_descriptor, - data.as_ptr() as *const libc::c_void, - data.len(), - offset as i64, - ) - }; - - if n < 0 { - Err(std::io::Error::last_os_error().raw_os_error().unwrap_or(libc::EIO)) - } else { + WriteTarget::Local { file, ino: handle_ino } => match write_at(&file, data, offset) { + Ok(n) => { let written = n as u32; let new_end = offset + written as u64; let mut inodes = self.inode_table.write().expect("inodes poisoned"); @@ -1976,7 +1985,8 @@ impl VirtualFs { inodes.touch(handle_ino); Ok(written) } - } + Err(e) => Err(e.raw_os_error().unwrap_or(libc::EIO)), + }, WriteTarget::Streaming { ino: handle_ino, channel, diff --git a/tests/common/fs_tests.rs b/tests/common/fs_tests.rs index dae115c2..c07a6b91 100644 --- a/tests/common/fs_tests.rs +++ b/tests/common/fs_tests.rs @@ -294,8 +294,10 @@ pub fn run_write_tests(mp: &str, remote_file: &str, remote_content: &str) -> Tes } // 12. Flush idempotency: dup fd then close both - eprintln!(" [write] flush idempotency (dup fd)"); + // libc::dup is Unix-only; Windows has no equivalent FD duplication semantics. + #[cfg(unix)] { + eprintln!(" [write] flush idempotency (dup fd)"); use std::io::{Seek, SeekFrom, Write}; use std::os::unix::io::AsRawFd; let path = format!("{}/duptest.txt", mp); @@ -629,8 +631,10 @@ pub fn run_simple_write_tests(mp: &str, remote_file: &str) -> TestResult { } // 14. Flush idempotency: dup fd then close both (FUSE calls flush per fd) - eprintln!(" [simple-write] flush idempotency (dup fd)"); + // libc::dup is Unix-only; skip on Windows. + #[cfg(unix)] { + eprintln!(" [simple-write] flush idempotency (dup fd)"); use std::io::Write; use std::os::unix::io::AsRawFd; let path = format!("{}/duptest.txt", mp); diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 4a52d3a0..101bfab7 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -378,23 +378,70 @@ pub fn mount_repo(repo_id: &str, mount_point: &str, cache_dir: &str, extra_args: } /// Spawn hf-mount-nfs to mount a bucket via NFS. -pub fn mount_bucket_nfs(bucket_id: &str, mount_point: &str, cache_dir: &str, extra_args: &[&str]) -> Child { - let token = std::env::var("HF_TOKEN").unwrap(); +/// Returns a mount point string usable on the current platform. +/// Unix: `/tmp/hf-mount-nfs-{slug}-{pid}`. Windows: `Z:` (drive letters are +/// process-global so this requires --test-threads=1, which we already enforce). +pub fn nfs_mount_point(slug: &str) -> String { + #[cfg(unix)] + { + format!("/tmp/hf-mount-nfs-{}-{}", slug, std::process::id()) + } + #[cfg(windows)] + { + let _ = slug; + "Z:".to_string() + } +} - let binary = std::env::current_exe() +fn nfs_binary_path() -> std::path::PathBuf { + let dir = std::env::current_exe() .unwrap() .parent() .unwrap() .parent() .unwrap() - .join("hf-mount-nfs"); + .to_path_buf(); + if cfg!(windows) { + dir.join("hf-mount-nfs.exe") + } else { + dir.join("hf-mount-nfs") + } +} + +#[cfg(target_os = "linux")] +fn nfs_is_mounted(mount_point: &str) -> bool { + std::fs::read_to_string("/proc/mounts") + .map(|s| s.lines().any(|l| l.contains(mount_point))) + .unwrap_or(false) +} + +#[cfg(target_os = "macos")] +fn nfs_is_mounted(mount_point: &str) -> bool { + Command::new("mount") + .output() + .ok() + .map(|o| String::from_utf8_lossy(&o.stdout).contains(mount_point)) + .unwrap_or(false) +} +#[cfg(windows)] +fn nfs_is_mounted(mount_point: &str) -> bool { + // Best-effort: the drive letter / mount path is reachable while the mount is live. + std::fs::metadata(mount_point).is_ok() +} + +pub fn mount_bucket_nfs(bucket_id: &str, mount_point: &str, cache_dir: &str, extra_args: &[&str]) -> Child { + let token = std::env::var("HF_TOKEN").unwrap(); + + let binary = nfs_binary_path(); eprintln!("Mounting NFS with binary: {:?}", binary); if !binary.exists() { panic!("hf-mount-nfs binary not found, run cargo build --release first"); } + // Drive-letter targets on Windows aren't a directory and must not be created. + #[cfg(unix)] std::fs::create_dir_all(mount_point).ok(); std::fs::create_dir_all(cache_dir).ok(); @@ -421,9 +468,7 @@ pub fn mount_bucket_nfs(bucket_id: &str, mount_point: &str, cache_dir: &str, ext for i in 0..30 { std::thread::sleep(Duration::from_millis(500)); - if let Ok(mounts) = std::fs::read_to_string("/proc/mounts") - && mounts.lines().any(|line| line.contains(mount_point)) - { + if nfs_is_mounted(mount_point) { eprintln!("Mount ready after {}ms", (i + 1) * 500); return child; } @@ -441,7 +486,10 @@ pub fn unmount(mount_point: &str, child: Child, graceful_secs: u64) { /// Unmount NFS and wait for hf-mount to exit. pub fn unmount_nfs(mount_point: &str, child: Child, graceful_secs: u64) { + #[cfg(unix)] unmount_with(mount_point, child, graceful_secs, &["sudo", "umount"]); + #[cfg(windows)] + unmount_with(mount_point, child, graceful_secs, &["umount.exe", "-f"]); } fn unmount_with(mount_point: &str, mut child: Child, graceful_secs: u64, cmd: &[&str]) { diff --git a/tests/nfs_ops.rs b/tests/nfs_ops.rs index 8ffe1246..20820a0a 100644 --- a/tests/nfs_ops.rs +++ b/tests/nfs_ops.rs @@ -9,7 +9,7 @@ async fn test_nfs_read_only() { None => return, }; - let mount_point = format!("/tmp/hf-mount-nfs-{}", std::process::id()); + let mount_point = common::nfs_mount_point("ro"); let cache_dir = format!("/tmp/hf-mount-nfs-cache-{}", std::process::id()); let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { @@ -46,7 +46,7 @@ async fn test_nfs_point_lookup_in_large_dir() { }; let (target_rel, target_content) = common::seed_big_dir_with_target(&guard.hub, "nfs-pl").await; - let mount_point = format!("/tmp/hf-mount-nfs-pl-mnt-{}", std::process::id()); + let mount_point = common::nfs_mount_point("pl"); let cache_dir = format!("/tmp/hf-mount-nfs-pl-cache-{}", std::process::id()); let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { @@ -76,7 +76,7 @@ async fn test_nfs_deep_cold_read() { }; let (deep_rel, payload) = common::seed_deep_tree(&guard.hub, "nfs-dr").await; - let mount_point = format!("/tmp/hf-mount-nfs-dr-mnt-{}", std::process::id()); + let mount_point = common::nfs_mount_point("dr"); let cache_dir = format!("/tmp/hf-mount-nfs-dr-cache-{}", std::process::id()); let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { @@ -106,7 +106,7 @@ async fn test_nfs_writes() { None => return, }; - let mount_point = format!("/tmp/hf-mount-nfs-w-{}", std::process::id()); + let mount_point = common::nfs_mount_point("w"); let cache_dir = format!("/tmp/hf-mount-nfs-w-cache-{}", std::process::id()); let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {