From 2713207526fcf958b39038dfebf22c5159d5e899 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Thu, 9 Jul 2026 12:50:00 +0800 Subject: [PATCH 01/18] refactor(fspy): own macOS shared-memory mapping Co-authored-by: GPT-5.6 Co-Authored-By: Claude Fable 5 --- Cargo.lock | 120 +-------- Cargo.toml | 1 - crates/fspy_shared/src/ipc/channel/mod.rs | 4 +- crates/fspy_shm/Cargo.toml | 8 +- crates/fspy_shm/src/lib.rs | 84 +----- crates/fspy_shm/src/macos.rs | 306 ++++++++++++++++++++++ 6 files changed, 327 insertions(+), 196 deletions(-) create mode 100644 crates/fspy_shm/src/macos.rs diff --git a/Cargo.lock b/Cargo.lock index 7efdf6e3..c5a99575 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1392,7 +1392,6 @@ dependencies = [ "memmap2", "passfd", "rustix", - "shared_memory", "subprocess_test", "tokio", "tokio-util", @@ -2015,15 +2014,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 +2140,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 +2162,7 @@ dependencies = [ "cfg-if", "cfg_aliases 0.2.1", "libc", - "memoffset 0.9.1", + "memoffset", ] [[package]] @@ -2198,7 +2175,7 @@ dependencies = [ "cfg-if", "cfg_aliases 0.2.1", "libc", - "memoffset 0.9.1", + "memoffset", ] [[package]] @@ -2920,8 +2897,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 +2906,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 +2925,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 +3360,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 +4701,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 +4757,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 +4789,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 +4804,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 +4822,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 +4840,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..df571513 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,11 @@ tokio-util = { workspace = true } tracing = { workspace = true } uuid = { workspace = true, features = ["v4"] } +[target.'cfg(target_os = "macos")'.dependencies] +memmap2 = { workspace = true } +rustix = { workspace = true, features = ["fs", "param", "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/src/lib.rs b/crates/fspy_shm/src/lib.rs index f2ff97e9..03253179 100644 --- a/crates/fspy_shm/src/lib.rs +++ b/crates/fspy_shm/src/lib.rs @@ -1,93 +1,21 @@ #![doc = include_str!("../README.md")] -#[cfg(not(any(target_os = "linux", target_os = "windows")))] -use std::io; - #[cfg(target_os = "linux")] mod linux; +#[cfg(target_os = "macos")] +mod macos; #[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 = "macos")] +pub use macos::{Shm, create, open}; #[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() - } - - /// 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() } - } -} +#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] +compile_error!("fspy_shm does not support this platform"); #[cfg(test)] mod tests { diff --git a/crates/fspy_shm/src/macos.rs b/crates/fspy_shm/src/macos.rs new file mode 100644 index 00000000..2ac10b7e --- /dev/null +++ b/crates/fspy_shm/src/macos.rs @@ -0,0 +1,306 @@ +use std::{io, slice}; + +use memmap2::{MmapOptions, MmapRaw}; +use rustix::{ + fs::{Mode, fstat, ftruncate}, + io::Errno, + param::page_size, + shm::{self, OFlags}, +}; +use uuid::Uuid; + +const NAME_PREFIX: &str = "/fspy_"; +// Darwin rounds `st_size` to a VM page, so retain the exact-size residue in +// the name while leaving 80 random bits for collision resistance. +const NAME_RANDOM_BYTES: usize = 10; +const NAME_SIZE_BYTES: usize = 2; +const NAME_SUFFIX_LEN: usize = (NAME_RANDOM_BYTES + NAME_SIZE_BYTES) * 2; +const NAME_LEN: usize = NAME_PREFIX.len() + NAME_SUFFIX_LEN; +const SIZE_TAG_MODULUS: u64 = 1 << (NAME_SIZE_BYTES * 8); +const HEX: &[u8; 16] = b"0123456789abcdef"; + +/// An owned macOS shared-memory mapping. +pub struct Shm { + name: ShmName, + mapping: MmapRaw, +} + +/// 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 { + create_with(size, || new_id(size)) +} + +fn create_with(size: usize, mut next_id: impl FnMut() -> String) -> io::Result { + let size_u64 = valid_size(size)?; + + loop { + let id = next_id(); + validate_id(&id)?; + if !size_tag_matches(&id, size) { + return Err(invalid_id()); + } + 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()), + }; + // From this point on, every failure path unlinks the newly created name. + let owner = OwnerName { id }; + + ftruncate(&fd, size_u64).map_err(io::Error::from)?; + let mapping = MmapOptions::new().len(size).map_raw(&fd)?; + drop(fd); + + return Ok(Shm { name: ShmName::Owner(owner), mapping }); + } +} + +/// Opens the POSIX shared-memory mapping identified by `id`. +/// +/// # Errors +/// +/// Returns an error if the identifier is invalid, the object is unavailable, +/// or its size does not exactly match `size`. +pub fn open(id: &str, size: usize) -> io::Result { + let size_u64 = valid_size(size)?; + validate_id(id)?; + + let fd = shm::open(id, OFlags::RDWR, Mode::empty()).map_err(io::Error::from)?; + let stat = fstat(&fd).map_err(io::Error::from)?; + let actual_size = u64::try_from(stat.st_size).ok(); + if !size_tag_matches(id, size) + || (actual_size != Some(size_u64) && actual_size != rounded_size(size_u64)) + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "shared-memory object has an unexpected size", + )); + } + let mapping = MmapOptions::new().len(size).map_raw(&fd)?; + drop(fd); + + Ok(Shm { name: ShmName::Opened(id.to_owned()), mapping }) +} + +fn new_id(size: usize) -> String { + let uuid = Uuid::new_v4(); + let mut id = String::with_capacity(NAME_LEN); + id.push_str(NAME_PREFIX); + for byte in &uuid.as_bytes()[..NAME_RANDOM_BYTES] { + push_hex_byte(&mut id, *byte); + } + let size_bytes = size.to_be_bytes(); + for byte in &size_bytes[size_bytes.len() - NAME_SIZE_BYTES..] { + push_hex_byte(&mut id, *byte); + } + id +} + +fn push_hex_byte(output: &mut String, byte: u8) { + output.push(char::from(HEX[usize::from(byte >> 4)])); + output.push(char::from(HEX[usize::from(byte & 0x0f)])); +} + +fn size_tag_matches(id: &str, size: usize) -> bool { + let size_bytes = size.to_be_bytes(); + let expected = &size_bytes[size_bytes.len() - NAME_SIZE_BYTES..]; + let encoded = &id.as_bytes()[NAME_LEN - NAME_SIZE_BYTES * 2..]; + + encoded.chunks_exact(2).zip(expected).all(|(digits, byte)| { + digits[0] == HEX[usize::from(byte >> 4)] && digits[1] == HEX[usize::from(byte & 0x0f)] + }) +} + +fn rounded_size(size: u64) -> Option { + let page_size = u64::try_from(page_size()).ok()?; + if page_size == 0 || page_size > SIZE_TAG_MODULUS { + return None; + } + let remainder = size.checked_rem(page_size)?; + if remainder == 0 { Some(size) } else { size.checked_add(page_size - remainder) } +} + +fn validate_id(id: &str) -> io::Result<()> { + let Some(suffix) = id.strip_prefix(NAME_PREFIX) else { + return Err(invalid_id()); + }; + if id.len() != NAME_LEN + || !suffix.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(invalid_id()); + } + Ok(()) +} + +fn invalid_id() -> io::Error { + io::Error::new(io::ErrorKind::InvalidInput, "invalid macOS shared-memory identifier") +} + +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")) +} + +struct OwnerName { + id: String, +} + +impl Drop for OwnerName { + fn drop(&mut self) { + let _ = shm::unlink(self.id.as_str()); + } +} + +enum ShmName { + Owner(OwnerName), + Opened(String), +} + +impl ShmName { + fn as_str(&self) -> &str { + match self { + Self::Owner(owner) => &owner.id, + Self::Opened(id) => id, + } + } +} + +#[expect(clippy::len_without_is_empty, reason = "shared-memory mappings are always non-empty")] +impl Shm { + /// Returns this mapping's opaque POSIX shared-memory name. + #[must_use] + pub fn id(&self) -> &str { + self.name.as_str() + } + + /// 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::*; + + const SIZE: usize = 64 * 1024; + + #[test] + fn identifiers_are_canonical_darwin_names() { + let owner = create(SIZE).unwrap(); + let suffix = owner.id().strip_prefix(NAME_PREFIX).unwrap(); + + assert_eq!(owner.id().len(), NAME_LEN); + assert!(owner.id().len() <= 31); + assert_eq!(suffix.len(), NAME_SUFFIX_LEN); + assert!(size_tag_matches(owner.id(), SIZE)); + assert!(suffix.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))); + } + + #[test] + fn malformed_ids_and_inexact_sizes_are_rejected() { + for id in [ + "fspy_000000000000000000000000", + "/fspy_00000000000000000000000", + "/fspy_0000000000000000000000000", + "/fspy_00000000000000000000000g", + "/fspy_00000000000000000000000A", + "/fspy_00000000000/000000000000", + ] { + assert_eq!(open(id, SIZE).err().unwrap().kind(), io::ErrorKind::InvalidInput); + } + + let owner = create(SIZE).unwrap(); + assert_eq!(open(owner.id(), 0).err().unwrap().kind(), io::ErrorKind::InvalidInput); + assert_eq!(open(owner.id(), SIZE - 1).err().unwrap().kind(), io::ErrorKind::InvalidData); + assert_eq!(open(owner.id(), SIZE + 1).err().unwrap().kind(), io::ErrorKind::InvalidData); + } + + #[test] + fn non_page_aligned_size_reopens_exactly() { + let owner = create(100).unwrap(); + let opened = open(owner.id(), 100).unwrap(); + + assert_eq!(owner.len(), 100); + assert_eq!(opened.len(), 100); + } + + #[test] + fn name_collisions_are_retried() { + let existing = create(SIZE).unwrap(); + let fresh_id = loop { + let id = new_id(SIZE); + if id != existing.id() { + break id; + } + }; + let mut ids = [existing.id().to_owned(), fresh_id.clone()].into_iter(); + + let created = + create_with(SIZE, || ids.next().expect("creation did not stop after retry")).unwrap(); + + assert_eq!(created.id(), fresh_id); + } + + #[cfg(target_pointer_width = "64")] + #[test] + fn failed_initialization_unlinks_the_created_name() { + let id = new_id(usize::MAX); + + assert!(create_with(usize::MAX, || id.clone()).is_err()); + assert!(matches!(shm::open(id.as_str(), OFlags::RDWR, Mode::empty()), Err(Errno::NOENT))); + } + + #[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(), PRODUCTION_SIZE).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); + } + } +} From f3371fb45081216e3676f6e5116542de11545d88 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Fri, 10 Jul 2026 22:37:25 +0800 Subject: [PATCH 02/18] docs(fspy): explain macOS shared memory design Co-authored-by: GPT-5 Codex --- crates/fspy_shm/README.md | 5 +- crates/fspy_shm/src/macos/README.md | 88 +++++++++++++++++++ .../fspy_shm/src/{macos.rs => macos/mod.rs} | 2 + 3 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 crates/fspy_shm/src/macos/README.md rename crates/fspy_shm/src/{macos.rs => macos/mod.rs} (99%) diff --git a/crates/fspy_shm/README.md b/crates/fspy_shm/README.md index 37b3d048..0ebb3aaf 100644 --- a/crates/fspy_shm/README.md +++ b/crates/fspy_shm/README.md @@ -34,6 +34,9 @@ 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. +The implementations share the crate-level contract, not identifier formats or +native resources. Each platform README explains its operating-system choices +and why the previous `shared_memory` backend did not meet its requirements. diff --git a/crates/fspy_shm/src/macos/README.md b/crates/fspy_shm/src/macos/README.md new file mode 100644 index 00000000..f1ad0f24 --- /dev/null +++ b/crates/fspy_shm/src/macos/README.md @@ -0,0 +1,88 @@ +# macOS backend + +The macOS backend uses named POSIX shared-memory objects and maps them with +`mmap`. POSIX shared memory provides native string-based discovery without a +filesystem path or descriptor broker, and mapped reads and writes require no +syscalls. + +## Runtime flow + +`create(size)` performs these steps: + +1. Generate a name containing random bytes and a tag derived from the requested + size. +2. Validate the name against the restricted format used by this backend. +3. Call `shm_open` with create, exclusive, and read-write flags. Retry if the + random name already exists. +4. Install the owner guard immediately so any later initialization failure + unlinks the new name. +5. Set the object's logical size and map an exact-size shared view. +6. Close the descriptor. The mapped view keeps the memory object alive. + +`open(id, size)` validates the identifier, opens the POSIX object, verifies its +size, and maps exactly `size` bytes. It does not use the current directory or a +temporary-directory environment variable. + +## Requirements + +The backend must provide: + +- a string identifier that another process can open, +- demand-backed storage for a large logical mapping, +- ordinary memory and atomic operations for concurrent writers, +- exact logical-size validation, and +- POSIX unlink behavior where existing views survive owner cleanup but new + opens fail. + +## Options considered + +| Option | Decision | +| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------- | +| Anonymous `mmap` with inheritance | Rejected. Processes created later cannot recover the mapping from a string identifier. | +| Temporary file mapping | Rejected. A pathname, cleanup protocol, and possible disk writeback add no benefit over native POSIX shared memory. | +| Mach memory entry with port transfer | Rejected. It would require a Mach-port broker to turn the entry into a serializable identifier. | +| POSIX `shm_open` | Selected. It directly supplies naming, cross-process opening, demand-backed mapping, and unlink semantics. | + +## Why not `shared_memory` + +The `shared_memory` macOS backend uses the same POSIX primitives, but its open +path ignores the caller's expected size and reports `fstat().st_size` as the +mapping length. Darwin can report that size rounded to a virtual-memory page, +so the facade can expose more bytes than the logical channel capacity and +cannot reject nearby size mismatches. + +The local backend maps the requested logical size and validates both the +reported object size and a size tag stored in the identifier. The tag +distinguishes requested sizes that Darwin rounds to the same page, while the +reported size distinguishes larger differences. + +Owning this small backend also removes `shared_memory`'s general persistence, +ownership-transfer, logging, and compatibility surface from the dependency +graph. Fspy needs only named creation, opening, mapping, and owner unlink. + +## Identifier format + +Darwin limits portable POSIX shared-memory names to a short slash-prefixed +form. Identifiers use `/fspy_`, 80 random bits, and a 16-bit size tag, all +encoded as lowercase hexadecimal. The complete name remains within the Darwin +limit while retaining enough randomness for collision resistance. + +Creation still handles collisions correctly by retrying `shm_open` after +`EEXIST`. `open` accepts only the canonical prefix, length, and lowercase-hex +format generated by this module. + +## Lifetime + +The owner stores the POSIX name in an RAII guard. Dropping the owner calls +`shm_unlink`, which prevents new opens. Existing mappings remain valid until +each process unmaps its own view. + +The owner guard is created immediately after `shm_open`. If sizing or mapping +fails, stack unwinding unlinks the partially initialized object instead of +leaving a stale name. + +## Verification + +The module tests cover canonical identifiers, malformed names, exact and +non-page-aligned sizes, collision retry, cleanup after failed initialization, +cross-view endpoint access, and the production-size mapping. diff --git a/crates/fspy_shm/src/macos.rs b/crates/fspy_shm/src/macos/mod.rs similarity index 99% rename from crates/fspy_shm/src/macos.rs rename to crates/fspy_shm/src/macos/mod.rs index 2ac10b7e..9158850f 100644 --- a/crates/fspy_shm/src/macos.rs +++ b/crates/fspy_shm/src/macos/mod.rs @@ -1,3 +1,5 @@ +#![doc = include_str!("README.md")] + use std::{io, slice}; use memmap2::{MmapOptions, MmapRaw}; From 16ed6c801640cf9ceafb3aa086f7fb96e32a69b9 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Fri, 10 Jul 2026 22:45:46 +0800 Subject: [PATCH 03/18] docs(fspy): focus macOS docs on design choices Co-authored-by: GPT-5 Codex --- crates/fspy_shm/src/macos/README.md | 84 +++++++++-------------------- 1 file changed, 24 insertions(+), 60 deletions(-) diff --git a/crates/fspy_shm/src/macos/README.md b/crates/fspy_shm/src/macos/README.md index f1ad0f24..ab5fbdf1 100644 --- a/crates/fspy_shm/src/macos/README.md +++ b/crates/fspy_shm/src/macos/README.md @@ -1,38 +1,19 @@ # macOS backend -The macOS backend uses named POSIX shared-memory objects and maps them with -`mmap`. POSIX shared memory provides native string-based discovery without a -filesystem path or descriptor broker, and mapped reads and writes require no -syscalls. - -## Runtime flow - -`create(size)` performs these steps: - -1. Generate a name containing random bytes and a tag derived from the requested - size. -2. Validate the name against the restricted format used by this backend. -3. Call `shm_open` with create, exclusive, and read-write flags. Retry if the - random name already exists. -4. Install the owner guard immediately so any later initialization failure - unlinks the new name. -5. Set the object's logical size and map an exact-size shared view. -6. Close the descriptor. The mapped view keeps the memory object alive. - -`open(id, size)` validates the identifier, opens the POSIX object, verifies its -size, and maps exactly `size` bytes. It does not use the current directory or a -temporary-directory environment variable. +The macOS backend uses named POSIX shared memory. It provides string-based +discovery, demand-backed mapped storage, and POSIX unlink semantics without a +filesystem path or descriptor broker. ## Requirements The backend must provide: -- a string identifier that another process can open, +- a serialized identifier that another process can open, - demand-backed storage for a large logical mapping, -- ordinary memory and atomic operations for concurrent writers, +- memory and atomic operations for concurrent writers, - exact logical-size validation, and -- POSIX unlink behavior where existing views survive owner cleanup but new - opens fail. +- owner cleanup that preserves already-open views while preventing later + opens. ## Options considered @@ -40,49 +21,32 @@ The backend must provide: | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------- | | Anonymous `mmap` with inheritance | Rejected. Processes created later cannot recover the mapping from a string identifier. | | Temporary file mapping | Rejected. A pathname, cleanup protocol, and possible disk writeback add no benefit over native POSIX shared memory. | -| Mach memory entry with port transfer | Rejected. It would require a Mach-port broker to turn the entry into a serializable identifier. | +| Mach memory entry with port transfer | Rejected. It would require a Mach-port broker to turn the entry into a serialized identifier. | | POSIX `shm_open` | Selected. It directly supplies naming, cross-process opening, demand-backed mapping, and unlink semantics. | +Linux rejects POSIX `shm_open` because Linux normally routes it through the +container's size-limited `/dev/shm` mount. macOS does not have that mount-level +constraint, so a broker would add complexity without solving a macOS problem. + ## Why not `shared_memory` -The `shared_memory` macOS backend uses the same POSIX primitives, but its open +The `shared_memory` macOS backend uses the same POSIX primitive, but its open path ignores the caller's expected size and reports `fstat().st_size` as the mapping length. Darwin can report that size rounded to a virtual-memory page, so the facade can expose more bytes than the logical channel capacity and cannot reject nearby size mismatches. -The local backend maps the requested logical size and validates both the -reported object size and a size tag stored in the identifier. The tag -distinguishes requested sizes that Darwin rounds to the same page, while the -reported size distinguishes larger differences. - -Owning this small backend also removes `shared_memory`'s general persistence, -ownership-transfer, logging, and compatibility surface from the dependency -graph. Fspy needs only named creation, opening, mapping, and owner unlink. - -## Identifier format - -Darwin limits portable POSIX shared-memory names to a short slash-prefixed -form. Identifiers use `/fspy_`, 80 random bits, and a 16-bit size tag, all -encoded as lowercase hexadecimal. The complete name remains within the Darwin -limit while retaining enough randomness for collision resistance. - -Creation still handles collisions correctly by retrying `shm_open` after -`EEXIST`. `open` accepts only the canonical prefix, length, and lowercase-hex -format generated by this module. - -## Lifetime - -The owner stores the POSIX name in an RAII guard. Dropping the owner calls -`shm_unlink`, which prevents new opens. Existing mappings remain valid until -each process unmaps its own view. +The local backend maps the requested logical size. It validates the reported +object size and a compact size tag in the identifier, which distinguishes +logical sizes that Darwin rounds to the same page. -The owner guard is created immediately after `shm_open`. If sizing or mapping -fails, stack unwinding unlinks the partially initialized object instead of -leaving a stale name. +Owning this small backend also removes `shared_memory`'s persistence, +ownership-transfer, logging, and compatibility surface. Fspy needs only named +creation, opening, mapping, exact-size validation, and owner unlink. -## Verification +## Lifetime semantics -The module tests cover canonical identifiers, malformed names, exact and -non-page-aligned sizes, collision retry, cleanup after failed initialization, -cross-view endpoint access, and the production-size mapping. +Dropping the owner unlinks the POSIX name, preventing later opens. Existing +views remain valid until each process unmaps its own view. Initialization +failures after name creation also unlink the name rather than leaving a stale +shared-memory object. From df8dc9013fab7c49ee3040d8d1cce64f044c373b Mon Sep 17 00:00:00 2001 From: wan9chi Date: Fri, 10 Jul 2026 22:55:12 +0800 Subject: [PATCH 04/18] docs(fspy): tighten macOS backend decision record Co-authored-by: GPT-5 Codex --- crates/fspy_shm/src/macos/README.md | 55 ++++++++--------------------- 1 file changed, 15 insertions(+), 40 deletions(-) diff --git a/crates/fspy_shm/src/macos/README.md b/crates/fspy_shm/src/macos/README.md index ab5fbdf1..cfb263fa 100644 --- a/crates/fspy_shm/src/macos/README.md +++ b/crates/fspy_shm/src/macos/README.md @@ -1,52 +1,27 @@ # macOS backend -The macOS backend uses named POSIX shared memory. It provides string-based -discovery, demand-backed mapped storage, and POSIX unlink semantics without a -filesystem path or descriptor broker. - -## Requirements - -The backend must provide: - -- a serialized identifier that another process can open, -- demand-backed storage for a large logical mapping, -- memory and atomic operations for concurrent writers, -- exact logical-size validation, and -- owner cleanup that preserves already-open views while preventing later - opens. +The macOS backend uses named POSIX shared memory. It provides name-based opens, +demand-backed storage, and unlink-on-owner-drop semantics without a broker. ## Options considered -| Option | Decision | -| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------- | -| Anonymous `mmap` with inheritance | Rejected. Processes created later cannot recover the mapping from a string identifier. | -| Temporary file mapping | Rejected. A pathname, cleanup protocol, and possible disk writeback add no benefit over native POSIX shared memory. | -| Mach memory entry with port transfer | Rejected. It would require a Mach-port broker to turn the entry into a serialized identifier. | -| POSIX `shm_open` | Selected. It directly supplies naming, cross-process opening, demand-backed mapping, and unlink semantics. | +| Option | Decision | +| ------------------------------------ | --------------------------------------------------------------------------------------------------------- | +| System V shared memory | Rejected because kernel IPC limits affect availability and segment lifetime requires a separate protocol. | +| Sparse temporary file | Rejected because dirty pages may reach disk and the file needs a separate lifetime protocol. | +| Mach memory entry with port transfer | Rejected because name-based discovery would require a port broker. | +| POSIX shared memory | Selected. It provides naming, demand-backed storage, and unlink semantics directly. | -Linux rejects POSIX `shm_open` because Linux normally routes it through the -container's size-limited `/dev/shm` mount. macOS does not have that mount-level -constraint, so a broker would add complexity without solving a macOS problem. +Unlike Linux, macOS does not route POSIX shared memory through a container's +`/dev/shm` mount. ## Why not `shared_memory` -The `shared_memory` macOS backend uses the same POSIX primitive, but its open -path ignores the caller's expected size and reports `fstat().st_size` as the -mapping length. Darwin can report that size rounded to a virtual-memory page, -so the facade can expose more bytes than the logical channel capacity and -cannot reject nearby size mismatches. - -The local backend maps the requested logical size. It validates the reported -object size and a compact size tag in the identifier, which distinguishes -logical sizes that Darwin rounds to the same page. - -Owning this small backend also removes `shared_memory`'s persistence, -ownership-transfer, logging, and compatibility surface. Fspy needs only named -creation, opening, mapping, exact-size validation, and owner unlink. +`shared_memory` opens the size reported by `fstat` instead of validating the +caller's expected size. Darwin rounds the object size to a VM page, so that API +cannot preserve fspy's exact logical-size contract. ## Lifetime semantics -Dropping the owner unlinks the POSIX name, preventing later opens. Existing -views remain valid until each process unmaps its own view. Initialization -failures after name creation also unlink the name rather than leaving a stale -shared-memory object. +Dropping the owner unlinks the POSIX name. Existing views remain valid; later +opens fail. From 577874ceb3ed54fc6c1ad8252a2487201c6b038c Mon Sep 17 00:00:00 2001 From: wan9chi Date: Fri, 10 Jul 2026 23:14:58 +0800 Subject: [PATCH 05/18] refactor(fspy): simplify macOS shared memory Co-authored-by: GPT-5 Codex --- Cargo.lock | 1 + crates/fspy_shm/Cargo.toml | 3 +- crates/fspy_shm/src/macos/README.md | 3 +- crates/fspy_shm/src/macos/mod.rs | 235 ++++++---------------------- 4 files changed, 50 insertions(+), 192 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c5a99575..10f4e533 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1388,6 +1388,7 @@ dependencies = [ name = "fspy_shm" version = "0.0.0" dependencies = [ + "base64", "ctor", "memmap2", "passfd", diff --git a/crates/fspy_shm/Cargo.toml b/crates/fspy_shm/Cargo.toml index df571513..5ed62d81 100644 --- a/crates/fspy_shm/Cargo.toml +++ b/crates/fspy_shm/Cargo.toml @@ -17,8 +17,9 @@ 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", "param", "shm"] } +rustix = { workspace = true, features = ["fs", "shm"] } uuid = { workspace = true, features = ["v4"] } [target.'cfg(target_os = "windows")'.dependencies] diff --git a/crates/fspy_shm/src/macos/README.md b/crates/fspy_shm/src/macos/README.md index cfb263fa..44f4d2ab 100644 --- a/crates/fspy_shm/src/macos/README.md +++ b/crates/fspy_shm/src/macos/README.md @@ -19,7 +19,8 @@ Unlike Linux, macOS does not route POSIX shared memory through a container's `shared_memory` opens the size reported by `fstat` instead of validating the caller's expected size. Darwin rounds the object size to a VM page, so that API -cannot preserve fspy's exact logical-size contract. +cannot preserve fspy's exact logical-size contract. Fspy includes the logical +size in the native name instead. ## Lifetime semantics diff --git a/crates/fspy_shm/src/macos/mod.rs b/crates/fspy_shm/src/macos/mod.rs index 9158850f..37d1de97 100644 --- a/crates/fspy_shm/src/macos/mod.rs +++ b/crates/fspy_shm/src/macos/mod.rs @@ -2,29 +2,23 @@ use std::{io, slice}; +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; use memmap2::{MmapOptions, MmapRaw}; use rustix::{ - fs::{Mode, fstat, ftruncate}, + fs::{Mode, ftruncate}, io::Errno, - param::page_size, shm::{self, OFlags}, }; use uuid::Uuid; const NAME_PREFIX: &str = "/fspy_"; -// Darwin rounds `st_size` to a VM page, so retain the exact-size residue in -// the name while leaving 80 random bits for collision resistance. -const NAME_RANDOM_BYTES: usize = 10; -const NAME_SIZE_BYTES: usize = 2; -const NAME_SUFFIX_LEN: usize = (NAME_RANDOM_BYTES + NAME_SIZE_BYTES) * 2; -const NAME_LEN: usize = NAME_PREFIX.len() + NAME_SUFFIX_LEN; -const SIZE_TAG_MODULUS: u64 = 1 << (NAME_SIZE_BYTES * 8); -const HEX: &[u8; 16] = b"0123456789abcdef"; +const ID_BYTES: usize = 9; /// An owned macOS shared-memory mapping. pub struct Shm { - name: ShmName, + id: String, mapping: MmapRaw, + owner_name: Option, } /// Creates a POSIX shared-memory mapping of `size` bytes and returns its @@ -34,20 +28,21 @@ pub struct Shm { /// /// Returns an error if the object cannot be created, sized, or mapped. pub fn create(size: usize) -> io::Result { - create_with(size, || new_id(size)) -} - -fn create_with(size: usize, mut next_id: impl FnMut() -> String) -> io::Result { - let size_u64 = valid_size(size)?; + 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 = next_id(); - validate_id(&id)?; - if !size_tag_matches(&id, size) { - return Err(invalid_id()); - } + let id = new_id(); + let name = mapping_name(&id, size); let fd = match shm::open( - id.as_str(), + name.as_str(), OFlags::CREATE | OFlags::EXCL | OFlags::RDWR, Mode::RUSR | Mode::WUSR, ) { @@ -55,14 +50,20 @@ fn create_with(size: usize, mut next_id: impl FnMut() -> String) -> io::Result continue, Err(error) => return Err(error.into()), }; - // From this point on, every failure path unlinks the newly created name. - let owner = OwnerName { id }; - ftruncate(&fd, size_u64).map_err(io::Error::from)?; - let mapping = MmapOptions::new().len(size).map_raw(&fd)?; - drop(fd); + if let Err(error) = ftruncate(&fd, size_u64) { + let _ = shm::unlink(name.as_str()); + return Err(error.into()); + } + let mapping = match MmapOptions::new().len(size).map_raw(&fd) { + Ok(mapping) => mapping, + Err(error) => { + let _ = shm::unlink(name.as_str()); + return Err(error); + } + }; - return Ok(Shm { name: ShmName::Owner(owner), mapping }); + return Ok(Shm { id, mapping, owner_name: Some(name) }); } } @@ -70,124 +71,38 @@ fn create_with(size: usize, mut next_id: impl FnMut() -> String) -> io::Result io::Result { - let size_u64 = valid_size(size)?; - validate_id(id)?; - - let fd = shm::open(id, OFlags::RDWR, Mode::empty()).map_err(io::Error::from)?; - let stat = fstat(&fd).map_err(io::Error::from)?; - let actual_size = u64::try_from(stat.st_size).ok(); - if !size_tag_matches(id, size) - || (actual_size != Some(size_u64) && actual_size != rounded_size(size_u64)) - { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "shared-memory object has an unexpected size", - )); - } + let name = mapping_name(id, size); + let fd = shm::open(name.as_str(), OFlags::RDWR, Mode::empty()).map_err(io::Error::from)?; let mapping = MmapOptions::new().len(size).map_raw(&fd)?; - drop(fd); - Ok(Shm { name: ShmName::Opened(id.to_owned()), mapping }) + Ok(Shm { id: id.to_owned(), mapping, owner_name: None }) } -fn new_id(size: usize) -> String { +fn new_id() -> String { let uuid = Uuid::new_v4(); - let mut id = String::with_capacity(NAME_LEN); - id.push_str(NAME_PREFIX); - for byte in &uuid.as_bytes()[..NAME_RANDOM_BYTES] { - push_hex_byte(&mut id, *byte); - } - let size_bytes = size.to_be_bytes(); - for byte in &size_bytes[size_bytes.len() - NAME_SIZE_BYTES..] { - push_hex_byte(&mut id, *byte); - } - id -} - -fn push_hex_byte(output: &mut String, byte: u8) { - output.push(char::from(HEX[usize::from(byte >> 4)])); - output.push(char::from(HEX[usize::from(byte & 0x0f)])); -} - -fn size_tag_matches(id: &str, size: usize) -> bool { - let size_bytes = size.to_be_bytes(); - let expected = &size_bytes[size_bytes.len() - NAME_SIZE_BYTES..]; - let encoded = &id.as_bytes()[NAME_LEN - NAME_SIZE_BYTES * 2..]; - - encoded.chunks_exact(2).zip(expected).all(|(digits, byte)| { - digits[0] == HEX[usize::from(byte >> 4)] && digits[1] == HEX[usize::from(byte & 0x0f)] - }) -} - -fn rounded_size(size: u64) -> Option { - let page_size = u64::try_from(page_size()).ok()?; - if page_size == 0 || page_size > SIZE_TAG_MODULUS { - return None; - } - let remainder = size.checked_rem(page_size)?; - if remainder == 0 { Some(size) } else { size.checked_add(page_size - remainder) } -} - -fn validate_id(id: &str) -> io::Result<()> { - let Some(suffix) = id.strip_prefix(NAME_PREFIX) else { - return Err(invalid_id()); - }; - if id.len() != NAME_LEN - || !suffix.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) - { - return Err(invalid_id()); - } - Ok(()) -} - -fn invalid_id() -> io::Error { - io::Error::new(io::ErrorKind::InvalidInput, "invalid macOS shared-memory identifier") -} - -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")) + URL_SAFE_NO_PAD.encode(&uuid.as_bytes()[..ID_BYTES]) } -struct OwnerName { - id: String, +fn mapping_name(id: &str, size: usize) -> String { + format!("{NAME_PREFIX}{id}_{}", URL_SAFE_NO_PAD.encode(size.to_be_bytes())) } -impl Drop for OwnerName { +impl Drop for Shm { fn drop(&mut self) { - let _ = shm::unlink(self.id.as_str()); - } -} - -enum ShmName { - Owner(OwnerName), - Opened(String), -} - -impl ShmName { - fn as_str(&self) -> &str { - match self { - Self::Owner(owner) => &owner.id, - Self::Opened(id) => id, + if let Some(name) = &self.owner_name { + let _ = shm::unlink(name.as_str()); } } } #[expect(clippy::len_without_is_empty, reason = "shared-memory mappings are always non-empty")] impl Shm { - /// Returns this mapping's opaque POSIX shared-memory name. + /// Returns this mapping's opaque macOS identifier. #[must_use] pub fn id(&self) -> &str { - self.name.as_str() + &self.id } /// Returns the mapped length in bytes. @@ -220,72 +135,12 @@ impl Shm { mod tests { use super::*; - const SIZE: usize = 64 * 1024; - - #[test] - fn identifiers_are_canonical_darwin_names() { - let owner = create(SIZE).unwrap(); - let suffix = owner.id().strip_prefix(NAME_PREFIX).unwrap(); - - assert_eq!(owner.id().len(), NAME_LEN); - assert!(owner.id().len() <= 31); - assert_eq!(suffix.len(), NAME_SUFFIX_LEN); - assert!(size_tag_matches(owner.id(), SIZE)); - assert!(suffix.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))); - } - #[test] - fn malformed_ids_and_inexact_sizes_are_rejected() { - for id in [ - "fspy_000000000000000000000000", - "/fspy_00000000000000000000000", - "/fspy_0000000000000000000000000", - "/fspy_00000000000000000000000g", - "/fspy_00000000000000000000000A", - "/fspy_00000000000/000000000000", - ] { - assert_eq!(open(id, SIZE).err().unwrap().kind(), io::ErrorKind::InvalidInput); - } - - let owner = create(SIZE).unwrap(); - assert_eq!(open(owner.id(), 0).err().unwrap().kind(), io::ErrorKind::InvalidInput); - assert_eq!(open(owner.id(), SIZE - 1).err().unwrap().kind(), io::ErrorKind::InvalidData); - assert_eq!(open(owner.id(), SIZE + 1).err().unwrap().kind(), io::ErrorKind::InvalidData); - } - - #[test] - fn non_page_aligned_size_reopens_exactly() { + fn size_mismatches_are_rejected() { let owner = create(100).unwrap(); - let opened = open(owner.id(), 100).unwrap(); - - assert_eq!(owner.len(), 100); - assert_eq!(opened.len(), 100); - } - - #[test] - fn name_collisions_are_retried() { - let existing = create(SIZE).unwrap(); - let fresh_id = loop { - let id = new_id(SIZE); - if id != existing.id() { - break id; - } - }; - let mut ids = [existing.id().to_owned(), fresh_id.clone()].into_iter(); - - let created = - create_with(SIZE, || ids.next().expect("creation did not stop after retry")).unwrap(); - - assert_eq!(created.id(), fresh_id); - } - - #[cfg(target_pointer_width = "64")] - #[test] - fn failed_initialization_unlinks_the_created_name() { - let id = new_id(usize::MAX); - assert!(create_with(usize::MAX, || id.clone()).is_err()); - assert!(matches!(shm::open(id.as_str(), OFlags::RDWR, Mode::empty()), Err(Errno::NOENT))); + assert!(open(owner.id(), 99).is_err()); + assert!(open(owner.id(), 101).is_err()); } #[cfg(target_pointer_width = "64")] From a4c6f38e85d2d8ba76985d27c1b7c1fda9c71431 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Fri, 10 Jul 2026 23:26:51 +0800 Subject: [PATCH 06/18] refactor(fspy): use macOS shared memory name as id Co-authored-by: GPT-5 Codex --- crates/fspy_shm/src/macos/README.md | 7 +++--- crates/fspy_shm/src/macos/mod.rs | 36 ++++++++--------------------- 2 files changed, 13 insertions(+), 30 deletions(-) diff --git a/crates/fspy_shm/src/macos/README.md b/crates/fspy_shm/src/macos/README.md index 44f4d2ab..64a89c8a 100644 --- a/crates/fspy_shm/src/macos/README.md +++ b/crates/fspy_shm/src/macos/README.md @@ -17,10 +17,9 @@ Unlike Linux, macOS does not route POSIX shared memory through a container's ## Why not `shared_memory` -`shared_memory` opens the size reported by `fstat` instead of validating the -caller's expected size. Darwin rounds the object size to a VM page, so that API -cannot preserve fspy's exact logical-size contract. Fspy includes the logical -size in the native name instead. +`shared_memory` uses the same POSIX primitives. Keeping it only for macOS would +retain its general-purpose persistence and ownership layer for operations this +backend performs directly. ## Lifetime semantics diff --git a/crates/fspy_shm/src/macos/mod.rs b/crates/fspy_shm/src/macos/mod.rs index 37d1de97..cc4d7a29 100644 --- a/crates/fspy_shm/src/macos/mod.rs +++ b/crates/fspy_shm/src/macos/mod.rs @@ -12,13 +12,12 @@ use rustix::{ use uuid::Uuid; const NAME_PREFIX: &str = "/fspy_"; -const ID_BYTES: usize = 9; /// An owned macOS shared-memory mapping. pub struct Shm { id: String, mapping: MmapRaw, - owner_name: Option, + owner: bool, } /// Creates a POSIX shared-memory mapping of `size` bytes and returns its @@ -40,9 +39,8 @@ pub fn create(size: usize) -> io::Result { loop { let id = new_id(); - let name = mapping_name(&id, size); let fd = match shm::open( - name.as_str(), + id.as_str(), OFlags::CREATE | OFlags::EXCL | OFlags::RDWR, Mode::RUSR | Mode::WUSR, ) { @@ -52,18 +50,18 @@ pub fn create(size: usize) -> io::Result { }; if let Err(error) = ftruncate(&fd, size_u64) { - let _ = shm::unlink(name.as_str()); + 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(name.as_str()); + let _ = shm::unlink(id.as_str()); return Err(error); } }; - return Ok(Shm { id, mapping, owner_name: Some(name) }); + return Ok(Shm { id, mapping, owner: true }); } } @@ -73,26 +71,20 @@ pub fn create(size: usize) -> io::Result { /// /// Returns an error if the mapping is unavailable. pub fn open(id: &str, size: usize) -> io::Result { - let name = mapping_name(id, size); - let fd = shm::open(name.as_str(), OFlags::RDWR, Mode::empty()).map_err(io::Error::from)?; + let fd = shm::open(id, OFlags::RDWR, Mode::empty()).map_err(io::Error::from)?; let mapping = MmapOptions::new().len(size).map_raw(&fd)?; - Ok(Shm { id: id.to_owned(), mapping, owner_name: None }) + Ok(Shm { id: id.to_owned(), mapping, owner: false }) } fn new_id() -> String { - let uuid = Uuid::new_v4(); - URL_SAFE_NO_PAD.encode(&uuid.as_bytes()[..ID_BYTES]) -} - -fn mapping_name(id: &str, size: usize) -> String { - format!("{NAME_PREFIX}{id}_{}", URL_SAFE_NO_PAD.encode(size.to_be_bytes())) + format!("{NAME_PREFIX}{}", URL_SAFE_NO_PAD.encode(Uuid::new_v4().as_bytes())) } impl Drop for Shm { fn drop(&mut self) { - if let Some(name) = &self.owner_name { - let _ = shm::unlink(name.as_str()); + if self.owner { + let _ = shm::unlink(self.id.as_str()); } } } @@ -135,14 +127,6 @@ impl Shm { mod tests { use super::*; - #[test] - fn size_mismatches_are_rejected() { - let owner = create(100).unwrap(); - - assert!(open(owner.id(), 99).is_err()); - assert!(open(owner.id(), 101).is_err()); - } - #[cfg(target_pointer_width = "64")] #[test] fn four_gib_mapping_supports_endpoint_access() { From 726e242e646cd51042cabb43cdf9674970a17de7 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Fri, 10 Jul 2026 23:31:58 +0800 Subject: [PATCH 07/18] docs(fspy): note macOS descriptors are close-on-exec Co-authored-by: GPT-5 Codex --- crates/fspy_shm/src/macos/mod.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/fspy_shm/src/macos/mod.rs b/crates/fspy_shm/src/macos/mod.rs index cc4d7a29..029da6ba 100644 --- a/crates/fspy_shm/src/macos/mod.rs +++ b/crates/fspy_shm/src/macos/mod.rs @@ -39,6 +39,7 @@ pub fn create(size: usize) -> io::Result { 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, @@ -71,6 +72,7 @@ pub fn create(size: usize) -> io::Result { /// /// Returns an error if the mapping is unavailable. pub fn open(id: &str, size: usize) -> 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)?; let mapping = MmapOptions::new().len(size).map_raw(&fd)?; From eb2bd71cc0fb85ddaf4fe7daa35d32b7b58b8c26 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Fri, 10 Jul 2026 23:50:02 +0800 Subject: [PATCH 08/18] docs(fspy): use direct macOS shared memory wording Co-authored-by: GPT-5 Codex --- crates/fspy_shm/README.md | 6 +++--- crates/fspy_shm/src/macos/README.md | 22 +++++++++++----------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/crates/fspy_shm/README.md b/crates/fspy_shm/README.md index 0ebb3aaf..e0a102c5 100644 --- a/crates/fspy_shm/README.md +++ b/crates/fspy_shm/README.md @@ -37,6 +37,6 @@ Each platform keeps its implementation rationale beside its source: - [macOS: named POSIX shared memory](src/macos/README.md) - [Windows: sparse file-backed named mapping](src/windows/README.md) -The implementations share the crate-level contract, not identifier formats or -native resources. Each platform README explains its operating-system choices -and why the previous `shared_memory` backend did not meet its requirements. +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/macos/README.md b/crates/fspy_shm/src/macos/README.md index 64a89c8a..b84f3879 100644 --- a/crates/fspy_shm/src/macos/README.md +++ b/crates/fspy_shm/src/macos/README.md @@ -1,25 +1,25 @@ # macOS backend -The macOS backend uses named POSIX shared memory. It provides name-based opens, -demand-backed storage, and unlink-on-owner-drop semantics without a broker. +The macOS backend uses named POSIX shared memory. Another process opens the +same bytes using the POSIX name. Pages are allocated as they are accessed, and +dropping the owner removes the name. No broker is required. ## Options considered -| Option | Decision | -| ------------------------------------ | --------------------------------------------------------------------------------------------------------- | -| System V shared memory | Rejected because kernel IPC limits affect availability and segment lifetime requires a separate protocol. | -| Sparse temporary file | Rejected because dirty pages may reach disk and the file needs a separate lifetime protocol. | -| Mach memory entry with port transfer | Rejected because name-based discovery would require a port broker. | -| POSIX shared memory | Selected. It provides naming, demand-backed storage, and unlink semantics directly. | +| 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, which requires a broker. | +| 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 primitives. Keeping it only for macOS would -retain its general-purpose persistence and ownership layer for operations this -backend performs directly. +`shared_memory` calls the same POSIX APIs. Fspy only needs to create, open, +map, and unlink shared memory, so the backend calls those APIs directly. ## Lifetime semantics From d55b32eb9ec080783f4ca6f64affd5c29351f2f0 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sat, 11 Jul 2026 06:19:22 +0800 Subject: [PATCH 09/18] docs(fspy): remove hard wraps from macOS READMEs Co-authored-by: GPT-5 Codex --- crates/fspy_shm/README.md | 4 +--- crates/fspy_shm/src/macos/README.md | 13 ++++--------- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/crates/fspy_shm/README.md b/crates/fspy_shm/README.md index e0a102c5..c87ee546 100644 --- a/crates/fspy_shm/README.md +++ b/crates/fspy_shm/README.md @@ -37,6 +37,4 @@ Each platform keeps its implementation rationale beside its source: - [macOS: named POSIX shared memory](src/macos/README.md) - [Windows: sparse file-backed named mapping](src/windows/README.md) -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. +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/macos/README.md b/crates/fspy_shm/src/macos/README.md index b84f3879..83533cd2 100644 --- a/crates/fspy_shm/src/macos/README.md +++ b/crates/fspy_shm/src/macos/README.md @@ -1,8 +1,6 @@ # macOS backend -The macOS backend uses named POSIX shared memory. Another process opens the -same bytes using the POSIX name. Pages are allocated as they are accessed, and -dropping the owner removes the name. No broker is required. +The macOS backend uses named POSIX shared memory. Another process opens the same bytes using the POSIX name. Pages are allocated as they are accessed, and dropping the owner removes the name. No broker is required. ## Options considered @@ -13,15 +11,12 @@ dropping the owner removes the name. No broker is required. | Mach memory entry with port transfer | Rejected because another process can receive the memory entry only through a Mach port, which requires a broker. | | 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. +Unlike Linux, macOS does not route POSIX shared memory through a container's `/dev/shm` mount. ## Why not `shared_memory` -`shared_memory` calls the same POSIX APIs. Fspy only needs to create, open, -map, and unlink shared memory, so the backend calls those APIs directly. +`shared_memory` calls the same POSIX APIs. Fspy only needs to create, open, map, and unlink shared memory, so the backend calls those APIs directly. ## Lifetime semantics -Dropping the owner unlinks the POSIX name. Existing views remain valid; later -opens fail. +Dropping the owner unlinks the POSIX name. Existing views remain valid; later opens fail. From 17e6a2f4162e242a514a6d945b0537b820f039f0 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sat, 11 Jul 2026 06:28:49 +0800 Subject: [PATCH 10/18] refactor(fspy): share platform module name Co-authored-by: GPT-5 Codex --- crates/fspy_shm/src/lib.rs | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/crates/fspy_shm/src/lib.rs b/crates/fspy_shm/src/lib.rs index 03253179..622b8cd8 100644 --- a/crates/fspy_shm/src/lib.rs +++ b/crates/fspy_shm/src/lib.rs @@ -1,18 +1,17 @@ #![doc = include_str!("../README.md")] #[cfg(target_os = "linux")] -mod linux; +#[path = "linux/mod.rs"] +mod os_impl; #[cfg(target_os = "macos")] -mod macos; +#[path = "macos/mod.rs"] +mod os_impl; #[cfg(target_os = "windows")] -mod windows; +#[path = "windows/mod.rs"] +mod os_impl; -#[cfg(target_os = "linux")] -pub use linux::{Shm, create, open}; -#[cfg(target_os = "macos")] -pub use macos::{Shm, create, open}; -#[cfg(target_os = "windows")] -pub use windows::{Shm, create, open}; +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] +pub use os_impl::{Shm, create, open}; #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] compile_error!("fspy_shm does not support this platform"); From 3f8582783172ea8a0e36c92d62e7a19cdb5fd43e Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sat, 11 Jul 2026 06:30:42 +0800 Subject: [PATCH 11/18] refactor(fspy): remove redundant platform guards Co-authored-by: GPT-5 Codex --- crates/fspy_shm/src/lib.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/crates/fspy_shm/src/lib.rs b/crates/fspy_shm/src/lib.rs index 622b8cd8..cd8c806f 100644 --- a/crates/fspy_shm/src/lib.rs +++ b/crates/fspy_shm/src/lib.rs @@ -10,12 +10,8 @@ mod os_impl; #[path = "windows/mod.rs"] mod os_impl; -#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] pub use os_impl::{Shm, create, open}; -#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] -compile_error!("fspy_shm does not support this platform"); - #[cfg(test)] mod tests { use std::{mem::align_of, process::Command}; From 4f888007b4caed14c4f837baa855b7ca4f10096c Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sat, 11 Jul 2026 06:32:51 +0800 Subject: [PATCH 12/18] docs(fspy): clarify macOS shared memory rationale Co-authored-by: GPT-5 Codex --- crates/fspy_shm/src/macos/README.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/fspy_shm/src/macos/README.md b/crates/fspy_shm/src/macos/README.md index 83533cd2..9fbc50d1 100644 --- a/crates/fspy_shm/src/macos/README.md +++ b/crates/fspy_shm/src/macos/README.md @@ -1,21 +1,21 @@ # macOS backend -The macOS backend uses named POSIX shared memory. Another process opens the same bytes using the POSIX name. Pages are allocated as they are accessed, and dropping the owner removes the name. No broker is required. +The macOS backend uses named POSIX shared memory. Another process opens the same bytes using the POSIX name. Pages are allocated as they are accessed, and dropping the owner removes the name. Processes open the object directly by name, so fspy does not need a separate service to pass file descriptors between them. ## 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, which requires a broker. | -| POSIX shared memory | Selected. Another process can open it by name, pages are allocated as accessed, and `shm_unlink` removes the name. | +| 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` calls the same POSIX APIs. Fspy only needs to create, open, map, and unlink shared memory, so the backend calls those APIs directly. +`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 From 0d96f375fc0660821c56d528308c3af41d26bb3a Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sat, 11 Jul 2026 06:55:45 +0800 Subject: [PATCH 13/18] refactor(fspy): derive macOS shared memory size Co-authored-by: GPT-5 Codex --- crates/fspy_shm/src/macos/README.md | 2 +- crates/fspy_shm/src/macos/mod.rs | 12 +++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/crates/fspy_shm/src/macos/README.md b/crates/fspy_shm/src/macos/README.md index 9fbc50d1..08642bda 100644 --- a/crates/fspy_shm/src/macos/README.md +++ b/crates/fspy_shm/src/macos/README.md @@ -1,6 +1,6 @@ # macOS backend -The macOS backend uses named POSIX shared memory. Another process opens the same bytes using the POSIX name. Pages are allocated as they are accessed, and dropping the owner removes the name. Processes open the object directly by name, so fspy does not need a separate service to pass file descriptors between them. +The macOS backend uses named POSIX shared memory. Another process opens the object by name, reads its current size from the descriptor, and maps the complete object. 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 diff --git a/crates/fspy_shm/src/macos/mod.rs b/crates/fspy_shm/src/macos/mod.rs index 029da6ba..e1f83e25 100644 --- a/crates/fspy_shm/src/macos/mod.rs +++ b/crates/fspy_shm/src/macos/mod.rs @@ -5,7 +5,7 @@ use std::{io, slice}; use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; use memmap2::{MmapOptions, MmapRaw}; use rustix::{ - fs::{Mode, ftruncate}, + fs::{Mode, fstat, ftruncate}, io::Errno, shm::{self, OFlags}, }; @@ -71,9 +71,15 @@ pub fn create(size: usize) -> io::Result { /// # Errors /// /// Returns an error if the mapping is unavailable. -pub fn open(id: &str, size: usize) -> io::Result { +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)?; + 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 }) @@ -135,7 +141,7 @@ mod tests { const PRODUCTION_SIZE: usize = 4 * 1024 * 1024 * 1024; let owner = create(PRODUCTION_SIZE).unwrap(); - let opened = open(owner.id(), 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. From 225d1f8e084e6454f59a482a2f1e88ffdcb08daf Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sat, 11 Jul 2026 07:04:10 +0800 Subject: [PATCH 14/18] docs(fspy): omit macOS mapping mechanics Co-authored-by: GPT-5 Codex --- crates/fspy_shm/src/macos/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/fspy_shm/src/macos/README.md b/crates/fspy_shm/src/macos/README.md index 08642bda..058db0ef 100644 --- a/crates/fspy_shm/src/macos/README.md +++ b/crates/fspy_shm/src/macos/README.md @@ -1,6 +1,6 @@ # macOS backend -The macOS backend uses named POSIX shared memory. Another process opens the object by name, reads its current size from the descriptor, and maps the complete object. 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. +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 From 894b9b3ebf9f6c3f4bfad3942c598e34c3b845d9 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sat, 11 Jul 2026 07:16:09 +0800 Subject: [PATCH 15/18] docs(fspy): explain macOS shared memory choice Co-authored-by: GPT-5 Codex --- crates/fspy_shm/src/macos/mod.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/fspy_shm/src/macos/mod.rs b/crates/fspy_shm/src/macos/mod.rs index e1f83e25..72291ec9 100644 --- a/crates/fspy_shm/src/macos/mod.rs +++ b/crates/fspy_shm/src/macos/mod.rs @@ -72,8 +72,13 @@ pub fn create(size: usize) -> io::Result { /// /// Returns an error if the mapping is unavailable. pub fn open(id: &str) -> io::Result { + // A Mach named-memory handle is a task-local port right that another + // process can use only after receiving it over IPC. POSIX shared memory + // keeps this operation addressable by a string without a transfer service. // `rustix::shm::open` sets `FD_CLOEXEC` on the returned descriptor. let fd = shm::open(id, OFlags::RDWR, Mode::empty()).map_err(io::Error::from)?; + // The owner sets the size before publishing `id`, and fspy never resizes + // the object afterward, so it stays fixed across `fstat` and `mmap`. let stat = fstat(&fd)?; let size = usize::try_from(stat.st_size) .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid shared-memory size"))?; From 8de5b1cfefb25d584c4b1951a48394fe4e40d36c Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sat, 11 Jul 2026 07:18:47 +0800 Subject: [PATCH 16/18] docs(fspy): clarify macOS resize race Co-authored-by: GPT-5 Codex --- crates/fspy_shm/src/macos/mod.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/fspy_shm/src/macos/mod.rs b/crates/fspy_shm/src/macos/mod.rs index 72291ec9..d67e406d 100644 --- a/crates/fspy_shm/src/macos/mod.rs +++ b/crates/fspy_shm/src/macos/mod.rs @@ -72,13 +72,14 @@ pub fn create(size: usize) -> io::Result { /// /// Returns an error if the mapping is unavailable. pub fn open(id: &str) -> io::Result { - // A Mach named-memory handle is a task-local port right that another - // process can use only after receiving it over IPC. POSIX shared memory - // keeps this operation addressable by a string without a transfer service. // `rustix::shm::open` sets `FD_CLOEXEC` on the returned descriptor. let fd = shm::open(id, OFlags::RDWR, Mode::empty()).map_err(io::Error::from)?; - // The owner sets the size before publishing `id`, and fspy never resizes - // the object afterward, so it stays fixed across `fstat` and `mmap`. + // macOS cannot seal a POSIX shared-memory object against resizing. If + // another process shrank it between `fstat` and `mmap`, `mmap` would use the + // old size and accessing truncated pages could raise `SIGBUS`. Fspy sets + // the size before sharing `id` and never changes it afterward. A fixed-size + // Mach memory entry would remove the race, but another process cannot open + // one by name; the owner would have to send its Mach port over IPC. let stat = fstat(&fd)?; let size = usize::try_from(stat.st_size) .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid shared-memory size"))?; From ce06b3937f8c99c5ac047b50746454ab90262232 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sat, 11 Jul 2026 07:24:23 +0800 Subject: [PATCH 17/18] docs(fspy): describe macOS open failure Co-authored-by: GPT-5 Codex --- crates/fspy_shm/src/macos/mod.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/crates/fspy_shm/src/macos/mod.rs b/crates/fspy_shm/src/macos/mod.rs index d67e406d..be79c652 100644 --- a/crates/fspy_shm/src/macos/mod.rs +++ b/crates/fspy_shm/src/macos/mod.rs @@ -74,12 +74,10 @@ pub fn create(size: usize) -> io::Result { 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)?; - // macOS cannot seal a POSIX shared-memory object against resizing. If - // another process shrank it between `fstat` and `mmap`, `mmap` would use the - // old size and accessing truncated pages could raise `SIGBUS`. Fspy sets - // the size before sharing `id` and never changes it afterward. A fixed-size - // Mach memory entry would remove the race, but another process cannot open - // one by name; the owner would have to send its Mach port over IPC. + // Another process that opens this object for writing can resize it. If it + // shrinks the object after `fstat`, `mmap` rejects the requested size and + // `open` returns an error. Fspy sets the size before sharing `id` and never + // changes it afterward. let stat = fstat(&fd)?; let size = usize::try_from(stat.st_size) .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid shared-memory size"))?; From d4719a83d515154215d027176263b581e5350f5c Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sat, 11 Jul 2026 07:25:52 +0800 Subject: [PATCH 18/18] docs(fspy): scope macOS resize safety Co-authored-by: GPT-5 Codex --- crates/fspy_shm/src/macos/mod.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/fspy_shm/src/macos/mod.rs b/crates/fspy_shm/src/macos/mod.rs index be79c652..0e5f20b7 100644 --- a/crates/fspy_shm/src/macos/mod.rs +++ b/crates/fspy_shm/src/macos/mod.rs @@ -74,10 +74,9 @@ pub fn create(size: usize) -> io::Result { 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)?; - // Another process that opens this object for writing can resize it. If it - // shrinks the object after `fstat`, `mmap` rejects the requested size and - // `open` returns an error. Fspy sets the size before sharing `id` and never - // changes it afterward. + // 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"))?;