diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c5c30f4..6a1188dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Changelog +- **Fixed** Automatic file-access tracking now backs its shared memory with a sparse temporary file on every platform, so tasks are still tracked inside coding-agent sandboxes that deny POSIX shared memory and Unix domain sockets ([#563](https://github.com/voidzero-dev/vite-task/issues/563), [#576](https://github.com/voidzero-dev/vite-task/pull/576)). - **Added** Tasks now run with `VP_RUN=1` set, so tools can tell they are running under `vp run` instead of being invoked directly ([#570](https://github.com/voidzero-dev/vite-task/pull/570)). - **Fixed** The task cache now supports much larger automatically tracked input sets without hitting wincode's default 4 MiB sequence preallocation limit ([#554](https://github.com/voidzero-dev/vite-task/pull/554)). - **Fixed** npm workspace patterns beginning with `./` now discover matching packages correctly ([vite-plus#2201](https://github.com/voidzero-dev/vite-plus/issues/2201), [#547](https://github.com/voidzero-dev/vite-task/pull/547)). diff --git a/Cargo.lock b/Cargo.lock index 351ca942..11097429 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1380,16 +1380,9 @@ dependencies = [ name = "fspy_shm" version = "0.0.0" dependencies = [ - "base64", "ctor", - "memfd", "memmap2", - "nix 0.31.2", - "passfd", "subprocess_test", - "tokio", - "tokio-util", - "tracing", "uuid", "windows-sys 0.61.2", ] @@ -1993,15 +1986,6 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" -[[package]] -name = "memfd" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227" -dependencies = [ - "rustix", -] - [[package]] name = "memmap2" version = "0.9.11" diff --git a/Cargo.toml b/Cargo.toml index 7e1382b6..1e12e8b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -89,7 +89,6 @@ jsonc-parser = { version = "0.32.0", features = ["serde"] } libc = "0.2.185" libtest-mimic = "0.8.2" memmap2 = "0.9.11" -memfd = "0.6.5" monostate = "1.0.2" napi = "3" napi-build = "2" diff --git a/crates/fspy_shared/src/ipc/channel/mod.rs b/crates/fspy_shared/src/ipc/channel/mod.rs index ae34c8ca..ba89e04e 100644 --- a/crates/fspy_shared/src/ipc/channel/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/mod.rs @@ -2,55 +2,22 @@ mod shm_io; -use std::{env::temp_dir, fs::File, io, mem::MaybeUninit, ops::Deref, path::PathBuf, sync::Arc}; +use std::{env::temp_dir, fs::File, io, ops::Deref, path::PathBuf}; -use fspy_shm::Shm; +use fspy_shm::{Mapping, ShmKeeper}; pub use shm_io::FrameMut; use shm_io::{ShmReader, ShmWriter}; use tracing::debug; use uuid::Uuid; -use wincode::{ - SchemaRead, SchemaWrite, - config::Config, - error::{ReadResult, WriteResult}, - io::{Reader, Writer}, -}; +use wincode::{SchemaRead, SchemaWrite}; use super::NativeStr; -/// wincode schema adapter for `Arc`, which is a foreign type with unsized inner. -pub(crate) struct ArcStrSchema; - -// SAFETY: Delegates to `str`'s SchemaWrite impl, preserving its size/write invariants. -unsafe impl SchemaWrite for ArcStrSchema { - type Src = Arc; - - fn size_of(src: &Self::Src) -> WriteResult { - >::size_of(src) - } - - fn write(writer: impl Writer, src: &Self::Src) -> WriteResult<()> { - >::write(writer, src) - } -} - -// SAFETY: Delegates to `&str`'s SchemaRead impl; dst is initialized on Ok. -unsafe impl<'de, C: Config> SchemaRead<'de, C> for ArcStrSchema { - type Dst = Arc; - - fn read(mut reader: impl Reader<'de>, dst: &mut MaybeUninit) -> ReadResult<()> { - let s: &str = <&str as SchemaRead<'de, C>>::get(&mut reader)?; - dst.write(Arc::from(s)); - Ok(()) - } -} - /// Serializable configuration to create channel senders. #[derive(SchemaWrite, SchemaRead, Clone, Debug)] pub struct ChannelConf { lock_file_path: Box, - #[wincode(with = "ArcStrSchema")] - shm_id: Arc, + shm_id: Box, } /// Creates a mpsc IPC channel with one receiver and a `ChannelConf` that can be passed around processes and used to create multiple senders @@ -62,12 +29,16 @@ pub fn channel(capacity: usize) -> io::Result<(ChannelConf, Receiver)> { // Initialize the lock file with a unique name. let lock_file_path = temp_dir().join(format!("fspy_ipc_{}.lock", Uuid::new_v4())); - let shm = fspy_shm::create(capacity)?; + let keeper = fspy_shm::create(capacity)?; + // The creating process has no privileged view; it opens one like any other. + let mapping = fspy_shm::open(keeper.id())?; - let conf = - ChannelConf { lock_file_path: lock_file_path.as_os_str().into(), shm_id: shm.id().into() }; + let conf = ChannelConf { + lock_file_path: lock_file_path.as_os_str().into(), + shm_id: keeper.id().into(), + }; - let receiver = Receiver::new(lock_file_path, shm)?; + let receiver = Receiver::new(lock_file_path, keeper, mapping)?; Ok((conf, receiver)) } @@ -83,7 +54,7 @@ impl ChannelConf { let lock_file = File::open(self.lock_file_path.to_cow_os_str())?; lock_file.try_lock_shared()?; - let shm = fspy_shm::open(&self.shm_id)?; + let shm = fspy_shm::open(&self.shm_id.to_cow_os_str())?; // SAFETY: `shm` is a freshly opened shared memory region with valid pointer and size. // Exclusive write access is ensured by the shared file lock held by this sender. let writer = unsafe { ShmWriter::new(shm) }; @@ -92,7 +63,7 @@ impl ChannelConf { } pub struct Sender { - writer: ShmWriter, + writer: ShmWriter, lock_file_path: Box, lock_file: File, } @@ -106,20 +77,13 @@ impl Drop for Sender { } impl Deref for Sender { - type Target = ShmWriter; + type Target = ShmWriter; fn deref(&self) -> &Self::Target { &self.writer } } -#[cfg_attr( - target_os = "windows", - expect( - clippy::non_send_fields_in_send_ty, - reason = "`Sender` holds a shared file lock that ensures there's no reader, so `shm` can be safely written to" - ) -)] /// SAFETY: `Sender` holds a shared file lock that ensures there's no reader, so `shm` can be safely written to. unsafe impl Send for Sender {} @@ -131,16 +95,11 @@ unsafe impl Sync for Sender {} pub struct Receiver { lock_file_path: PathBuf, lock_file: File, - shm: Shm, + /// Keeps the backing file's name alive for as long as senders may attach. + _keeper: ShmKeeper, + mapping: Mapping, } -#[cfg_attr( - target_os = "windows", - expect( - clippy::non_send_fields_in_send_ty, - reason = "Receiver doesn't read or write `shm`. It only passes it to `ReceiverLockGuard` under the lock" - ) -)] /// SAFETY: `Receiver` doesn't read or write `shm`. It only passes it to `ReceiverLockGuard` under the lock. unsafe impl Send for Receiver {} @@ -156,9 +115,9 @@ impl Drop for Receiver { } impl Receiver { - fn new(lock_file_path: PathBuf, shm: Shm) -> io::Result { + fn new(lock_file_path: PathBuf, keeper: ShmKeeper, mapping: Mapping) -> io::Result { let lock_file = File::create(&lock_file_path)?; - Ok(Self { lock_file_path, lock_file, shm }) + Ok(Self { lock_file_path, lock_file, _keeper: keeper, mapping }) } /// Lock the shared memory for unique read access. @@ -172,7 +131,7 @@ impl Receiver { self.lock_file.lock()?; // SAFETY: The exclusive file lock is held, so no writers can access the shared memory. // The lock ensures all prior writes are visible to this thread. - let reader = ShmReader::new(unsafe { self.shm.as_slice() }); + let reader = ShmReader::new(unsafe { self.mapping.as_slice() }); Ok(ReceiverLockGuard { reader, lock_file: &self.lock_file }) } } diff --git a/crates/fspy_shared/src/ipc/channel/shm_io.rs b/crates/fspy_shared/src/ipc/channel/shm_io.rs index 62d927cd..67123b3d 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io.rs @@ -9,7 +9,7 @@ use std::{ }; use bytemuck::must_cast; -use fspy_shm::Shm; +use fspy_shm::Mapping; use wincode::{SchemaWrite, Serialize as _, config::DefaultConfig}; // `ShmWriter` writes headers using atomic operations to prevent partial writes due to crashes, @@ -28,7 +28,7 @@ pub trait AsRawSlice { fn as_raw_slice(&self) -> *mut [u8]; } -impl AsRawSlice for Shm { +impl AsRawSlice for Mapping { fn as_raw_slice(&self) -> *mut [u8] { slice_from_raw_parts_mut(self.as_ptr(), self.len()) } @@ -672,27 +672,15 @@ mod tests { const SHM_SIZE: usize = 1024 * 1024; - // On Linux, `fspy_shm::create` spawns the mapping's broker onto the - // ambient tokio runtime, which serves the child processes' opens. - #[cfg(target_os = "linux")] - let runtime = tokio::runtime::Builder::new_multi_thread() - .worker_threads(1) - .enable_io() - .enable_time() - .build() - .unwrap(); - #[cfg(target_os = "linux")] - let _guard = runtime.enter(); - - let shm = fspy_shm::create(SHM_SIZE).unwrap(); - let shm_name = shm.id().to_owned(); + let keeper = fspy_shm::create(SHM_SIZE).unwrap(); + let shm_name = keeper.id().to_str().expect("test temp dir is UTF-8").to_owned(); let children: Vec = (0..CHILD_COUNT) .map(|child_index| { let cmd = command_for_fn!( (shm_name.clone(), child_index), |(shm_name, child_index): (String, usize)| { - let shm = fspy_shm::open(&shm_name).unwrap(); + let shm = fspy_shm::open(std::ffi::OsStr::new(&shm_name)).unwrap(); // SAFETY: `shm` is a freshly opened shared memory region with a valid // pointer and size. Concurrent write access is safe because `ShmWriter` // uses atomic operations. @@ -712,9 +700,10 @@ mod tests { assert!(status.success()); } + let mapping = fspy_shm::open(keeper.id()).unwrap(); // SAFETY: All child processes have exited (waited above), so no concurrent writers exist. // The shared memory is valid and fully written. - let shm = unsafe { shm.as_slice() }; + let shm = unsafe { mapping.as_slice() }; let reader = ShmReader::new(shm); let frames = reader.iter_frames().map(BStr::new).collect::>(); assert_eq!(frames.len(), CHILD_COUNT * FRAME_COUNT_EACH_CHILD); diff --git a/crates/fspy_shm/Cargo.toml b/crates/fspy_shm/Cargo.toml index 82f571b8..77fcee47 100644 --- a/crates/fspy_shm/Cargo.toml +++ b/crates/fspy_shm/Cargo.toml @@ -7,24 +7,13 @@ license.workspace = true publish = false rust-version.workspace = true -[target.'cfg(target_os = "linux")'.dependencies] -memmap2 = { workspace = true } -memfd = { workspace = true } -passfd = { workspace = true, features = ["async"] } -nix = { workspace = true, features = ["fs", "socket", "user"] } -tokio = { workspace = true, features = ["macros", "net", "rt", "time"] } -tokio-util = { workspace = true } -tracing = { workspace = true } +[dependencies] uuid = { workspace = true, features = ["v4"] } -[target.'cfg(target_os = "macos")'.dependencies] -base64 = { workspace = true } +[target.'cfg(unix)'.dependencies] memmap2 = { workspace = true } -nix = { workspace = true, features = ["fs", "mman"] } -uuid = { workspace = true, features = ["v4"] } [target.'cfg(target_os = "windows")'.dependencies] -uuid = { workspace = true, features = ["v4"] } windows-sys = { workspace = true, features = [ "Win32_Foundation", "Win32_Security", @@ -37,7 +26,6 @@ windows-sys = { workspace = true, features = [ [dev-dependencies] ctor = { workspace = true } subprocess_test = { workspace = true } -tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } [lints] workspace = true diff --git a/crates/fspy_shm/README.md b/crates/fspy_shm/README.md index 773be435..36df0b55 100644 --- a/crates/fspy_shm/README.md +++ b/crates/fspy_shm/README.md @@ -2,41 +2,71 @@ `fspy_shm` is the private shared-memory layer used by fspy IPC channels. It gives the channel one API for creating a mapping, passing its identifier to another process, and opening additional views of the same bytes. -`fspy_shm` exposes only the operations used by fspy. Callers must treat an identifier as a string and must not depend on a platform's naming scheme. +`fspy_shm` exposes only the operations used by fspy. Treat an identifier as an opaque `OsStr`; do not depend on how it is built. ## API The public API is defined in [`src/lib.rs`](src/lib.rs). -| API | Contract | -| ----------------- | ---------------------------------------------------------------------------------- | -| `create(size)` | Creates a non-empty, zero-initialized mapping and returns its unique owner. | -| `open(id)` | Opens another view of the mapping identified by `id`. | -| `Shm::id()` | Returns the identifier to send to another process. | -| `Shm::len()` | Returns the mapped size. | -| `Shm::as_ptr()` | Returns a mutable raw pointer to the first byte. | -| `Shm::as_slice()` | Returns a shared slice. The caller must prevent mutation for the slice's lifetime. | +| API | Contract | +| --------------------- | --------------------------------------------------------------------------------------- | +| `create(size)` | Creates a zero-initialized backing file of `size` bytes and returns its `ShmKeeper`. | +| `open(id)` | Opens a `Mapping` of the shared memory identified by `id`. | +| `ShmKeeper::id()` | Returns the identifier any process, including the creator, passes to `open`. | +| `Mapping::len()` | Returns the mapped size. | +| `Mapping::as_ptr()` | Returns a mutable raw pointer to the first byte. | +| `Mapping::as_slice()` | Returns the bytes as a shared slice. The caller must prevent mutation for its lifetime. | -`Shm` does not synchronize memory access. The fspy channel combines it with atomic frame headers and a lock file. Senders hold a shared file lock while writing. The receiver takes the exclusive lock before reading, which waits for existing senders and rejects new ones. +`ShmKeeper` is the name: while it lives, `open` succeeds, and dropping it removes the backing file. `Mapping` is the bytes: it keeps them alive until dropped and can do nothing else. The creating process gets its own view through `open`, like every other process. Neither type synchronizes memory access. The fspy channel adds that on top with atomic frame headers and a lock file: senders hold a shared file lock while writing, and the receiver takes the exclusive lock before reading, which waits for existing senders and rejects new ones. Every byte in a mapping returned by `create` is initially zero. `open` exposes the mapping's current contents and does not reinitialize them. -## Ownership semantics +## Implementation -`create` returns the only owner. `open` returns non-owning views. +One implementation serves every platform: a sparse file named `vite-task-fspy-.shm` directly in the system temporary directory. The identifier is the file's absolute path, so another process opens the mapping by opening that path. There is no broker, no global object name, and no asynchronous runtime. The files sit in the temporary directory itself rather than a shared subdirectory: a subdirectory would belong to whichever user created it first and block everyone else, while uniquely named `0o600` files in a sticky-bit directory work for all users. -- While the owner is alive, a process that knows the identifier can open the mapping. -- An opened view remains usable after the owner is dropped. Its operating system mapping keeps the underlying bytes alive. -- After the owner is dropped, new opens behave differently by platform. POSIX removes the name. Windows can continue accepting opens by section name until the final handle or view is closed. +Only written pages ever occupy memory or disk. The multi-gigabyte capacity fspy asks for therefore costs about as much as the data a run actually records. -The channel hides that difference with its lock file. [`ChannelConf::sender`](../fspy_shared/src/ipc/channel/mod.rs) opens and locks the receiver's exact lock-file path before it calls `fspy_shm::open`. The receiver removes that path before dropping the owner, so a sender that starts later fails before opening shared memory. +The platform-specific parts are five short passages: -## Platform designs +| Concern | Unix | Windows | +| ----------------- | ---------------------------------------- | ------------------------------------------------------------------------------------ | +| Same-user access | `mode(0o600)` on the backing file | the per-user `%TEMP%` ACL | +| Mapping | `memmap2` | an unnamed section, mapped directly (see below) | +| Sparseness | file holes, produced by setting a length | `FSCTL_SET_SPARSE` before setting a length, or NTFS allocates every cluster | +| Owner cleanup | unlink the path | close the `FILE_FLAG_DELETE_ON_CLOSE` handle, since a mapped file cannot be unlinked | +| Descriptor safety | `O_CLOEXEC`, the Rust standard default | non-inheritable handles, the Rust standard default | -Each platform keeps its implementation rationale beside its source: +`FILE_ATTRIBUTE_TEMPORARY` asks Windows to keep the data in memory when it can. Creation fails on a volume without sparse-file support. -- [Linux: `memfd` with a descriptor broker](src/linux/README.md) -- [macOS: named POSIX shared memory](src/macos/README.md) -- [Windows: sparse file-backed named mapping](src/windows/README.md) +Windows maps the file itself instead of going through `memmap2`. `memmap2` duplicates the file handle and keeps it for the mapping's lifetime, and `FILE_FLAG_DELETE_ON_CLOSE` fires only once every handle to the file is closed, so a `memmap2` view would defer the keeper's cleanup until the last mapping in any process went away. A direct view holds a section reference and no handle, which keeps the lifetime semantics below the same on every platform. Nothing in the channel depends on when the name disappears, so the deferral would not break correctness; the direct mapping stays because one lifetime story and one test suite for all platforms are worth eighty lines of exercised unsafe code. Revisit if `memmap2` can map without retaining the handle. -All implementations provide the API above. Their identifiers and operating system objects differ. Each platform README explains the chosen API and why the previous `shared_memory` backend did not meet its requirements. +## Options considered + +| Option | Decision | +| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| POSIX shared memory | Rejected. Coding-agent sandboxes deny it on macOS (`ipc-posix-shm-write-create`), and Linux stores the objects in `/dev/shm`, so they share that mount's size limit. | +| `memfd` with a descriptor broker | Rejected. Handing the descriptor to another process needs a Unix-domain socket, which the same sandboxes block, and the broker needed an ambient asynchronous runtime just to create a mapping. | +| System V shared memory | Rejected. IPC namespace limits affect availability and the owner must explicitly remove the segment. | +| Paging-file-backed section | Rejected. The section's full size is charged against the system commit limit. | +| Reserved section with incremental commits | Rejected. Writers would have to coordinate when committing more pages. | +| Mach memory entry with port transfer | Rejected. Another process can receive the memory entry only through a Mach port, which needs a separate service. | +| Sparse temporary file, opened by path | Selected. Every sandbox fspy runs under permits it, no descriptor has to be passed, and all platforms end up with the same lifetime semantics. | + +Earlier revisions rejected temporary files because dirty pages can reach disk. Only pages fspy writes are dirty, about a hundred kilobytes per run, so writeback costs little. Unlike POSIX shared memory on Linux, a temporary file does not compete for the `/dev/shm` mount's size limit. + +## Why not `shared_memory` + +`shared_memory` uses POSIX `shm_open` on Unix, which keeps the sandbox and `/dev/shm` problems above. On Windows it creates and extends a regular file before fspy could mark it sparse, so untouched ranges would still consume disk blocks. + +## Lifetime semantics + +`create` returns the only keeper. `open` returns plain `Mapping`s. + +- While the keeper is alive, a process that knows the identifier can open the mapping. +- Dropping the keeper removes the backing file's name, so later opens fail. This is cleanup; a protocol that must invalidate the shared memory's contents writes that into the bytes. +- A `Mapping` stays usable after the keeper is gone. It holds no handle and knows no name, so it can neither delay the keeper's cleanup nor extend the identifier's validity. + +The channel guards the same window from its own side: [`ChannelConf::sender`](../fspy_shared/src/ipc/channel/mod.rs) opens and locks the receiver's exact lock-file path before it calls `fspy_shm::open`, and the receiver removes that path before dropping the keeper, so a sender that starts later fails before opening shared memory. + +If the keeper's process is killed, Windows deletes the backing file as the kernel closes the handle. Unix leaves a sparse file behind for the system's temporary-file reaper. diff --git a/crates/fspy_shm/src/file_backed.rs b/crates/fspy_shm/src/file_backed.rs new file mode 100644 index 00000000..70fd8d73 --- /dev/null +++ b/crates/fspy_shm/src/file_backed.rs @@ -0,0 +1,458 @@ +//! Shared memory backed by a sparse temporary file and identified by its path. +//! +//! Creating and opening are the same code on every platform. What differs is +//! contained: the creation flags that make the file same-user and +//! self-deleting, marking the file sparse on NTFS, how the owner's cleanup +//! removes the name, and which primitive maps the bytes. + +#[cfg(unix)] +use std::fs; +#[cfg(unix)] +use std::path::PathBuf; +use std::{ + env::temp_dir, + ffi::{OsStr, OsString}, + fs::{File, OpenOptions}, + io, + path::Path, +}; + +#[cfg(unix)] +use memmap2::{MmapOptions, MmapRaw}; +use uuid::Uuid; + +#[cfg(windows)] +mod sys; + +/// The platform's raw mapped view of a backing file. +/// +/// Unix uses `memmap2`. Windows cannot: `memmap2` duplicates the file handle +/// and keeps it for the mapping's lifetime, which would hold the owner's +/// delete-on-close hostage until every view unmapped, so Windows maps the file +/// itself and keeps only the view. +#[cfg(unix)] +type RawMapping = MmapRaw; +#[cfg(windows)] +type RawMapping = sys::MappedView; + +/// Prefix of backing file names inside the system temporary directory. +/// +/// The files sit directly in the temporary directory. A shared subdirectory +/// would belong to whichever user created it first and block everyone else; +/// uniquely named `0o600` files in the sticky-bit temp directory avoid that. +const BACKING_PREFIX: &str = "vite-task-fspy-"; + +/// The mapped bytes of a shared-memory backing file. +/// +/// A `Mapping` carries no identifier, no handle, and no cleanup duty. It keeps +/// the bytes alive until it is dropped and cannot affect the backing file's +/// name. +pub struct Mapping { + raw: RawMapping, +} + +/// Keeps the shared memory's name alive and removes it on drop. +/// +/// A keeper holds no view of the bytes. The creating process calls [`open`] +/// with the keeper's identifier, like every other process. +/// +/// Removal on drop is cleanup: later opens fail, existing [`Mapping`]s keep +/// working. A protocol that needs to invalidate the shared memory's contents +/// must write that into the bytes. +pub struct ShmKeeper { + id: OsString, + _cleanup: OwnerCleanup, +} + +/// Creates a zero-initialized, sparse, temporary backing file of `size` bytes +/// and returns its [`ShmKeeper`]. +/// +/// Only pages that are actually written occupy memory or disk, so a large +/// capacity is cheap. +/// +/// # Errors +/// +/// Returns an error if the backing file cannot be created or sized. Creation +/// also fails when the volume holding the temporary directory does not support +/// sparse files. +pub fn create(size: usize) -> io::Result { + if size == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "shared-memory size must be nonzero", + )); + } + let size_u64 = u64::try_from(size).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidInput, "shared-memory size exceeds u64") + })?; + + // `temp_dir` reflects `TMPDIR` verbatim, which may be relative. The + // identifier travels to processes with other working directories, so + // resolve it against the creator's current directory first. + let path = std::path::absolute(temp_dir())? + .join(format!("{BACKING_PREFIX}{}.shm", Uuid::new_v4().simple())); + let id = path.as_os_str().to_owned(); + + let file = create_options().open(&path)?; + match size_backing_file(&file, size_u64) { + Ok(()) => Ok(ShmKeeper { + id, + _cleanup: OwnerCleanup { + #[cfg(unix)] + path, + #[cfg(windows)] + _file: file, + }, + }), + Err(error) => { + discard_backing_file(&path); + Err(error) + } + } +} + +/// Opens a [`Mapping`] of the shared memory identified by `id`. +/// +/// `id` is the backing file's absolute path, so the opener's temporary +/// directory and working directory do not participate. +/// +/// # Errors +/// +/// Returns an error if the shared memory is unavailable, which is the common +/// case once its keeper has been dropped. +pub fn open(id: &OsStr) -> io::Result { + // Rust's default share mode on Windows permits the owner's concurrent + // read/write/delete access, and Rust opens are `O_CLOEXEC` / non-inheritable + // on every platform, so a traced process never leaks this descriptor. + let file = OpenOptions::new().read(true).write(true).open(id)?; + // If another process shrinks the file before `map_raw`, mapping fails. If it + // resizes afterwards, `open` does not touch the mapped pages. A concurrent + // resize cannot make `open` access invalid memory. + let size = usize::try_from(file.metadata()?.len()) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid shared-memory size"))?; + if size == 0 { + return Err(io::Error::new(io::ErrorKind::InvalidData, "shared-memory size is zero")); + } + // The mapping keeps the data alive; the handle is not needed any more. + map(&file, size) +} + +/// Open options for the creating process: exclusive, same-user, and on Windows +/// self-deleting once the owner's handle closes. +fn create_options() -> OpenOptions { + let mut options = OpenOptions::new(); + options.read(true).write(true).create_new(true); + + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + + // Only the creating user may open the mapping. + options.mode(0o600); + } + + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt as _; + + // The per-user `%TEMP%` ACL provides the same-user gating that `0o600` + // provides on Unix. + options + .share_mode(sys::SHARE_ALL) + .attributes(sys::TEMPORARY) + .custom_flags(sys::DELETE_ON_CLOSE); + } + + options +} + +/// Gives the freshly created backing file its logical size. Every byte reads +/// as zero because the file is all holes. +fn size_backing_file(file: &File, size_u64: u64) -> io::Result<()> { + // NTFS allocates clusters for the whole logical size unless the file is + // marked sparse first, which would turn the capacity into real disk usage. + // Volumes without sparse-file support fail here. + #[cfg(windows)] + sys::set_sparse(file)?; + + file.set_len(size_u64) +} + +/// Maps the first `size` bytes of an already sized backing file. +fn map(file: &File, size: usize) -> io::Result { + #[cfg(unix)] + let raw = MmapOptions::new().len(size).map_raw(file)?; + #[cfg(windows)] + let raw = sys::MappedView::new(file, size)?; + + Ok(Mapping { raw }) +} + +/// Removes a backing file whose owner never came into existence. +#[cfg(unix)] +fn discard_backing_file(path: &Path) { + let _ = fs::remove_file(path); +} + +/// Windows deletes the file as soon as the creating handle closes +/// (`FILE_FLAG_DELETE_ON_CLOSE`), so there is nothing to do here. +#[cfg(windows)] +const fn discard_backing_file(_path: &Path) {} + +/// Keeper-side cleanup. Dropping it makes the backing file's name disappear +/// while existing mappings keep the bytes alive. +struct OwnerCleanup { + /// Unlinked on drop. + #[cfg(unix)] + path: PathBuf, + /// The creating handle, opened with `FILE_FLAG_DELETE_ON_CLOSE`. Closing it + /// applies the delete disposition even while other processes hold views, + /// because those are section references rather than handles. A mapped file + /// cannot be unlinked by path on Windows, so this is how the name goes away. + #[cfg(windows)] + _file: File, +} + +#[cfg(unix)] +impl Drop for OwnerCleanup { + fn drop(&mut self) { + let _ = fs::remove_file(&self.path); + } +} + +#[expect(clippy::len_without_is_empty, reason = "shared-memory mappings are always non-empty")] +impl Mapping { + /// Returns the mapped length in bytes. + #[must_use] + #[cfg_attr( + windows, + expect( + clippy::missing_const_for_fn, + reason = "the Unix mapping's accessors are not const, so this cannot be either" + ) + )] + pub fn len(&self) -> usize { + self.raw.len() + } + + /// Returns a raw pointer to the first mapped byte. + #[must_use] + #[cfg_attr( + windows, + expect( + clippy::missing_const_for_fn, + reason = "the Unix mapping's accessors are not const, so this cannot be either" + ) + )] + pub fn as_ptr(&self) -> *mut u8 { + #[cfg(unix)] + { + self.raw.as_mut_ptr() + } + #[cfg(windows)] + { + self.raw.as_ptr() + } + } + + /// Returns the mapped bytes as a shared slice. + /// + /// # Safety + /// + /// The caller must ensure that no process or thread mutates the mapping for + /// the lifetime of the returned slice. + #[must_use] + pub unsafe fn as_slice(&self) -> &[u8] { + // SAFETY: The mapping is valid for its full length, and the caller + // guarantees that it is not mutated while the slice is borrowed. + unsafe { std::slice::from_raw_parts(self.as_ptr().cast_const(), self.len()) } + } +} + +impl ShmKeeper { + /// Returns the shared memory's opaque identifier, which any process — + /// including the creating one — passes to [`open`]. + #[must_use] + pub fn id(&self) -> &OsStr { + &self.id + } +} + +#[cfg(test)] +mod tests { + use std::{ffi::OsString, fs, process::Command}; + + use subprocess_test::command_for_fn; + + use super::*; + + const SIZE: usize = 64 * 1024; + + /// Throwaway probe, never to merge: can Windows remove a mapped file's + /// name manually when no handle is open and only a written view remains? + #[cfg(windows)] + #[test] + fn probe_manual_delete_of_mapped_file() { + let path = std::path::absolute(temp_dir()) + .unwrap() + .join(format!("{BACKING_PREFIX}probe-{}.shm", Uuid::new_v4().simple())); + let mut options = OpenOptions::new(); + options.read(true).write(true).create_new(true); + { + use std::os::windows::fs::OpenOptionsExt as _; + options.share_mode(sys::SHARE_ALL).attributes(sys::TEMPORARY); + } + let file = options.open(&path).unwrap(); + sys::set_sparse(&file).unwrap(); + file.set_len(65536).unwrap(); + let mapping = map(&file, 65536).unwrap(); + drop(file); + // SAFETY: in bounds, no concurrent access. + unsafe { mapping.as_ptr().write(17) }; + + let removed = fs::remove_file(&path); + let exists_after = path.exists(); + let reopen_fails = OpenOptions::new().read(true).open(&path).is_err(); + // SAFETY: in bounds, no concurrent access. + let view_alive = unsafe { mapping.as_ptr().read() } == 17; + let rename_target = path.with_extension("renamed"); + let renamed = fs::rename(&path, &rename_target); + panic!( + "PROBE remove_file={removed:?} exists_after={exists_after} \ + reopen_fails={reopen_fails} view_alive={view_alive} rename_after={renamed:?}" + ); + } + + /// Throwaway probe two: does manual removal also work while another HANDLE + /// (share-delete) is still open, as a retained memmap2 handle would be? + #[cfg(windows)] + #[test] + fn probe_manual_delete_with_open_handle() { + let path = std::path::absolute(temp_dir()) + .unwrap() + .join(format!("{BACKING_PREFIX}probe2-{}.shm", Uuid::new_v4().simple())); + let mut options = OpenOptions::new(); + options.read(true).write(true).create_new(true); + { + use std::os::windows::fs::OpenOptionsExt as _; + options.share_mode(sys::SHARE_ALL).attributes(sys::TEMPORARY); + } + let file = options.open(&path).unwrap(); + sys::set_sparse(&file).unwrap(); + file.set_len(65536).unwrap(); + let mapping = map(&file, 65536).unwrap(); + // Keep `file` open: it stands in for the handle memmap2 would retain. + // SAFETY: in bounds, no concurrent access. + unsafe { mapping.as_ptr().write(17) }; + + let removed = fs::remove_file(&path); + let exists_after = path.exists(); + let reopen_fails = OpenOptions::new().read(true).open(&path).is_err(); + // SAFETY: in bounds, no concurrent access. + let view_alive = unsafe { mapping.as_ptr().read() } == 17; + let handle_still_writes = { + use std::io::Write as _; + (&file).write_all(b"x").is_ok() + }; + panic!( + "PROBE2 remove_file={removed:?} exists_after={exists_after} \ + reopen_fails={reopen_fails} view_alive={view_alive} \ + handle_still_writes={handle_still_writes}" + ); + } + + #[test] + fn subprocess_open_ignores_changed_temp_and_working_directory() { + let keeper = create(SIZE).unwrap(); + let mapping = open(keeper.id()).unwrap(); + let changed_cwd = + temp_dir().join(format!("{BACKING_PREFIX}changed-cwd-{}", Uuid::new_v4())); + fs::create_dir(&changed_cwd).unwrap(); + // SAFETY: The child does not access the mapping until this write completes. + unsafe { mapping.as_ptr().write(17) }; + + let id = keeper.id().to_str().expect("test temp dir is UTF-8").to_owned(); + let mut command = command_for_fn!(id, |id: String| { + let opened = open(OsStr::new(&id)).unwrap(); + // SAFETY: The parent waits for this child and does not access the + // mapping concurrently. + unsafe { + assert_eq!(opened.as_ptr().read(), 17); + opened.as_ptr().add(SIZE - 1).write(29); + } + }); + command.cwd = changed_cwd.clone(); + // The identifier is an absolute path, so a relative temporary directory + // in the child must make no difference on any platform. + for name in ["TMPDIR", "TMP", "TEMP"] { + command.envs.insert(OsString::from(name), OsString::from("changed-relative-tmp")); + } + let succeeded = Command::from(command).status().unwrap().success(); + fs::remove_dir(changed_cwd).unwrap(); + + assert!(succeeded); + // SAFETY: The child exited before this read. + assert_eq!(unsafe { mapping.as_ptr().add(SIZE - 1).read() }, 29); + } + + #[test] + fn keeper_drop_removes_backing_file_and_preserves_existing_mappings() { + let keeper = create(SIZE).unwrap(); + let id = keeper.id().to_owned(); + let path = Path::new(&id).to_owned(); + let opened = open(&id).unwrap(); + assert!(path.exists()); + + drop(keeper); + + assert!(!path.exists()); + assert!(open(&id).is_err()); + // SAFETY: The mapping remains live and no other access is concurrent. + unsafe { opened.as_ptr().write(17) }; + // SAFETY: The preceding write is complete and the mapping remains live. + assert_eq!(unsafe { opened.as_ptr().read() }, 17); + } + + #[cfg(target_pointer_width = "64")] + #[test] + fn four_gib_mapping_is_sparse_and_supports_endpoint_access() { + const PRODUCTION_SIZE: usize = 4 * 1024 * 1024 * 1024; + #[cfg(windows)] + const MAX_ENDPOINT_ALLOCATION: u64 = 16 * 1024 * 1024; + + let keeper = create(PRODUCTION_SIZE).unwrap(); + #[cfg(windows)] + { + let (logical_size, initial_allocation) = backing_file_sizes(keeper.id()); + assert_eq!(logical_size, PRODUCTION_SIZE as u64); + assert!(initial_allocation < MAX_ENDPOINT_ALLOCATION); + } + + let first = open(keeper.id()).unwrap(); + let opened = open(keeper.id()).unwrap(); + // SAFETY: Both endpoint indexes are within the exact mapped length and + // accesses are synchronized within this test. + unsafe { + first.as_ptr().write(17); + first.as_ptr().add(PRODUCTION_SIZE - 1).write(29); + assert_eq!(opened.as_ptr().read(), 17); + assert_eq!(opened.as_ptr().add(PRODUCTION_SIZE - 1).read(), 29); + } + + // Touching both endpoints must not have allocated the range between them. + #[cfg(windows)] + { + let (logical_size, endpoint_allocation) = backing_file_sizes(keeper.id()); + assert_eq!(logical_size, PRODUCTION_SIZE as u64); + assert!(endpoint_allocation < MAX_ENDPOINT_ALLOCATION); + } + } + + /// The backing file's logical size and the bytes NTFS actually allocated + /// for it, read through a separate handle on the keeper's path. + #[cfg(windows)] + fn backing_file_sizes(id: &OsStr) -> (u64, u64) { + let file = File::open(id).unwrap(); + sys::file_sizes(&file).unwrap() + } +} diff --git a/crates/fspy_shm/src/file_backed/sys.rs b/crates/fspy_shm/src/file_backed/sys.rs new file mode 100644 index 00000000..80b1e6f4 --- /dev/null +++ b/crates/fspy_shm/src/file_backed/sys.rs @@ -0,0 +1,159 @@ +//! The Win32 calls the shared implementation needs on Windows: marking the +//! backing file sparse, mapping it without retaining a file handle, and (in +//! tests) reading back how much of it the filesystem actually allocated. + +use std::{ + fs::File, + io, + os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}, + ptr::NonNull, +}; + +#[cfg(test)] +use windows_sys::Win32::Storage::FileSystem::{ + FILE_STANDARD_INFO, FileStandardInfo, GetFileInformationByHandleEx, +}; +use windows_sys::Win32::{ + Foundation::GetLastError, + Storage::FileSystem::{ + FILE_ATTRIBUTE_TEMPORARY, FILE_FLAG_DELETE_ON_CLOSE, FILE_SHARE_DELETE, FILE_SHARE_READ, + FILE_SHARE_WRITE, + }, + System::{ + IO::DeviceIoControl, + Ioctl::FSCTL_SET_SPARSE, + Memory::{ + CreateFileMappingW, FILE_MAP_WRITE, MEMORY_MAPPED_VIEW_ADDRESS, MapViewOfFile, + PAGE_READWRITE, UnmapViewOfFile, + }, + }, +}; + +pub(super) const SHARE_ALL: u32 = FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE; +pub(super) const TEMPORARY: u32 = FILE_ATTRIBUTE_TEMPORARY; +pub(super) const DELETE_ON_CLOSE: u32 = FILE_FLAG_DELETE_ON_CLOSE; + +/// Marks `file` sparse so that setting its length reserves no clusters. +pub(super) fn set_sparse(file: &File) -> io::Result<()> { + let mut bytes_returned = 0; + // SAFETY: `file` supplies a valid synchronous file handle. FSCTL_SET_SPARSE + // requires no input or output buffers, and `bytes_returned` is writable for + // the duration of the call. + let result = unsafe { + DeviceIoControl( + file.as_raw_handle().cast(), + FSCTL_SET_SPARSE, + std::ptr::null(), + 0, + std::ptr::null_mut(), + 0, + &raw mut bytes_returned, + std::ptr::null_mut(), + ) + }; + if result == 0 { Err(last_error()) } else { Ok(()) } +} + +/// Returns `file`'s logical size and the number of bytes the filesystem +/// allocated for it. +#[cfg(test)] +pub(super) fn file_sizes(file: &File) -> io::Result<(u64, u64)> { + let mut info = FILE_STANDARD_INFO::default(); + let info_size = u32::try_from(std::mem::size_of::()) + .map_err(|_| io::Error::other("file size information is too large"))?; + // SAFETY: `file` supplies a valid handle and `info` is a writable + // FILE_STANDARD_INFO buffer of exactly `info_size` bytes. + let result = unsafe { + GetFileInformationByHandleEx( + file.as_raw_handle().cast(), + FileStandardInfo, + (&raw mut info).cast(), + info_size, + ) + }; + if result == 0 { + return Err(last_error()); + } + + let logical_size = u64::try_from(info.EndOfFile) + .map_err(|_| io::Error::other("file has a negative logical size"))?; + let allocated_size = u64::try_from(info.AllocationSize) + .map_err(|_| io::Error::other("file has a negative allocated size"))?; + Ok((logical_size, allocated_size)) +} + +fn last_error() -> io::Error { + // SAFETY: GetLastError has no preconditions and is called immediately after + // the failing Win32 operation on the same thread. + io::Error::from_raw_os_error(unsafe { GetLastError() }.cast_signed()) +} + +/// A mapped view of a backing file that holds no file handle of its own. +/// +/// This is why Windows does not use `memmap2` here: `memmap2` duplicates the +/// file handle and keeps it for the mapping's lifetime, and `FILE_FLAG_ +/// DELETE_ON_CLOSE` only fires once *every* handle to the file is closed. A +/// view holds a section reference rather than a handle, so an opener cannot +/// hold the owner's cleanup hostage. +pub(super) struct MappedView { + pointer: NonNull, + len: usize, +} + +// SAFETY: a view is a plain memory region; there is no thread affinity in +// `MapViewOfFile`. Callers govern access to the bytes, exactly as they do for +// the `memmap2` mapping used on Unix. +unsafe impl Send for MappedView {} +// SAFETY: as above. +unsafe impl Sync for MappedView {} + +impl MappedView { + /// Maps the first `len` bytes of `file` for reading and writing. + pub(super) fn new(file: &File, len: usize) -> io::Result { + // SAFETY: `file` supplies a valid handle, the security pointer is null, + // and a null name creates an unnamed section covering the whole file. + let mapping = unsafe { + CreateFileMappingW( + file.as_raw_handle().cast(), + std::ptr::null(), + PAGE_READWRITE, + 0, + 0, + std::ptr::null(), + ) + }; + if mapping.is_null() { + return Err(last_error()); + } + // SAFETY: a non-null CreateFileMappingW result is an owned mapping handle. + let mapping = unsafe { OwnedHandle::from_raw_handle(mapping.cast()) }; + + // SAFETY: `mapping` is a valid section handle, and offset zero with + // `len` maps exactly the range the caller asked for. + let view = + unsafe { MapViewOfFile(mapping.as_raw_handle().cast(), FILE_MAP_WRITE, 0, 0, len) }; + let pointer = NonNull::new(view.Value.cast::()).ok_or_else(last_error)?; + + // The view keeps an internal reference to the section, so dropping the + // section handle here — and the caller's file handle later — is safe. + Ok(Self { pointer, len }) + } + + pub(super) const fn as_ptr(&self) -> *mut u8 { + self.pointer.as_ptr() + } + + pub(super) const fn len(&self) -> usize { + self.len + } +} + +impl Drop for MappedView { + fn drop(&mut self) { + // SAFETY: `pointer` is the base address returned by MapViewOfFile and + // this value owns that view until this single unmap operation. + let _ = unsafe { + UnmapViewOfFile(MEMORY_MAPPED_VIEW_ADDRESS { Value: self.pointer.as_ptr().cast() }) + }; + } +} diff --git a/crates/fspy_shm/src/lib.rs b/crates/fspy_shm/src/lib.rs index 4798b8b8..47082fb0 100644 --- a/crates/fspy_shm/src/lib.rs +++ b/crates/fspy_shm/src/lib.rs @@ -1,113 +1,101 @@ #![doc = include_str!("../README.md")] -#[cfg(target_os = "linux")] -#[path = "linux/mod.rs"] -mod os_impl; -#[cfg(target_os = "macos")] -#[path = "macos/mod.rs"] -mod os_impl; -#[cfg(target_os = "windows")] -#[path = "windows/mod.rs"] -mod os_impl; - -pub use os_impl::{Shm, create, open}; +mod file_backed; + +pub use file_backed::{Mapping, ShmKeeper, create, open}; #[cfg(test)] mod tests { - use std::{mem::align_of, process::Command}; + use std::{ffi::OsStr, mem::align_of, process::Command}; use subprocess_test::command_for_fn; - use super::{Shm, create, open}; + use super::{Mapping, create, open}; // Page-aligned on all supported targets. const SIZE: usize = 64 * 1024; // Use one byte more than 64 KiB to test multiple pages and a partial last page. const ZERO_INITIALIZED_SIZE: usize = SIZE + 1; - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn new_mapping_is_zero_initialized_in_all_views() { - let owner = create(ZERO_INITIALIZED_SIZE).unwrap(); - let opened = open(owner.id()).unwrap(); + #[test] + fn new_mapping_is_zero_initialized_in_all_views() { + let keeper = create(ZERO_INITIALIZED_SIZE).unwrap(); + let first = open(keeper.id()).unwrap(); + let second = open(keeper.id()).unwrap(); - assert_zero_initialized(&owner); - assert_zero_initialized(&opened); + assert_zero_initialized(&first); + assert_zero_initialized(&second); } - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn create_and_open_are_shared() { - let owner = create(SIZE).unwrap(); - assert_eq!(owner.len(), SIZE); - assert_eq!(owner.as_ptr() as usize % align_of::(), 0); + #[test] + fn mappings_of_one_keeper_are_shared() { + let keeper = create(SIZE).unwrap(); + let first = open(keeper.id()).unwrap(); + assert_eq!(first.len(), SIZE); + assert_eq!(first.as_ptr() as usize % align_of::(), 0); - let opened = open(owner.id()).unwrap(); - assert_eq!(opened.id(), owner.id()); - assert_eq!(opened.len(), SIZE); + let second = open(keeper.id()).unwrap(); + assert_eq!(second.len(), SIZE); - write_byte(&owner, 0, 17); - assert_eq!(read_byte(&opened, 0), 17); - write_byte(&opened, SIZE - 1, 29); - assert_eq!(read_byte(&owner, SIZE - 1), 29); + write_byte(&first, 0, 17); + assert_eq!(read_byte(&second, 0), 17); + write_byte(&second, SIZE - 1, 29); + assert_eq!(read_byte(&first, SIZE - 1), 29); } - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn mapping_is_visible_across_processes() { - let owner = create(SIZE).unwrap(); - write_byte(&owner, 0, 17); + #[test] + fn mapping_is_visible_across_processes() { + let keeper = create(SIZE).unwrap(); + let mapping = open(keeper.id()).unwrap(); + write_byte(&mapping, 0, 17); - let command = command_for_fn!(owner.id().to_owned(), |id: String| { - let opened = open(&id).unwrap(); + let id = keeper.id().to_str().expect("test temp dir is UTF-8").to_owned(); + let command = command_for_fn!(id, |id: String| { + let opened = open(OsStr::new(&id)).unwrap(); assert_eq!(read_byte(&opened, 0), 17); write_byte(&opened, SIZE - 1, 29); }); - let success = - tokio::task::spawn_blocking(move || Command::from(command).status().unwrap().success()) - .await - .unwrap(); - assert!(success); - assert_eq!(read_byte(&owner, SIZE - 1), 29); + assert!(Command::from(command).status().unwrap().success()); + assert_eq!(read_byte(&mapping, SIZE - 1), 29); } - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn owner_drop_prevents_new_opens() { - let owner = create(SIZE).unwrap(); - let id = owner.id().to_owned(); - drop(owner); + #[test] + fn keeper_drop_prevents_new_opens() { + let keeper = create(SIZE).unwrap(); + let id = keeper.id().to_owned(); + drop(keeper); assert!(open(&id).is_err()); } - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn opened_mapping_survives_owner_drop() { - let owner = create(SIZE).unwrap(); - let id = owner.id().to_owned(); + #[test] + fn opened_mapping_survives_keeper_drop() { + let keeper = create(SIZE).unwrap(); + let id = keeper.id().to_owned(); let opened = open(&id).unwrap(); - write_byte(&owner, 0, 17); - drop(owner); + write_byte(&opened, 0, 17); + drop(keeper); - // Windows keeps the named object alive while an opened view exists. - #[cfg(not(target_os = "windows"))] assert!(open(&id).is_err()); assert_eq!(read_byte(&opened, 0), 17); write_byte(&opened, SIZE - 1, 29); assert_eq!(read_byte(&opened, SIZE - 1), 29); } - fn read_byte(shm: &Shm, index: usize) -> u8 { - assert!(index < shm.len()); + fn read_byte(mapping: &Mapping, index: usize) -> u8 { + assert!(index < mapping.len()); // SAFETY: The index is in bounds and tests synchronize all accesses. - unsafe { shm.as_ptr().add(index).read() } + unsafe { mapping.as_ptr().add(index).read() } } - fn assert_zero_initialized(shm: &Shm) { - assert!(shm.len() >= ZERO_INITIALIZED_SIZE); - // SAFETY: No writes occur while this slice is borrowed. - assert!(unsafe { shm.as_slice() }.iter().all(|byte| *byte == 0)); + fn assert_zero_initialized(mapping: &Mapping) { + assert!(mapping.len() >= ZERO_INITIALIZED_SIZE); + assert!((0..mapping.len()).all(|index| read_byte(mapping, index) == 0)); } - fn write_byte(shm: &Shm, index: usize, value: u8) { - assert!(index < shm.len()); + fn write_byte(mapping: &Mapping, index: usize, value: u8) { + assert!(index < mapping.len()); // SAFETY: The index is in bounds and tests synchronize all accesses. - unsafe { shm.as_ptr().add(index).write(value) }; + unsafe { mapping.as_ptr().add(index).write(value) }; } } diff --git a/crates/fspy_shm/src/linux/README.md b/crates/fspy_shm/src/linux/README.md deleted file mode 100644 index 5dee2565..00000000 --- a/crates/fspy_shm/src/linux/README.md +++ /dev/null @@ -1,24 +0,0 @@ -# Linux backend - -The Linux backend stores data in a sealed `memfd`. A process opens the mapping by connecting to an abstract Unix-domain socket and receiving the descriptor from a broker. It then accesses the mapped memory directly. - -The backend must avoid `/dev/shm` quotas, expose a large mapping without allocating every page up front, support synchronous opens from preload code, and stop new opens when the owner is dropped without invalidating existing views. - -## Options considered - -| Option | Decision | -| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------- | -| POSIX shared memory | Rejected because Linux stores it in `/dev/shm`, so it shares that mount's size limit. | -| System V shared memory | Rejected because IPC namespace limits affect availability and the owner must explicitly remove the segment. | -| Sparse temporary file | Rejected because dirty pages may reach disk, and sharing the path and deleting the file require additional handling. | -| `memfd` with descriptor broker | Selected. It avoids `/dev/shm` and System V limits. The kernel keeps it alive while a descriptor or mapping refers to it. | - -The broker accepts and serves clients with Tokio. Opening is synchronous because it can run before `main`, so creating an owner must occur inside a Tokio runtime. - -## Why not `shared_memory` - -`shared_memory` uses POSIX `shm_open` on Linux and cannot construct a mapping from a `memfd`, so it retains the `/dev/shm` dependency. - -## Lifetime semantics - -Dropping the owner stops the broker. Existing views remain valid; later opens fail. diff --git a/crates/fspy_shm/src/linux/broker.rs b/crates/fspy_shm/src/linux/broker.rs deleted file mode 100644 index 484f3e23..00000000 --- a/crates/fspy_shm/src/linux/broker.rs +++ /dev/null @@ -1,235 +0,0 @@ -use std::{ - ffi::OsStr, - future::Future, - io, - os::{ - fd::{AsRawFd, FromRawFd, OwnedFd}, - unix::ffi::OsStrExt, - }, - sync::Arc, -}; - -use nix::{ - sys::socket::{ - AddressFamily, SockFlag, SockType, UnixAddr, connect, getsockopt, socket, - sockopt::PeerCredentials, - }, - unistd::{Uid, geteuid}, -}; -use passfd::{FdPassingExt as SyncFdPassingExt, tokio::FdPassingExt as AsyncFdPassingExt}; -use tokio::{net::UnixListener, task::JoinSet}; -use tokio_util::sync::{CancellationToken, DropGuard}; -use tracing::{debug, warn}; -use uuid::Uuid; - -/// Creates the broker listener for `memfd` and returns the mapping id, the -/// broker task, and the guard whose drop stops the task. -/// -/// The listener is bound eagerly, so the id is usable as soon as the task is -/// spawned; connections arriving earlier wait in the listen backlog. -pub(super) fn new( - memfd: OwnedFd, -) -> io::Result<(String, impl Future + Send + 'static, DropGuard)> { - let id = Uuid::new_v4().simple().to_string(); - // Tokio treats a NUL-prefixed Unix socket path as a Linux abstract address. - // This keeps the broker out of the filesystem and makes the id the complete address. - let mut address = Vec::with_capacity(id.len() + 1); - address.push(0); - address.extend_from_slice(id.as_bytes()); - let listener = UnixListener::bind(OsStr::from_bytes(&address))?; - - let stop = CancellationToken::new(); - let service = run_broker(listener, memfd, geteuid(), stop.clone()); - Ok((id, service, stop.drop_guard())) -} - -async fn run_broker( - listener: UnixListener, - memfd: OwnedFd, - owner_uid: Uid, - stop: CancellationToken, -) { - let memfd = Arc::new(memfd); - let mut sends = JoinSet::new(); - loop { - tokio::select! { - biased; - () = stop.cancelled() => return, - _result = sends.join_next(), if !sends.is_empty() => {} - client = listener.accept() => match client { - Ok((client, _address)) => { - // Abstract sockets have no filesystem permissions, so authenticate the - // connecting process with the kernel-provided SO_PEERCRED credentials: - // https://man7.org/linux/man-pages/man7/unix.7.html - // D-Bus prefers the same mechanism because it requires no peer cooperation: - // https://gitlab.freedesktop.org/dbus/dbus/-/blob/958bf9db2100553bcd2fe2a854e1ebb42e886054/dbus/dbus-sysdeps-unix.c#L2296-2303 - let credentials = match getsockopt(&client, PeerCredentials) { - Ok(credentials) => credentials, - Err(error) => { - debug!("shared-memory broker failed to read peer credentials: {error}"); - continue; - } - }; - if credentials.uid() != owner_uid.as_raw() { - debug!("shared-memory broker rejected a client owned by another user"); - continue; - } - let memfd = Arc::clone(&memfd); - sends.spawn(async move { - if let Err(error) = - AsyncFdPassingExt::send_fd(&client, memfd.as_raw_fd()).await - { - debug!("shared-memory broker failed to send a descriptor: {error}"); - } - }); - } - Err(error) => { - warn!("shared-memory broker failed to accept a connection: {error}"); - return; - } - }, - } - } -} - -pub(super) fn request_memfd(id: &str) -> io::Result { - // Prevent the broker connection from leaking into later execs. - let socket = socket(AddressFamily::Unix, SockType::Stream, SockFlag::SOCK_CLOEXEC, None)?; - let address = UnixAddr::new_abstract(id.as_bytes())?; - connect(socket.as_raw_fd(), &address)?; - // `SCM_RIGHTS` does not preserve descriptor flags; `passfd` sets - // `FD_CLOEXEC` on the received descriptor before returning it. - let descriptor = SyncFdPassingExt::recv_fd(&socket.as_raw_fd())?; - // SAFETY: passfd returns a newly received descriptor owned by the caller. - Ok(unsafe { OwnedFd::from_raw_fd(descriptor) }) -} - -#[cfg(test)] -mod tests { - use std::{io, process::Command}; - - use memfd::MemfdOptions; - use nix::fcntl::{FcntlArg, FdFlag, SealFlag, fcntl}; - use subprocess_test::command_for_fn; - - use super::*; - - #[test] - fn broker_construction_is_independent_of_long_tmpdir() { - let command = command_for_fn!((), |(): ()| { - let runtime = tokio::runtime::Builder::new_multi_thread() - .worker_threads(1) - .enable_io() - .enable_time() - .build() - .unwrap(); - let _guard = runtime.enter(); - let _owner = crate::create(4096).unwrap(); - }); - let status = std::thread::spawn(move || { - Command::from(command) - .env("TMPDIR", format!("/tmp/{}", "x".repeat(4096))) - .status() - .unwrap() - }) - .join() - .unwrap(); - assert!(status.success()); - } - - #[test] - fn create_without_runtime_fails() { - let error = match crate::create(4096) { - Ok(_owner) => panic!("create without a runtime should fail"), - Err(error) => error, - }; - assert!(error.to_string().contains("tokio runtime")); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 1)] - async fn broker_stops_when_guard_drops() { - let memfd: OwnedFd = MemfdOptions::new() - .close_on_exec(true) - .create("shared-memory-test") - .unwrap() - .into_file() - .into(); - let (_id, service, guard) = new(memfd).unwrap(); - let broker = tokio::spawn(service); - tokio::task::yield_now().await; - assert!(!broker.is_finished()); - - drop(guard); - tokio::time::timeout(std::time::Duration::from_secs(10), broker) - .await - .expect("broker should stop when the guard drops") - .unwrap(); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 1)] - async fn received_descriptor_is_close_on_exec() { - let owner = crate::create(4096).unwrap(); - let id = owner.id().to_owned(); - let descriptor = request_memfd_blocking(id).await.unwrap(); - assert!( - FdFlag::from_bits_retain(fcntl(&descriptor, FcntlArg::F_GETFD).unwrap()) - .contains(FdFlag::FD_CLOEXEC) - ); - assert_eq!( - SealFlag::from_bits_retain(fcntl(&descriptor, FcntlArg::F_GET_SEALS).unwrap()), - SealFlag::F_SEAL_GROW | SealFlag::F_SEAL_SHRINK | SealFlag::F_SEAL_SEAL - ); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 1)] - async fn broker_serves_concurrent_opens() { - let owner = crate::create(4096).unwrap(); - let id = owner.id().to_owned(); - let clients = (0..12) - .map(|_| { - let id = id.clone(); - tokio::task::spawn_blocking(move || crate::open(&id)) - }) - .collect::>(); - - for client in clients { - assert_eq!(client.await.unwrap().unwrap().len(), 4096); - } - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 1)] - async fn brokers_are_isolated_by_abstract_name() { - let first = crate::create(4096).unwrap(); - let second = crate::create(4096).unwrap(); - let first_id = first.id().to_owned(); - let second_id = second.id().to_owned(); - - let first_opened = open_blocking(first_id).await.unwrap(); - let second_opened = open_blocking(second_id).await.unwrap(); - // SAFETY: Both mappings are live and the accesses are in bounds and synchronized. - unsafe { - first.as_ptr().write(17); - second.as_ptr().write(29); - assert_eq!(first_opened.as_ptr().read(), 17); - assert_eq!(second_opened.as_ptr().read(), 29); - } - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 1)] - async fn unavailable_ids_are_rejected() { - let owner = crate::create(4096).unwrap(); - let id = owner.id().to_owned(); - - assert!(open_blocking("not-a-broker-id".to_owned()).await.is_err()); - assert!(open_blocking("x".repeat(108)).await.is_err()); - assert!(open_blocking(id).await.is_ok()); - } - - async fn request_memfd_blocking(id: String) -> io::Result { - tokio::task::spawn_blocking(move || request_memfd(&id)).await.unwrap() - } - - async fn open_blocking(id: String) -> io::Result { - tokio::task::spawn_blocking(move || crate::open(&id)).await.unwrap() - } -} diff --git a/crates/fspy_shm/src/linux/mod.rs b/crates/fspy_shm/src/linux/mod.rs deleted file mode 100644 index f0abf020..00000000 --- a/crates/fspy_shm/src/linux/mod.rs +++ /dev/null @@ -1,130 +0,0 @@ -#![doc = include_str!("README.md")] - -mod broker; - -use std::{io, os::fd::OwnedFd, slice}; - -use memfd::{FileSeal, MemfdOptions}; -use memmap2::{MmapOptions, MmapRaw}; -use nix::sys::stat::fstat; -use tokio_util::sync::DropGuard; - -/// An owned Linux shared-memory mapping. -pub struct Shm { - id: String, - mapping: MmapRaw, - /// Stops the owner's broker on drop. `None` for opened views. - _service: Option, -} - -/// Creates a zero-initialized sealed memfd mapping of `size` bytes and returns -/// its owner. -/// -/// The memfd is handed out to other processes by a broker task spawned onto -/// the ambient tokio runtime. The broker stops on its own when the owner is -/// dropped, after which new [`open`] calls fail while already-open views stay -/// usable (see the [ownership semantics](crate)). -/// -/// # Errors -/// -/// Returns an error if no tokio runtime is active or the memfd, mapping, or -/// broker listener cannot be created. -pub fn create(size: usize) -> io::Result { - let runtime = tokio::runtime::Handle::try_current() - .map_err(|_| io::Error::other("creating Linux shared memory requires a tokio runtime"))?; - let size_u64 = valid_size(size)?; - // Prevent the descriptor from leaking across exec while permitting the - // size and seal set to be locked after initialization. - let memfd = MemfdOptions::new() - .allow_sealing(true) - .close_on_exec(true) - .create("vite-task-shared-memory") - .map_err(memfd_error)?; - memfd.as_file().set_len(size_u64)?; - // Keep the initialized size fixed and prevent removal of these seals; - // writes through the shared mapping remain allowed. - memfd - .add_seals(&[FileSeal::SealGrow, FileSeal::SealShrink, FileSeal::SealSeal]) - .map_err(memfd_error)?; - let mapping = MmapOptions::new().len(size).map_raw(memfd.as_file())?; - let memfd: OwnedFd = memfd.into_file().into(); - let (id, service, guard) = broker::new(memfd)?; - runtime.spawn(service); - - Ok(Shm { id, mapping, _service: Some(guard) }) -} - -/// Opens a view of the memfd mapping identified by `id` through its broker. -/// -/// Guaranteed to succeed only while the mapping's owner is alive; the -/// returned view stays usable independently of the owner afterwards (see the -/// [ownership semantics](crate)). -/// -/// # Errors -/// -/// Returns an error if the identifier is invalid, the broker is gone, or the -/// received memfd has an invalid size. -pub fn open(id: &str) -> io::Result { - let memfd = broker::request_memfd(id)?; - let stat = fstat(&memfd)?; - let size = usize::try_from(stat.st_size) - .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid shared-memory size"))?; - if size == 0 { - return Err(io::Error::new(io::ErrorKind::InvalidData, "shared-memory size is zero")); - } - let mapping = MmapOptions::new().len(size).map_raw(&memfd)?; - Ok(Shm { id: id.to_owned(), mapping, _service: None }) -} - -fn valid_size(size: usize) -> io::Result { - if size == 0 { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "shared-memory size must be nonzero", - )); - } - u64::try_from(size) - .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "shared-memory size exceeds u64")) -} - -fn memfd_error(error: memfd::Error) -> io::Error { - match error { - memfd::Error::Create(error) - | memfd::Error::AddSeals(error) - | memfd::Error::GetSeals(error) => error, - } -} - -#[expect(clippy::len_without_is_empty, reason = "shared-memory mappings are always non-empty")] -impl Shm { - /// Returns this mapping's opaque broker identifier. - #[must_use] - pub fn id(&self) -> &str { - &self.id - } - - /// Returns the mapped length in bytes. - #[must_use] - pub fn len(&self) -> usize { - self.mapping.len() - } - - /// Returns a raw pointer to the first mapped byte. - #[must_use] - pub fn as_ptr(&self) -> *mut u8 { - self.mapping.as_mut_ptr() - } - - /// Returns the mapped bytes as a shared slice. - /// - /// # Safety - /// - /// The caller must ensure that no process or thread mutates the mapping for - /// the lifetime of the returned slice. - #[must_use] - pub unsafe fn as_slice(&self) -> &[u8] { - // SAFETY: The mapping is valid for its full length, and the caller - // guarantees that it is not mutated while the slice is borrowed. - unsafe { slice::from_raw_parts(self.mapping.as_ptr(), self.mapping.len()) } - } -} diff --git a/crates/fspy_shm/src/macos/README.md b/crates/fspy_shm/src/macos/README.md deleted file mode 100644 index 058db0ef..00000000 --- a/crates/fspy_shm/src/macos/README.md +++ /dev/null @@ -1,22 +0,0 @@ -# macOS backend - -The macOS backend uses named POSIX shared memory. Another process opens the same object by name. Pages are allocated as they are accessed, and dropping the owner removes the name. Fspy does not need a separate service to pass file descriptors between processes. - -## Options considered - -| Option | Decision | -| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| System V shared memory | Rejected because kernel IPC limits affect availability and the owner must explicitly remove the segment. | -| Sparse temporary file | Rejected because dirty pages may reach disk. Another process needs the path, and the owner must delete the file. | -| Mach memory entry with port transfer | Rejected because another process can receive the memory entry only through a Mach port. Fspy would need a separate service to transfer that port. | -| POSIX shared memory | Selected. Another process can open it by name, pages are allocated as accessed, and `shm_unlink` removes the name. | - -Unlike Linux, macOS does not route POSIX shared memory through a container's `/dev/shm` mount. - -## Why not `shared_memory` - -`shared_memory` uses the same POSIX shared-memory mechanism, but it also supports opening mappings through files and changing which process deletes them. Fspy needs neither feature. It only needs to create, open, map, and unlink shared memory, so the backend calls the POSIX APIs directly. - -## Lifetime semantics - -Dropping the owner unlinks the POSIX name. Existing views remain valid; later opens fail. diff --git a/crates/fspy_shm/src/macos/mod.rs b/crates/fspy_shm/src/macos/mod.rs deleted file mode 100644 index ea646e1f..00000000 --- a/crates/fspy_shm/src/macos/mod.rs +++ /dev/null @@ -1,174 +0,0 @@ -#![doc = include_str!("README.md")] - -use std::{io, os::fd::AsFd, slice}; - -use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; -use memmap2::{MmapOptions, MmapRaw}; -use nix::{ - errno::Errno, - fcntl::{FcntlArg, FdFlag, OFlag, fcntl}, - sys::{ - mman::{shm_open, shm_unlink}, - stat::{Mode, fstat}, - }, - unistd::ftruncate, -}; -use uuid::Uuid; - -const NAME_PREFIX: &str = "/fspy_"; - -/// An owned macOS shared-memory mapping. -pub struct Shm { - id: String, - mapping: MmapRaw, - owner: bool, -} - -/// Creates a zero-initialized POSIX shared-memory mapping of `size` bytes and -/// returns its owner. -/// -/// # Errors -/// -/// Returns an error if the object cannot be created, sized, or mapped. -pub fn create(size: usize) -> io::Result { - if size == 0 { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "shared-memory size must be nonzero", - )); - } - let size_i64 = i64::try_from(size).map_err(|_| { - io::Error::new(io::ErrorKind::InvalidInput, "shared-memory size exceeds supported range") - })?; - - loop { - let id = new_id(); - let fd = match shm_open( - id.as_str(), - OFlag::O_CREAT | OFlag::O_EXCL | OFlag::O_RDWR, - Mode::S_IRUSR | Mode::S_IWUSR, - ) { - Ok(fd) => fd, - Err(Errno::EEXIST) => continue, - Err(error) => return Err(error.into()), - }; - - if let Err(error) = ensure_cloexec(&fd) { - let _ = shm_unlink(id.as_str()); - return Err(error.into()); - } - if let Err(error) = ftruncate(&fd, size_i64) { - let _ = shm_unlink(id.as_str()); - return Err(error.into()); - } - let mapping = match MmapOptions::new().len(size).map_raw(&fd) { - Ok(mapping) => mapping, - Err(error) => { - let _ = shm_unlink(id.as_str()); - return Err(error); - } - }; - - return Ok(Shm { id, mapping, owner: true }); - } -} - -/// Opens the POSIX shared-memory mapping identified by `id`. -/// -/// # Errors -/// -/// Returns an error if the mapping is unavailable. -pub fn open(id: &str) -> io::Result { - let fd = shm_open(id, OFlag::O_RDWR, Mode::empty())?; - ensure_cloexec(&fd)?; - // If another process shrinks the object before `mmap`, `mmap` returns an - // error. If it resizes the object after `mmap`, `open` does not access the - // mapped pages. A concurrent resize cannot make `open` access invalid memory. - let stat = fstat(&fd)?; - let size = usize::try_from(stat.st_size) - .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid shared-memory size"))?; - if size == 0 { - return Err(io::Error::new(io::ErrorKind::InvalidData, "shared-memory size is zero")); - } - let mapping = MmapOptions::new().len(size).map_raw(&fd)?; - - Ok(Shm { id: id.to_owned(), mapping, owner: false }) -} - -fn new_id() -> String { - format!("{NAME_PREFIX}{}", URL_SAFE_NO_PAD.encode(Uuid::new_v4().as_bytes())) -} - -// macOS rejects O_CLOEXEC for shm_open, so preserve the descriptor guarantee via fcntl. -fn ensure_cloexec(fd: &Fd) -> nix::Result<()> { - let flags = FdFlag::from_bits_retain(fcntl(fd, FcntlArg::F_GETFD)?); - if !flags.contains(FdFlag::FD_CLOEXEC) { - fcntl(fd, FcntlArg::F_SETFD(flags | FdFlag::FD_CLOEXEC))?; - } - Ok(()) -} - -impl Drop for Shm { - fn drop(&mut self) { - if self.owner { - let _ = shm_unlink(self.id.as_str()); - } - } -} - -#[expect(clippy::len_without_is_empty, reason = "shared-memory mappings are always non-empty")] -impl Shm { - /// Returns this mapping's opaque macOS identifier. - #[must_use] - pub fn id(&self) -> &str { - &self.id - } - - /// Returns the mapped length in bytes. - #[must_use] - pub fn len(&self) -> usize { - self.mapping.len() - } - - /// Returns a raw pointer to the first mapped byte. - #[must_use] - pub fn as_ptr(&self) -> *mut u8 { - self.mapping.as_mut_ptr() - } - - /// Returns the mapped bytes as a shared slice. - /// - /// # Safety - /// - /// The caller must ensure that no process or thread mutates the mapping for - /// the lifetime of the returned slice. - #[must_use] - pub unsafe fn as_slice(&self) -> &[u8] { - // SAFETY: The mapping is valid for its full length, and the caller - // guarantees that it is not mutated while the slice is borrowed. - unsafe { slice::from_raw_parts(self.mapping.as_ptr(), self.mapping.len()) } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[cfg(target_pointer_width = "64")] - #[test] - fn four_gib_mapping_supports_endpoint_access() { - const PRODUCTION_SIZE: usize = 4 * 1024 * 1024 * 1024; - - let owner = create(PRODUCTION_SIZE).unwrap(); - let opened = open(owner.id()).unwrap(); - - // SAFETY: Both endpoint indexes are within the exact mapped length and - // accesses are synchronized within this test. - unsafe { - owner.as_ptr().write(17); - owner.as_ptr().add(PRODUCTION_SIZE - 1).write(29); - assert_eq!(opened.as_ptr().read(), 17); - assert_eq!(opened.as_ptr().add(PRODUCTION_SIZE - 1).read(), 29); - } - } -} diff --git a/crates/fspy_shm/src/windows/README.md b/crates/fspy_shm/src/windows/README.md deleted file mode 100644 index fac5350a..00000000 --- a/crates/fspy_shm/src/windows/README.md +++ /dev/null @@ -1,23 +0,0 @@ -# Windows backend - -The Windows backend maps a sparse temporary file and gives the mapping a Windows section name. Another process opens the same section using only that name. Creating the mapping does not reserve its full size in system commit or disk space. - -## Options considered - -| Option | Decision | -| ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- | -| Paging-file-backed section | Rejected because the section's full size is charged against system commit. | -| Reserved section with incremental `VirtualAlloc(MEM_COMMIT)` | Rejected because writers would need to coordinate when committing more pages. | -| Sparse temporary file with a named section | Selected. Disk blocks are allocated only for written ranges, and another process can open the mapping using the section name. | - -`FILE_ATTRIBUTE_TEMPORARY` tells Windows to keep file data in memory when possible, but Windows may still write dirty pages to disk under memory pressure. Creation fails if the temporary volume does not support sparse files. - -## Why not `shared_memory` - -`shared_memory` creates and extends a regular file before fspy can mark it sparse. It therefore cannot ensure that untouched ranges use no disk blocks. - -`shared_memory` also uses the file path to identify the mapping. Fspy uses the section name, so another process does not need the creator's temporary-file path. - -## Lifetime semantics - -Dropping the owner unmaps its view and closes the delete-on-close backing file. Existing views keep the section alive. The section name may remain openable during that period. `ChannelConf::sender` checks the receiver's lock file first to prevent new senders after the receiver has shut down. diff --git a/crates/fspy_shm/src/windows/mod.rs b/crates/fspy_shm/src/windows/mod.rs deleted file mode 100644 index ec0c8cad..00000000 --- a/crates/fspy_shm/src/windows/mod.rs +++ /dev/null @@ -1,198 +0,0 @@ -#![doc = include_str!("README.md")] - -mod sys; - -use std::{ - env::temp_dir, - fs::{self, File, OpenOptions}, - io, - os::windows::fs::OpenOptionsExt, - slice, -}; - -use sys::MappedView; -use uuid::Uuid; - -const MAPPING_NAME_PREFIX: &str = r"Local\vite-task-fspy-"; -const BACKING_DIR: &str = "vite-task-fspy"; - -/// An owned Windows shared-memory mapping. -pub struct Shm { - id: String, - view: MappedView, - #[cfg_attr(not(test), expect(dead_code, reason = "retained for owner cleanup"))] - backing_file: Option, -} - -/// Creates a zero-initialized, sparse, temporary file-backed named mapping of -/// `size` bytes and returns its owner. -/// -/// # Errors -/// -/// Returns an error if the backing file or mapping cannot be created. -pub fn create(size: usize) -> io::Result { - if size == 0 { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "shared-memory size must be nonzero", - )); - } - let size_u64 = u64::try_from(size).map_err(|_| { - io::Error::new(io::ErrorKind::InvalidInput, "shared-memory size exceeds u64") - })?; - let id = Uuid::new_v4().simple().to_string(); - let backing_dir = temp_dir().join(BACKING_DIR); - fs::create_dir_all(&backing_dir)?; - let backing_file = OpenOptions::new() - .read(true) - .write(true) - .create_new(true) - .share_mode(sys::SHARE_ALL) - .attributes(sys::TEMPORARY) - .custom_flags(sys::DELETE_ON_CLOSE) - .open(backing_dir.join(format!("{id}.shm")))?; - sys::set_sparse(&backing_file)?; - backing_file.set_len(size_u64)?; - let mapping = sys::create_file_mapping(&backing_file, &mapping_name(&id))?; - let view = MappedView::new(mapping)?; - - Ok(Shm { id, view, backing_file: Some(backing_file) }) -} - -/// Opens the named mapping identified by `id`. -/// -/// # Errors -/// -/// Returns an error if the mapping is unavailable. -pub fn open(id: &str) -> io::Result { - let mapping = sys::open_file_mapping(&mapping_name(id))?; - let view = MappedView::new(mapping)?; - - Ok(Shm { id: id.to_owned(), view, backing_file: None }) -} - -fn mapping_name(id: &str) -> String { - format!("{MAPPING_NAME_PREFIX}{id}") -} - -#[expect(clippy::len_without_is_empty, reason = "shared-memory mappings are always non-empty")] -impl Shm { - /// Returns this mapping's opaque Windows identifier. - #[must_use] - pub fn id(&self) -> &str { - &self.id - } - - /// Returns the mapped length in bytes. - #[must_use] - pub const fn len(&self) -> usize { - self.view.len() - } - - /// Returns a raw pointer to the first mapped byte. - #[must_use] - pub const fn as_ptr(&self) -> *mut u8 { - self.view.as_ptr() - } - - /// Returns the mapped bytes as a shared slice. - /// - /// # Safety - /// - /// The caller must ensure that no process or thread mutates the mapping for - /// the lifetime of the returned slice. - #[must_use] - pub const unsafe fn as_slice(&self) -> &[u8] { - // SAFETY: The view is valid for its exact length, and the caller - // guarantees that it is not mutated while the slice is borrowed. - unsafe { slice::from_raw_parts(self.view.as_ptr(), self.view.len()) } - } -} - -#[cfg(test)] -mod tests { - use std::{ffi::OsString, fs, process::Command}; - - use subprocess_test::command_for_fn; - - use super::*; - - const SIZE: usize = 64 * 1024; - - #[test] - fn subprocess_open_ignores_changed_temp_and_working_directory() { - let owner = create(SIZE).unwrap(); - let changed_cwd = - temp_dir().join(BACKING_DIR).join(format!("changed-cwd-{}", Uuid::new_v4())); - fs::create_dir(&changed_cwd).unwrap(); - // SAFETY: The child does not access the mapping until this write completes. - unsafe { owner.as_ptr().write(17) }; - - let mut command = command_for_fn!(owner.id().to_owned(), |id: String| { - let opened = open(&id).unwrap(); - // SAFETY: The parent waits for this child and does not access the - // mapping concurrently. - unsafe { - assert_eq!(opened.as_ptr().read(), 17); - opened.as_ptr().add(SIZE - 1).write(29); - } - }); - command.cwd = changed_cwd.clone(); - command.envs.insert(OsString::from("TMP"), OsString::from("changed-relative-tmp")); - command.envs.insert(OsString::from("TEMP"), OsString::from("changed-relative-temp")); - let succeeded = Command::from(command).status().unwrap().success(); - fs::remove_dir(changed_cwd).unwrap(); - - assert!(succeeded); - // SAFETY: The child exited before this read. - assert_eq!(unsafe { owner.as_ptr().add(SIZE - 1).read() }, 29); - } - - #[test] - fn owner_cleanup_deletes_backing_file_and_preserves_existing_views() { - let owner = create(SIZE).unwrap(); - let id = owner.id().to_owned(); - let path = temp_dir().join(BACKING_DIR).join(format!("{id}.shm")); - let opened = open(&id).unwrap(); - assert!(path.exists()); - - drop(owner); - - assert!(!path.exists()); - // SAFETY: The mapping remains live and no other test access is concurrent. - unsafe { opened.as_ptr().write(17) }; - // SAFETY: The preceding write is complete and the mapping remains live. - assert_eq!(unsafe { opened.as_ptr().read() }, 17); - let reopened = open(&id).unwrap(); - drop(opened); - drop(reopened); - assert!(open(&id).is_err()); - } - - #[cfg(target_pointer_width = "64")] - #[test] - fn four_gib_mapping_is_sparse_and_supports_endpoint_access() { - const PRODUCTION_SIZE: usize = 4 * 1024 * 1024 * 1024; - const MAX_ENDPOINT_ALLOCATION: u64 = 16 * 1024 * 1024; - - let owner = create(PRODUCTION_SIZE).unwrap(); - let backing_file = owner.backing_file.as_ref().unwrap(); - let (logical_size, initial_allocation) = sys::file_sizes(backing_file).unwrap(); - assert_eq!(logical_size, PRODUCTION_SIZE as u64); - assert!(initial_allocation < MAX_ENDPOINT_ALLOCATION); - - let opened = open(owner.id()).unwrap(); - // SAFETY: Both endpoint indexes are within the exact mapped length and - // accesses are synchronized within this test. - unsafe { - owner.as_ptr().write(17); - owner.as_ptr().add(PRODUCTION_SIZE - 1).write(29); - assert_eq!(opened.as_ptr().read(), 17); - assert_eq!(opened.as_ptr().add(PRODUCTION_SIZE - 1).read(), 29); - } - - let (logical_size, endpoint_allocation) = sys::file_sizes(backing_file).unwrap(); - assert_eq!(logical_size, PRODUCTION_SIZE as u64); - assert!(endpoint_allocation < MAX_ENDPOINT_ALLOCATION); - } -} diff --git a/crates/fspy_shm/src/windows/sys.rs b/crates/fspy_shm/src/windows/sys.rs deleted file mode 100644 index 9786e5bb..00000000 --- a/crates/fspy_shm/src/windows/sys.rs +++ /dev/null @@ -1,192 +0,0 @@ -use std::{ - io, - iter::once, - os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}, - ptr::NonNull, -}; - -#[cfg(test)] -use windows_sys::Win32::Storage::FileSystem::{ - FILE_STANDARD_INFO, FileStandardInfo, GetFileInformationByHandleEx, -}; -use windows_sys::Win32::{ - Foundation::{ERROR_ALREADY_EXISTS, GetLastError}, - Storage::FileSystem::{ - FILE_ATTRIBUTE_TEMPORARY, FILE_FLAG_DELETE_ON_CLOSE, FILE_SHARE_DELETE, FILE_SHARE_READ, - FILE_SHARE_WRITE, - }, - System::{ - IO::DeviceIoControl, - Ioctl::FSCTL_SET_SPARSE, - Memory::{ - CreateFileMappingW, FILE_MAP_WRITE, MEMORY_BASIC_INFORMATION, - MEMORY_MAPPED_VIEW_ADDRESS, MapViewOfFile, OpenFileMappingW, PAGE_READWRITE, - UnmapViewOfFile, VirtualQuery, - }, - }, -}; - -pub(super) const SHARE_ALL: u32 = FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE; -pub(super) const TEMPORARY: u32 = FILE_ATTRIBUTE_TEMPORARY; -pub(super) const DELETE_ON_CLOSE: u32 = FILE_FLAG_DELETE_ON_CLOSE; - -pub(super) fn set_sparse(file: &std::fs::File) -> io::Result<()> { - let mut bytes_returned = 0; - // SAFETY: `file` supplies a valid synchronous file handle. FSCTL_SET_SPARSE - // requires no input or output buffers, and `bytes_returned` is writable for - // the duration of the call. - let result = unsafe { - DeviceIoControl( - file.as_raw_handle().cast(), - FSCTL_SET_SPARSE, - std::ptr::null(), - 0, - std::ptr::null_mut(), - 0, - &raw mut bytes_returned, - std::ptr::null_mut(), - ) - }; - if result == 0 { Err(last_error()) } else { Ok(()) } -} - -#[cfg(test)] -pub(super) fn file_sizes(file: &std::fs::File) -> io::Result<(u64, u64)> { - let mut info = FILE_STANDARD_INFO::default(); - let info_size = u32::try_from(std::mem::size_of::()) - .map_err(|_| io::Error::other("file size information is too large"))?; - // SAFETY: `file` supplies a valid handle and `info` is a writable - // FILE_STANDARD_INFO buffer of exactly `info_size` bytes. - let result = unsafe { - GetFileInformationByHandleEx( - file.as_raw_handle().cast(), - FileStandardInfo, - (&raw mut info).cast(), - info_size, - ) - }; - if result == 0 { - return Err(last_error()); - } - - let logical_size = u64::try_from(info.EndOfFile) - .map_err(|_| io::Error::other("file has a negative logical size"))?; - let allocated_size = u64::try_from(info.AllocationSize) - .map_err(|_| io::Error::other("file has a negative allocated size"))?; - Ok((logical_size, allocated_size)) -} - -pub(super) fn create_file_mapping(file: &std::fs::File, name: &str) -> io::Result { - let name = wide_name(name)?; - // SAFETY: `file` supplies a valid handle, the security pointer is null, and - // `name` is a live, NUL-terminated UTF-16 buffer for the duration of the call. - let raw_handle = unsafe { - CreateFileMappingW( - file.as_raw_handle().cast(), - std::ptr::null(), - PAGE_READWRITE, - 0, - 0, - name.as_ptr(), - ) - }; - if raw_handle.is_null() { - return Err(last_error()); - } - - // CreateFileMappingW reports name collisions through the thread's last-error - // value even though it returns a valid handle. - // SAFETY: GetLastError has no preconditions and immediately follows that call. - let error = unsafe { GetLastError() }; - // SAFETY: A non-null CreateFileMappingW result is an owned mapping handle. - let handle = unsafe { OwnedHandle::from_raw_handle(raw_handle.cast()) }; - if error == ERROR_ALREADY_EXISTS { - Err(io::Error::new(io::ErrorKind::AlreadyExists, "shared-memory mapping already exists")) - } else { - Ok(handle) - } -} - -pub(super) fn open_file_mapping(name: &str) -> io::Result { - let name = wide_name(name)?; - // SAFETY: `name` is a live, NUL-terminated UTF-16 buffer and inheritance is disabled. - let raw_handle = unsafe { OpenFileMappingW(FILE_MAP_WRITE, 0, name.as_ptr()) }; - if raw_handle.is_null() { - return Err(last_error()); - } - - // SAFETY: A non-null OpenFileMappingW result is an owned mapping handle. - Ok(unsafe { OwnedHandle::from_raw_handle(raw_handle.cast()) }) -} - -pub(super) struct MappedView { - pointer: NonNull, - len: usize, - _mapping: OwnedHandle, -} - -impl MappedView { - pub(super) fn new(mapping: OwnedHandle) -> io::Result { - // SAFETY: `mapping` is a valid file-mapping handle. Offset and length - // zero map the complete section. - let view = - unsafe { MapViewOfFile(mapping.as_raw_handle().cast(), FILE_MAP_WRITE, 0, 0, 0) }; - let pointer = NonNull::new(view.Value.cast::()).ok_or_else(last_error)?; - - let mut info = MEMORY_BASIC_INFORMATION::default(); - // SAFETY: `pointer` is inside the mapped view and `info` is writable for - // its exact size. - let result = unsafe { - VirtualQuery( - pointer.as_ptr().cast(), - &raw mut info, - std::mem::size_of::(), - ) - }; - if result == 0 { - let error = last_error(); - // SAFETY: `pointer` is the base address returned by MapViewOfFile. - let _ = unsafe { - UnmapViewOfFile(MEMORY_MAPPED_VIEW_ADDRESS { Value: pointer.as_ptr().cast() }) - }; - return Err(error); - } - - let len = info.RegionSize; - Ok(Self { pointer, len, _mapping: mapping }) - } - - pub(super) const fn as_ptr(&self) -> *mut u8 { - self.pointer.as_ptr() - } - - pub(super) const fn len(&self) -> usize { - self.len - } -} - -impl Drop for MappedView { - fn drop(&mut self) { - // SAFETY: `pointer` is the base address returned by MapViewOfFile and - // this guard owns that view until this single unmap operation. - let _ = unsafe { - UnmapViewOfFile(MEMORY_MAPPED_VIEW_ADDRESS { Value: self.pointer.as_ptr().cast() }) - }; - } -} - -fn wide_name(name: &str) -> io::Result> { - if name.contains('\0') { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "shared-memory name contains a NUL", - )); - } - Ok(name.encode_utf16().chain(once(0)).collect()) -} - -fn last_error() -> io::Error { - // SAFETY: GetLastError has no preconditions and is called immediately after - // the failing Win32 operation on the same thread. - io::Error::from_raw_os_error(unsafe { GetLastError() }.cast_signed()) -}