From 126f02b446aa3395b2ed64cb63c16480edb24716 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Tue, 28 Jul 2026 17:36:02 +0800 Subject: [PATCH] fix(fspy): unify shared memory on a sparse temp file across all platforms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex CLI's and Claude Code's default sandboxes deny the primitives fspy's Unix shared memory was built on: the macOS Seatbelt profile denies `shm_open` (`ipc-posix-shm-write-create`), and both sandboxes block Unix-domain sockets, which the Linux memfd broker depended on. Plain files in the temp directory are writable under both. Replace all three backends with one file-backed implementation attached by path: a sparse temporary file plus `memmap2`, with the file's absolute path as the identifier. No broker, no tokio requirement, no global object names. Lifetime semantics converge too: dropping the owner makes the name disappear and later opens fail, while existing views stay usable — now on Windows as well, where closing the delete-on-close handle applies the delete disposition even while other processes hold views. Refs #563. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 1 + Cargo.lock | 16 - Cargo.toml | 1 - crates/fspy_shared/src/ipc/channel/mod.rs | 89 ++--- crates/fspy_shared/src/ipc/channel/shm_io.rs | 34 +- crates/fspy_shm/Cargo.toml | 18 +- crates/fspy_shm/README.md | 74 ++-- crates/fspy_shm/src/file_backed.rs | 348 +++++++++++++++++++ crates/fspy_shm/src/file_backed/sys.rs | 71 ++++ crates/fspy_shm/src/lib.rs | 124 +++---- crates/fspy_shm/src/linux/README.md | 24 -- crates/fspy_shm/src/linux/broker.rs | 235 ------------- crates/fspy_shm/src/linux/mod.rs | 130 ------- crates/fspy_shm/src/macos/README.md | 22 -- crates/fspy_shm/src/macos/mod.rs | 174 ---------- crates/fspy_shm/src/windows/README.md | 23 -- crates/fspy_shm/src/windows/mod.rs | 198 ----------- crates/fspy_shm/src/windows/sys.rs | 192 ---------- 18 files changed, 566 insertions(+), 1208 deletions(-) create mode 100644 crates/fspy_shm/src/file_backed.rs create mode 100644 crates/fspy_shm/src/file_backed/sys.rs delete mode 100644 crates/fspy_shm/src/linux/README.md delete mode 100644 crates/fspy_shm/src/linux/broker.rs delete mode 100644 crates/fspy_shm/src/linux/mod.rs delete mode 100644 crates/fspy_shm/src/macos/README.md delete mode 100644 crates/fspy_shm/src/macos/mod.rs delete mode 100644 crates/fspy_shm/src/windows/README.md delete mode 100644 crates/fspy_shm/src/windows/mod.rs delete mode 100644 crates/fspy_shm/src/windows/sys.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c5c30f4..91b6764e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Changelog +- **Fixed** Automatic file-access tracking now works inside coding-agent sandboxes, including the default Codex CLI and Claude Code sandboxes ([#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..fb1b2ef1 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,15 @@ 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, handle) = fspy_shm::create(capacity)?; + let mapping = handle.map()?; - 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,16 +53,17 @@ 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)?; - // 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) }; + let mapping = fspy_shm::open(&self.shm_id.to_cow_os_str())?.map()?; + // SAFETY: `mapping` is a freshly mapped 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(mapping) }; Ok(Sender { writer, lock_file, lock_file_path: self.lock_file_path.clone() }) } } 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..f7f28df5 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,31 +672,20 @@ 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, handle) = 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(); - // 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. - let writer = unsafe { ShmWriter::new(shm) }; + let mapping = + fspy_shm::open(std::ffi::OsStr::new(&shm_name)).unwrap().map().unwrap(); + // SAFETY: `mapping` is a freshly mapped shared memory region with a + // valid pointer and size. Concurrent write access is safe because + // `ShmWriter` uses atomic operations. + let writer = unsafe { ShmWriter::new(mapping) }; for i in 0..FRAME_COUNT_EACH_CHILD { let frame_data = std::format!("{child_index} {i}"); assert!(writer.try_write_frame(frame_data.as_bytes())); @@ -712,9 +701,10 @@ mod tests { assert!(status.success()); } + let mapping = handle.map().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..512cd57e 100644 --- a/crates/fspy_shm/Cargo.toml +++ b/crates/fspy_shm/Cargo.toml @@ -7,37 +7,21 @@ license.workspace = true publish = false rust-version.workspace = true -[target.'cfg(target_os = "linux")'.dependencies] +[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 } -uuid = { workspace = true, features = ["v4"] } - -[target.'cfg(target_os = "macos")'.dependencies] -base64 = { workspace = true } -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", "Win32_Storage_FileSystem", "Win32_System_IO", "Win32_System_Ioctl", - "Win32_System_Memory", ] } [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..6be9c901 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 and returns its `ShmKeeper` and an `ShmHandle`. | +| `open(id)` | Opens an `ShmHandle` on the shared memory identified by `id`. | +| `ShmKeeper::id()` | Returns the identifier another process passes to `open`. | +| `ShmHandle::map()` | Maps the shared bytes. Callable more than once. | +| `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. `ShmHandle` is the opened file: `create` returns one so the creator never looks its own file up by name, and `open` returns one to everybody else. `Mapping` is the bytes: it keeps them alive until dropped and can do nothing else. None of the three 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. +Mapping goes through `memmap2` on every platform. The remaining platform-specific parts are three short passages: -## Platform designs +| Concern | Unix | Windows | +| ----------------- | ---------------------------------------- | --------------------------------------------------------------------------- | +| Same-user access | `mode(0o600)` on the backing file | the per-user `%TEMP%` ACL | +| Sparseness | file holes, produced by setting a length | `FSCTL_SET_SPARSE` before setting a length, or NTFS allocates every cluster | +| Keeper cleanup | unlink the path | unlink the path; see the fallback below | +| 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) +The keeper removes the name with `remove_file` on every platform. Modern Windows deletes with POSIX semantics: the name goes away at once, while [existing handles keep working](https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ntddk/ns-ntddk-_file_disposition_information_ex) and [mapped views keep the data alive](https://learn.microsoft.com/en-us/windows/win32/api/memoryapi/nf-memoryapi-createfilemappingw) until the last one goes away. The first page also reserves the right to fail the delete while a mapped view exists, and Windows versions without POSIX delete do fail it. The keeper then falls back to reopening the file with `FILE_FLAG_DELETE_ON_CLOSE` and closing it, which deletes the file once every handle to it is closed. -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 `ShmHandle`s. + +- While the keeper is alive, a process that knows the identifier can open the shared memory. +- Dropping the keeper removes the backing file's name, so later opens fail. This is cleanup, not a stop signal: processes that already opened the shared memory keep reading and writing. The fspy channel stops writers with the close gate it stores in the shared bytes. +- An `ShmHandle` and its `Mapping`s stay usable after the keeper is gone. They keep the bytes alive and cannot 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, its `Drop` never runs and the file stays behind: on Unix for the system's temporary-file reaper, on Windows until a cleanup tool runs. The file costs about as much disk as the run wrote into it. diff --git a/crates/fspy_shm/src/file_backed.rs b/crates/fspy_shm/src/file_backed.rs new file mode 100644 index 00000000..778bd5eb --- /dev/null +++ b/crates/fspy_shm/src/file_backed.rs @@ -0,0 +1,348 @@ +//! Shared memory backed by a sparse temporary file and identified by its path. +//! +//! One implementation serves every platform. The platform-specific parts are +//! the creation flags, marking the file sparse on NTFS, and a fallback for +//! removing the name on old Windows. + +#[cfg(unix)] +use std::os::unix::fs::OpenOptionsExt as _; +#[cfg(windows)] +use std::os::windows::fs::OpenOptionsExt as _; +use std::{ + env::temp_dir, + ffi::OsStr, + fs::{self, File, OpenOptions}, + io, + path::PathBuf, +}; + +use memmap2::{MmapOptions, MmapRaw}; +use uuid::Uuid; + +#[cfg(windows)] +mod sys; + +/// 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-"; + +/// Keeps the shared memory's identifier alive and removes it on drop. +/// +/// Removal is cleanup, not a stop signal: later opens fail, but existing +/// [`ShmHandle`]s and [`Mapping`]s keep reading and writing. To stop them, +/// store a flag in the shared bytes, as the fspy channel's close gate does. +pub struct ShmKeeper { + path: PathBuf, +} + +/// Opened shared memory that is not mapped yet. +/// +/// [`map`](Self::map) can be called more than once; every call returns another +/// view of the same bytes. Drop the handle once the mappings exist. +pub struct ShmHandle { + file: File, + size: usize, +} + +/// The mapped shared bytes. +/// +/// A `Mapping` keeps the bytes alive until it is dropped and cannot affect the +/// shared memory's identifier. +pub struct Mapping { + raw: MmapRaw, +} + +/// Creates `size` bytes of zero-initialized shared memory. +/// +/// Returns its [`ShmKeeper`] and an already opened [`ShmHandle`], so the +/// creating process never has to go through [`open`]. +/// +/// Only pages that are actually written occupy memory or disk, so a large +/// capacity is cheap. +/// +/// # Errors +/// +/// Returns an error if the shared memory cannot be created or sized. Creation +/// fails on volumes without sparse-file support. +pub fn create(size: usize) -> io::Result<(ShmKeeper, ShmHandle)> { + 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 mut options = OpenOptions::new(); + options.read(true).write(true).create_new(true); + // Only the creating user may open the mapping. + #[cfg(unix)] + options.mode(0o600); + // The per-user `%TEMP%` ACL provides the same-user gating that `0o600` + // provides on Unix. `FILE_ATTRIBUTE_TEMPORARY` asks Windows to keep the + // data in memory when it can. + #[cfg(windows)] + options.share_mode(sys::SHARE_ALL).attributes(sys::TEMPORARY); + + let file = options.open(&path)?; + // The keeper exists from here on, so every error path below cleans up. + let keeper = ShmKeeper { path }; + + // 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)?; + // Every byte reads as zero because the file is all holes. + file.set_len(size_u64)?; + + Ok((keeper, ShmHandle { file, size })) +} + +/// Opens the shared memory identified by `id`. +/// +/// The identifier works from any process, regardless of the process's working +/// directory or environment. +/// +/// # 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 opens are `O_CLOEXEC` / non-inheritable on every platform, so a + // traced process never leaks this descriptor, and Rust's default Windows + // share mode permits concurrent read, write and delete access. + let file = OpenOptions::new().read(true).write(true).open(id)?; + // If another process shrinks the file before `map`, mapping fails. If it + // resizes afterwards, nothing here touches the mapped pages. A concurrent + // resize cannot make a mapping 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")); + } + Ok(ShmHandle { file, size }) +} + +impl Drop for ShmKeeper { + fn drop(&mut self) { + let _removed = fs::remove_file(&self.path); + // Windows versions without POSIX delete refuse to remove the name of a + // mapped file. Arm the deferred delete instead: a handle opened with + // `FILE_FLAG_DELETE_ON_CLOSE` deletes the file once every handle to it + // is closed. + #[cfg(windows)] + if _removed.is_err() { + let _ = OpenOptions::new() + .access_mode(sys::DELETE) + .share_mode(sys::SHARE_ALL) + .custom_flags(sys::DELETE_ON_CLOSE) + .open(&self.path); + } + } +} + +impl ShmKeeper { + /// Returns the shared memory's opaque identifier, which any process passes + /// to [`open`]. + #[must_use] + pub fn id(&self) -> &OsStr { + self.path.as_os_str() + } +} + +impl ShmHandle { + /// Maps the shared bytes. + /// + /// # Errors + /// + /// Returns an error if the mapping cannot be established. + pub fn map(&self) -> io::Result { + Ok(Mapping { raw: MmapOptions::new().len(self.size).map_raw(&self.file)? }) + } +} + +#[expect(clippy::len_without_is_empty, reason = "shared-memory mappings are always non-empty")] +impl Mapping { + /// Returns the mapped length in bytes. + #[must_use] + pub fn len(&self) -> usize { + self.raw.len() + } + + /// Returns a raw pointer to the first mapped byte. + #[must_use] + pub fn as_ptr(&self) -> *mut u8 { + self.raw.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 { std::slice::from_raw_parts(self.as_ptr().cast_const(), self.len()) } + } +} + +#[cfg(test)] +mod tests { + use std::{ffi::OsString, path::Path, process::Command}; + + use subprocess_test::command_for_fn; + + use super::*; + + const SIZE: usize = 64 * 1024; + + #[test] + fn one_handle_maps_repeatedly() { + let (_keeper, handle) = create(SIZE).unwrap(); + let first = handle.map().unwrap(); + let second = handle.map().unwrap(); + + // SAFETY: In bounds, and this test synchronizes all accesses. + unsafe { + first.as_ptr().write(17); + assert_eq!(second.as_ptr().read(), 17); + } + } + + #[test] + fn subprocess_open_ignores_changed_temp_and_working_directory() { + let (keeper, handle) = create(SIZE).unwrap(); + let mapping = handle.map().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().map().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); + } + + /// Removal semantics, part one: a mapping alone (no handle) keeps the + /// bytes alive across the keeper's removal of the name. + #[test] + fn keeper_drop_removes_backing_file_and_preserves_existing_mappings() { + let (keeper, handle) = create(SIZE).unwrap(); + let id = keeper.id().to_owned(); + let path = Path::new(&id).to_owned(); + let opened = open(&id).unwrap().map().unwrap(); + drop(handle); + 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); + } + + /// Removal semantics, part two: the name goes away even while a handle is + /// still open, and that handle keeps mapping the same bytes afterwards. + #[test] + fn keeper_drop_with_open_handle_removes_name_and_handle_still_maps() { + let (keeper, handle) = create(SIZE).unwrap(); + let id = keeper.id().to_owned(); + let before = handle.map().unwrap(); + // SAFETY: In bounds, and this test synchronizes all accesses. + unsafe { before.as_ptr().write(17) }; + + drop(keeper); + + assert!(!Path::new(&id).exists()); + assert!(open(&id).is_err()); + + let after = handle.map().unwrap(); + // SAFETY: In bounds, and this test synchronizes all accesses. + unsafe { + assert_eq!(after.as_ptr().read(), 17); + after.as_ptr().add(SIZE - 1).write(29); + assert_eq!(before.as_ptr().add(SIZE - 1).read(), 29); + } + } + + #[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, handle) = 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 = handle.map().unwrap(); + let opened = open(keeper.id()).unwrap().map().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..5ecaff50 --- /dev/null +++ b/crates/fspy_shm/src/file_backed/sys.rs @@ -0,0 +1,71 @@ +//! The Win32 calls the shared implementation needs on Windows: marking the +//! backing file sparse and, in tests, reading back how much of it the +//! filesystem actually allocated. + +use std::{fs::File, io, os::windows::io::AsRawHandle}; + +#[cfg(test)] +use windows_sys::Win32::Storage::FileSystem::{ + FILE_STANDARD_INFO, FileStandardInfo, GetFileInformationByHandleEx, +}; +use windows_sys::Win32::{ + 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}, +}; + +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) const DELETE: u32 = windows_sys::Win32::Storage::FileSystem::DELETE; + +/// 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(io::Error::last_os_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(io::Error::last_os_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)) +} diff --git a/crates/fspy_shm/src/lib.rs b/crates/fspy_shm/src/lib.rs index 4798b8b8..5330a58e 100644 --- a/crates/fspy_shm/src/lib.rs +++ b/crates/fspy_shm/src/lib.rs @@ -1,113 +1,103 @@ #![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, ShmHandle, 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, handle) = create(ZERO_INITIALIZED_SIZE).unwrap(); + let first = handle.map().unwrap(); + let second = open(keeper.id()).unwrap().map().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, handle) = create(SIZE).unwrap(); + let first = handle.map().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().map().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, handle) = create(SIZE).unwrap(); + let mapping = handle.map().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().map().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, handle) = create(SIZE).unwrap(); + let id = keeper.id().to_owned(); + drop(handle); + 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(); - let opened = open(&id).unwrap(); - write_byte(&owner, 0, 17); - drop(owner); + #[test] + fn opened_mapping_survives_keeper_drop() { + let (keeper, handle) = create(SIZE).unwrap(); + let id = keeper.id().to_owned(); + let opened = open(&id).unwrap().map().unwrap(); + write_byte(&opened, 0, 17); + drop(handle); + 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()) -}