diff --git a/Cargo.lock b/Cargo.lock index 7efdf6e3..10f4e533 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1388,11 +1388,11 @@ dependencies = [ name = "fspy_shm" version = "0.0.0" dependencies = [ + "base64", "ctor", "memmap2", "passfd", "rustix", - "shared_memory", "subprocess_test", "tokio", "tokio-util", @@ -2015,15 +2015,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" -[[package]] -name = "memoffset" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" -dependencies = [ - "autocfg", -] - [[package]] name = "memoffset" version = "0.9.1" @@ -2150,19 +2141,6 @@ dependencies = [ "wincode", ] -[[package]] -name = "nix" -version = "0.23.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f3790c00a0150112de0f4cd161e3d7fc4b2d8a5542ffc35f099a2562aecb35c" -dependencies = [ - "bitflags 1.3.2", - "cc", - "cfg-if", - "libc", - "memoffset 0.6.5", -] - [[package]] name = "nix" version = "0.28.0" @@ -2185,7 +2163,7 @@ dependencies = [ "cfg-if", "cfg_aliases 0.2.1", "libc", - "memoffset 0.9.1", + "memoffset", ] [[package]] @@ -2198,7 +2176,7 @@ dependencies = [ "cfg-if", "cfg_aliases 0.2.1", "libc", - "memoffset 0.9.1", + "memoffset", ] [[package]] @@ -2920,8 +2898,6 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ - "libc", - "rand_chacha 0.3.1", "rand_core 0.6.4", ] @@ -2931,20 +2907,10 @@ version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ - "rand_chacha 0.9.0", + "rand_chacha", "rand_core 0.9.5", ] -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", -] - [[package]] name = "rand_chacha" version = "0.9.0" @@ -2960,9 +2926,6 @@ name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] [[package]] name = "rand_core" @@ -3398,20 +3361,6 @@ dependencies = [ "libc", ] -[[package]] -name = "shared_memory" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba8593196da75d9dc4f69349682bd4c2099f8cde114257d1ef7ef1b33d1aba54" -dependencies = [ - "cfg-if", - "libc", - "log", - "nix 0.23.2", - "rand 0.8.5", - "win-sys", -] - [[package]] name = "shell-escape" version = "0.1.5" @@ -4753,15 +4702,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" -[[package]] -name = "win-sys" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b7b128a98c1cfa201b09eb49ba285887deb3cbe7466a98850eb1adabb452be5" -dependencies = [ - "windows", -] - [[package]] name = "winapi" version = "0.3.9" @@ -4818,19 +4758,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "windows" -version = "0.34.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45296b64204227616fdbf2614cefa4c236b98ee64dfaaaa435207ed99fe7829f" -dependencies = [ - "windows_aarch64_msvc 0.34.0", - "windows_i686_gnu 0.34.0", - "windows_i686_msvc 0.34.0", - "windows_x86_64_gnu 0.34.0", - "windows_x86_64_msvc 0.34.0", -] - [[package]] name = "windows-link" version = "0.2.1" @@ -4863,13 +4790,13 @@ checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ "windows-link", "windows_aarch64_gnullvm", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", + "windows_aarch64_msvc", + "windows_i686_gnu", "windows_i686_gnullvm", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", + "windows_i686_msvc", + "windows_x86_64_gnu", "windows_x86_64_gnullvm", - "windows_x86_64_msvc 0.53.1", + "windows_x86_64_msvc", ] [[package]] @@ -4878,24 +4805,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" -[[package]] -name = "windows_aarch64_msvc" -version = "0.34.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17cffbe740121affb56fad0fc0e421804adf0ae00891205213b5cecd30db881d" - [[package]] name = "windows_aarch64_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" -[[package]] -name = "windows_i686_gnu" -version = "0.34.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2564fde759adb79129d9b4f54be42b32c89970c18ebf93124ca8870a498688ed" - [[package]] name = "windows_i686_gnu" version = "0.53.1" @@ -4908,24 +4823,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" -[[package]] -name = "windows_i686_msvc" -version = "0.34.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cd9d32ba70453522332c14d38814bceeb747d80b3958676007acadd7e166956" - [[package]] name = "windows_i686_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" -[[package]] -name = "windows_x86_64_gnu" -version = "0.34.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfce6deae227ee8d356d19effc141a509cc503dfd1f850622ec4b0f84428e1f4" - [[package]] name = "windows_x86_64_gnu" version = "0.53.1" @@ -4938,12 +4841,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" -[[package]] -name = "windows_x86_64_msvc" -version = "0.34.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d19538ccc21819d01deaf88d6a17eae6596a12e9aafdbb97916fb49896d89de9" - [[package]] name = "windows_x86_64_msvc" version = "0.53.1" diff --git a/Cargo.toml b/Cargo.toml index c031ee5e..8c242060 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -123,7 +123,6 @@ serde = "1.0.219" serde_json = "1.0.140" serde_norway = "0.9.42" sha2 = "0.11.0" -shared_memory = "0.12.4" shell-escape = "0.1.5" similar = "3.0.0" smallvec = { version = "2.0.0-alpha.12", features = ["std"] } diff --git a/crates/fspy_shared/src/ipc/channel/mod.rs b/crates/fspy_shared/src/ipc/channel/mod.rs index d08c9529..ae34c8ca 100644 --- a/crates/fspy_shared/src/ipc/channel/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/mod.rs @@ -114,7 +114,7 @@ impl Deref for Sender { } #[cfg_attr( - not(target_os = "linux"), + 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" @@ -135,7 +135,7 @@ pub struct Receiver { } #[cfg_attr( - not(target_os = "linux"), + 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" diff --git a/crates/fspy_shm/Cargo.toml b/crates/fspy_shm/Cargo.toml index f05f4204..5ed62d81 100644 --- a/crates/fspy_shm/Cargo.toml +++ b/crates/fspy_shm/Cargo.toml @@ -7,9 +7,6 @@ license.workspace = true publish = false rust-version.workspace = true -[target.'cfg(not(any(target_os = "linux", target_os = "windows")))'.dependencies] -shared_memory = { workspace = true, features = ["logging"] } - [target.'cfg(target_os = "linux")'.dependencies] memmap2 = { workspace = true } passfd = { workspace = true, features = ["async"] } @@ -19,6 +16,12 @@ tokio-util = { workspace = true } tracing = { workspace = true } uuid = { workspace = true, features = ["v4"] } +[target.'cfg(target_os = "macos")'.dependencies] +base64 = { workspace = true } +memmap2 = { workspace = true } +rustix = { workspace = true, features = ["fs", "shm"] } +uuid = { workspace = true, features = ["v4"] } + [target.'cfg(target_os = "windows")'.dependencies] uuid = { workspace = true, features = ["v4"] } windows-sys = { workspace = true, features = [ diff --git a/crates/fspy_shm/README.md b/crates/fspy_shm/README.md index 37b3d048..c87ee546 100644 --- a/crates/fspy_shm/README.md +++ b/crates/fspy_shm/README.md @@ -34,6 +34,7 @@ The channel hides that difference with its lock file. [`ChannelConf::sender`](.. Each platform keeps its implementation rationale beside its source: - [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) -At this point in the stack, macOS still delegates mapping creation and opening to the [`shared_memory`](https://crates.io/crates/shared_memory) crate. +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. diff --git a/crates/fspy_shm/src/lib.rs b/crates/fspy_shm/src/lib.rs index f2ff97e9..cd8c806f 100644 --- a/crates/fspy_shm/src/lib.rs +++ b/crates/fspy_shm/src/lib.rs @@ -1,93 +1,16 @@ #![doc = include_str!("../README.md")] -#[cfg(not(any(target_os = "linux", target_os = "windows")))] -use std::io; - #[cfg(target_os = "linux")] -mod linux; +#[path = "linux/mod.rs"] +mod os_impl; +#[cfg(target_os = "macos")] +#[path = "macos/mod.rs"] +mod os_impl; #[cfg(target_os = "windows")] -mod windows; - -#[cfg(target_os = "linux")] -pub use linux::{Shm, create, open}; -#[cfg(not(any(target_os = "linux", target_os = "windows")))] -use shared_memory::{Shmem, ShmemConf}; -#[cfg(target_os = "windows")] -pub use windows::{Shm, create, open}; - -/// An owned shared-memory mapping. -#[cfg(not(any(target_os = "linux", target_os = "windows")))] -pub struct Shm { - inner: Shmem, -} - -/// Creates a shared-memory mapping of `size` bytes and returns its owner. -/// -/// Dropping the returned owner stops new [`open`] calls from being guaranteed -/// to succeed, while views that are already open stay usable (see the -/// [ownership semantics](crate)). -/// -/// # Errors -/// -/// Returns an error if the platform cannot create or map the region. -#[cfg(not(any(target_os = "linux", target_os = "windows")))] -pub fn create(size: usize) -> io::Result { - let conf = ShmemConf::new().size(size); - - let inner = conf.create().map_err(io::Error::other)?; - Ok(Shm { inner }) -} - -/// Opens a view of the shared-memory mapping identified by `id`. -/// -/// 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 mapping does not exist or cannot be mapped. -#[cfg(not(any(target_os = "linux", target_os = "windows")))] -pub fn open(id: &str) -> io::Result { - let conf = ShmemConf::new().os_id(id); - - let inner = conf.open().map_err(io::Error::other)?; - Ok(Shm { inner }) -} - -#[cfg(not(any(target_os = "linux", target_os = "windows")))] -#[expect(clippy::len_without_is_empty, reason = "shared-memory mappings are always non-empty")] -impl Shm { - /// Returns this mapping's opaque platform identifier. - #[must_use] - pub fn id(&self) -> &str { - self.inner.get_os_id() - } - - /// Returns the mapped length in bytes. - #[must_use] - pub fn len(&self) -> usize { - self.inner.len() - } +#[path = "windows/mod.rs"] +mod os_impl; - /// Returns a raw pointer to the first mapped byte. - #[must_use] - pub fn as_ptr(&self) -> *mut u8 { - self.inner.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 caller upholds the same synchronization contract required by `Shmem`. - unsafe { self.inner.as_slice() } - } -} +pub use os_impl::{Shm, create, open}; #[cfg(test)] mod tests { diff --git a/crates/fspy_shm/src/macos/README.md b/crates/fspy_shm/src/macos/README.md new file mode 100644 index 00000000..058db0ef --- /dev/null +++ b/crates/fspy_shm/src/macos/README.md @@ -0,0 +1,22 @@ +# 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 new file mode 100644 index 00000000..0e5f20b7 --- /dev/null +++ b/crates/fspy_shm/src/macos/mod.rs @@ -0,0 +1,158 @@ +#![doc = include_str!("README.md")] + +use std::{io, slice}; + +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use memmap2::{MmapOptions, MmapRaw}; +use rustix::{ + fs::{Mode, fstat, ftruncate}, + io::Errno, + shm::{self, OFlags}, +}; +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 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_u64 = u64::try_from(size).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidInput, "shared-memory size exceeds u64") + })?; + + loop { + let id = new_id(); + // `rustix::shm::open` sets `FD_CLOEXEC` on the returned descriptor. + let fd = match shm::open( + id.as_str(), + OFlags::CREATE | OFlags::EXCL | OFlags::RDWR, + Mode::RUSR | Mode::WUSR, + ) { + Ok(fd) => fd, + Err(Errno::EXIST) => continue, + Err(error) => return Err(error.into()), + }; + + if let Err(error) = ftruncate(&fd, size_u64) { + 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 { + // `rustix::shm::open` sets `FD_CLOEXEC` on the returned descriptor. + let fd = shm::open(id, OFlags::RDWR, Mode::empty()).map_err(io::Error::from)?; + // 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())) +} + +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); + } + } +}