From 9e9213ad90de0697489811c1fb5e383b4f597baa Mon Sep 17 00:00:00 2001 From: wan9chi Date: Fri, 14 Aug 2026 16:13:01 +0800 Subject: [PATCH 01/92] fix(fspy): replace quiescence locking with crash-tolerant frame publication The IPC channel previously required writer quiescence before reading: a file lock (or #577's active-writer gate) had to drain before the receiver could parse the inline frame stream. A traced process that closed the lock descriptor while keeping the mapping writable corrupted parsing (#544), and one that never exited (a daemon) or died mid-record could block collection or poison the writer count forever. The shared memory now uses a two-ended layout: an allocator word admits claims and closes the channel, a descriptor table grows from the front, and payloads grow from the back. Each frame commits by publishing its descriptor with a release CAS; closing atomically aborts every unfinished slot and copies committed payloads out with relaxed atomic loads, so the receiver never waits for a writer, never trusts payload bytes for traversal, and never holds a reference into memory another process may mutate. Loss of a record by a live writer (capacity, abandonment) flags the trace incomplete so the run is not cached from an under-reporting trace; process death needs no cleanup because records are published before the recorded operation is performed. Closes #544. Supersedes #577. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 1 - crates/fspy/src/ipc.rs | 55 +- crates/fspy/src/unix/mod.rs | 15 +- crates/fspy/src/windows/mod.rs | 14 +- crates/fspy_client_unix/src/lib.rs | 27 +- .../src/windows/client.rs | 6 +- crates/fspy_shared/Cargo.toml | 1 - crates/fspy_shared/src/ipc/channel/mod.rs | 214 +++--- crates/fspy_shared/src/ipc/channel/shm_io.rs | 727 ------------------ .../src/ipc/channel/shm_io/alloc_word.rs | 225 ++++++ .../src/ipc/channel/shm_io/layout.rs | 191 +++++ .../fspy_shared/src/ipc/channel/shm_io/mod.rs | 696 +++++++++++++++++ .../src/ipc/channel/shm_io/reader.rs | 130 ++++ .../src/ipc/channel/shm_io/slot.rs | 109 +++ .../src/ipc/channel/shm_io/state.rs | 210 +++++ .../src/ipc/channel/shm_io/writer.rs | 229 ++++++ 16 files changed, 1970 insertions(+), 880 deletions(-) delete mode 100644 crates/fspy_shared/src/ipc/channel/shm_io.rs create mode 100644 crates/fspy_shared/src/ipc/channel/shm_io/alloc_word.rs create mode 100644 crates/fspy_shared/src/ipc/channel/shm_io/layout.rs create mode 100644 crates/fspy_shared/src/ipc/channel/shm_io/mod.rs create mode 100644 crates/fspy_shared/src/ipc/channel/shm_io/reader.rs create mode 100644 crates/fspy_shared/src/ipc/channel/shm_io/slot.rs create mode 100644 crates/fspy_shared/src/ipc/channel/shm_io/state.rs create mode 100644 crates/fspy_shared/src/ipc/channel/shm_io/writer.rs diff --git a/Cargo.lock b/Cargo.lock index f0549dd6b..00513ba67 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1435,7 +1435,6 @@ dependencies = [ "subprocess_test", "thiserror 2.0.18", "tokio", - "tracing", "uuid", "vt_path", "winapi", diff --git a/crates/fspy/src/ipc.rs b/crates/fspy/src/ipc.rs index 51d498600..2a649155c 100644 --- a/crates/fspy/src/ipc.rs +++ b/crates/fspy/src/ipc.rs @@ -2,7 +2,7 @@ use std::io; use fspy_shared::ipc::{ PathAccess, - channel::{Receiver, ReceiverLockGuard}, + channel::{Frames, Receiver}, }; use tokio::task::spawn_blocking; @@ -11,28 +11,49 @@ use tokio::task::spawn_blocking; // This doesn't allocate physical memory until it's actually used. pub const SHM_CAPACITY: usize = 4 * 1024 * 1024 * 1024; -#[ouroboros::self_referencing] -pub struct OwnedReceiverLockGuard { - /// Owns the shared memory - receiver: Receiver, - /// Borrows the shared memory and owns the file lock - #[borrows(receiver)] - #[covariant] - lock_guard: ReceiverLockGuard<'this>, +/// The validated path accesses collected from a closed IPC channel. +pub struct CollectedAccesses { + frames: Frames, } -impl OwnedReceiverLockGuard { - pub fn lock(receiver: Receiver) -> io::Result { - Self::try_new(receiver, fspy_shared::ipc::channel::Receiver::lock) +impl CollectedAccesses { + /// Closes the channel and validates the collected trace. + /// + /// Never waits for tracked processes: closing rejects new records and + /// atomically ignores unfinished ones (see + /// [`fspy_shared::ipc::channel::Receiver::close`]). + /// + /// Fails when the trace cannot back the run's file accesses: a record + /// was lost before close, or the shared memory was corrupted. Failing + /// here — instead of returning a silently short trace — keeps the + /// tracking result trustworthy for caching. + pub fn collect(receiver: Receiver) -> io::Result { + let frames = receiver.close()?; + if !frames.is_complete() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "file-access trace is incomplete: a tracked process lost a record", + )); + } + // Validate every frame once so iteration is infallible. + for frame in frames.iter() { + let _: PathAccess<'_> = wincode::deserialize_exact(frame).map_err(|err| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("corrupt file-access record: {err}"), + ) + })?; + } + Ok(Self { frames }) } - pub async fn lock_async(receiver: Receiver) -> io::Result { - spawn_blocking(move || Self::lock(receiver)).await.expect("lock task panicked") + pub async fn collect_async(receiver: Receiver) -> io::Result { + spawn_blocking(move || Self::collect(receiver)).await.expect("collect task panicked") } pub fn iter_path_accesses(&self) -> impl Iterator> { - self.borrow_lock_guard() - .iter_frames() - .map(|frame| wincode::deserialize_exact(frame).unwrap()) + self.frames + .iter() + .map(|frame| wincode::deserialize_exact(frame).expect("frames validated in collect")) } } diff --git a/crates/fspy/src/unix/mod.rs b/crates/fspy/src/unix/mod.rs index f1d657436..944cec7c4 100644 --- a/crates/fspy/src/unix/mod.rs +++ b/crates/fspy/src/unix/mod.rs @@ -25,7 +25,7 @@ use tokio::task::spawn_blocking; use tokio_util::sync::CancellationToken; #[cfg(not(target_env = "musl"))] -use crate::ipc::{OwnedReceiverLockGuard, SHM_CAPACITY}; +use crate::ipc::{CollectedAccesses, SHM_CAPACITY}; use crate::{ChildTermination, Command, TrackedChild, arena::PathAccessArena, error::SpawnError}; #[derive(Debug)] @@ -137,7 +137,7 @@ impl SpyImpl { stdout: child.stdout.take(), stderr: child.stderr.take(), // Keep polling for the child to exit in the background even if `wait_handle` is not awaited, - // because we need to stop the supervisor and lock the channel as soon as the child exits. + // because we need to stop the supervisor and close the channel as soon as the child exits. wait_handle: tokio::spawn(async move { let status = tokio::select! { status = child.wait() => status?, @@ -159,15 +159,14 @@ impl SpyImpl { ); let arenas = arenas.collect::>(); - // Lock the ipc channel after the child has exited. + // Close the ipc channel after the child has exited. // We are not interested in path accesses from descendants after the main child has exited. #[cfg(not(target_env = "musl"))] - let ipc_receiver_lock_guard = - OwnedReceiverLockGuard::lock_async(ipc_receiver).await?; + let ipc_accesses = CollectedAccesses::collect_async(ipc_receiver).await?; let path_accesses = PathAccessIterable { arenas, #[cfg(not(target_env = "musl"))] - ipc_receiver_lock_guard, + ipc_accesses, }; io::Result::Ok(ChildTermination { status, path_accesses }) @@ -181,7 +180,7 @@ impl SpyImpl { pub struct PathAccessIterable { arenas: Vec, #[cfg(not(target_env = "musl"))] - ipc_receiver_lock_guard: OwnedReceiverLockGuard, + ipc_accesses: CollectedAccesses, } impl PathAccessIterable { @@ -191,7 +190,7 @@ impl PathAccessIterable { #[cfg(not(target_env = "musl"))] { - let accesses_in_shm = self.ipc_receiver_lock_guard.iter_path_accesses(); + let accesses_in_shm = self.ipc_accesses.iter_path_accesses(); accesses_in_shm.chain(accesses_in_arena) } #[cfg(target_env = "musl")] diff --git a/crates/fspy/src/windows/mod.rs b/crates/fspy/src/windows/mod.rs index c468888a6..ef720e988 100644 --- a/crates/fspy/src/windows/mod.rs +++ b/crates/fspy/src/windows/mod.rs @@ -24,19 +24,19 @@ use crate::{ ChildTermination, TrackedChild, command::Command, error::SpawnError, - ipc::{OwnedReceiverLockGuard, SHM_CAPACITY}, + ipc::{CollectedAccesses, SHM_CAPACITY}, }; const INTERPOSE_CDYLIB: Artifact = artifact!("fspy_preload", "CARGO_CDYLIB_FILE_FSPY_PRELOAD_WINDOWS"); pub struct PathAccessIterable { - ipc_receiver_lock_guard: OwnedReceiverLockGuard, + ipc_accesses: CollectedAccesses, } impl PathAccessIterable { pub fn iter(&self) -> impl Iterator> { - self.ipc_receiver_lock_guard.iter_path_accesses() + self.ipc_accesses.iter_path_accesses() } } @@ -158,7 +158,7 @@ impl SpyImpl { stderr: child.stderr.take(), process_handle, // Keep polling for the child to exit in the background even if `wait_handle` is not awaited, - // because we need to stop the supervisor and lock the channel as soon as the child exits. + // because we need to stop the supervisor and close the channel as soon as the child exits. wait_handle: tokio::spawn(async move { let status = tokio::select! { status = child.wait() => status?, @@ -167,10 +167,10 @@ impl SpyImpl { child.wait().await? } }; - // Lock the ipc channel after the child has exited. + // Close the ipc channel after the child has exited. // We are not interested in path accesses from descendants after the main child has exited. - let ipc_receiver_lock_guard = OwnedReceiverLockGuard::lock_async(receiver).await?; - let path_accesses = PathAccessIterable { ipc_receiver_lock_guard }; + let ipc_accesses = CollectedAccesses::collect_async(receiver).await?; + let path_accesses = PathAccessIterable { ipc_accesses }; io::Result::Ok(ChildTermination { status, path_accesses }) }) diff --git a/crates/fspy_client_unix/src/lib.rs b/crates/fspy_client_unix/src/lib.rs index d697257e2..f6814cf66 100644 --- a/crates/fspy_client_unix/src/lib.rs +++ b/crates/fspy_client_unix/src/lib.rs @@ -84,12 +84,23 @@ impl Client { let frame_size = NonZeroUsize::new(serialized_size) .expect("fspy: encoded PathAccess should never be empty"); - let mut frame = ipc_sender - .claim_frame(frame_size) - .expect("fspy: failed to claim frame in shared memory"); + let Ok(mut frame) = ipc_sender.claim_frame(frame_size) else { + // The receiver has closed the channel (this process outlived the + // run's tracking boundary) or the region is full (the claim + // itself already marked the trace incomplete). Either way the + // interception must proceed without a record — a preload library + // can never panic its host process. + return Ok(()); + }; let mut writer: &mut [u8] = &mut frame; + // A serialization failure drops `frame` unfinished, which marks the + // trace incomplete. PathAccess::serialize_into(&mut writer, &path_access)?; - assert_eq!(writer.len(), 0); + debug_assert_eq!(writer.len(), 0); + if !writer.is_empty() { + return Ok(()); + } + frame.finish(); Ok(()) } @@ -106,10 +117,6 @@ impl Client { /// /// Returns errors from exec resolution, platform preparation, or the /// forwarding callback. - /// - /// # Panics - /// - /// Panics if reporting the executable path fails. pub unsafe fn handle_exec( &self, config: ExecResolveConfig, @@ -120,7 +127,9 @@ impl Client { // null-terminated arrays, as provided by the caller. let mut exec = unsafe { raw_exec.to_exec() }; let pre_exec = handle_exec(&mut exec, config, &self.encoded_payload, |mode, path| { - self.send(mode, path).unwrap(); + // A lost record already marked the trace incomplete inside + // `send`; the exec itself must proceed regardless. + let _ = self.send(mode, path); })?; RawExec::from_exec(exec, |raw_command| f(raw_command, pre_exec)) } diff --git a/crates/fspy_preload_windows/src/windows/client.rs b/crates/fspy_preload_windows/src/windows/client.rs index 48933414e..cf8c076ed 100644 --- a/crates/fspy_preload_windows/src/windows/client.rs +++ b/crates/fspy_preload_windows/src/windows/client.rs @@ -40,7 +40,11 @@ impl<'a> Client<'a> { let Some(sender) = &self.ipc_sender else { return; }; - sender.write_encoded(&access).expect("failed to send path access"); + // A failed write means the receiver closed the channel (this process + // outlived the run's tracking boundary) or the record was lost — the + // latter already marked the trace incomplete. The intercepted call + // must proceed either way; a detours DLL can never panic its host. + let _ = sender.write_encoded(&access); } pub unsafe fn prepare_child_process(&self, child_handle: HANDLE) -> BOOL { diff --git a/crates/fspy_shared/Cargo.toml b/crates/fspy_shared/Cargo.toml index 071210f63..213d25011 100644 --- a/crates/fspy_shared/Cargo.toml +++ b/crates/fspy_shared/Cargo.toml @@ -17,7 +17,6 @@ fspy_nostd_alloc = { workspace = true } fspy_shm = { workspace = true } fspy_ipc_str = { workspace = true } thiserror = { workspace = true } -tracing = { workspace = true } uuid = { workspace = true, features = ["v4"] } vt_path = { workspace = true } diff --git a/crates/fspy_shared/src/ipc/channel/mod.rs b/crates/fspy_shared/src/ipc/channel/mod.rs index ad5c5c205..6ca83e1d8 100644 --- a/crates/fspy_shared/src/ipc/channel/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/mod.rs @@ -1,16 +1,20 @@ //! Fast mpsc IPC channel implementation based on shared memory. +//! +//! The channel is crash-tolerant and nonblocking on both ends: any sender +//! process may die (or keep running) at any point without preventing the +//! receiver from closing the channel and reading every committed frame. See +//! the `shm_io` module for the underlying protocol. mod shm_io; -use std::{env::temp_dir, ffi::OsStr, fs::File, io, ops::Deref, path::PathBuf}; +use std::{env::temp_dir, ffi::OsStr, io, ops::Deref, path::PathBuf}; use allocator_api2::alloc::Global; use fspy_nostd::Fat; use fspy_nostd_alloc::OsCString; use fspy_shm::Mapping; -pub use shm_io::FrameMut; -use shm_io::{ShmReader, ShmWriter}; -use tracing::debug; +use shm_io::ShmWriter; +pub use shm_io::{ClaimError, FrameMut, Frames, WriteEncodedError}; use uuid::Uuid; use wincode::{SchemaRead, SchemaWrite}; @@ -27,16 +31,12 @@ const SHM_BACKING_PREFIX: &str = "vite-task-fspy-"; /// Serializable configuration to create channel senders. #[derive(SchemaWrite, SchemaRead, Clone, Debug)] pub struct ChannelConf { - lock_file_path: Box, shm_id: Box, } /// Creates a mpsc IPC channel with one receiver and a `ChannelConf` that can be passed around processes and used to create multiple senders #[expect(clippy::missing_errors_doc, reason = "non-vt crate: cannot use vt_str/vt_path types")] pub fn channel(capacity: usize) -> io::Result<(ChannelConf, Receiver)> { - // Initialize the lock file with a unique name. - let lock_file_path = temp_dir().join(format!("fspy_ipc_{}.lock", Uuid::new_v4())); - let shm_c_path = os_c_string(shm_backing_path()?.as_os_str())?; let handle = fspy_shm::create(shm_c_path.as_c_str().as_thin(), capacity).map_err(shm_error_to_io)?; @@ -44,13 +44,9 @@ pub fn channel(capacity: usize) -> io::Result<(ChannelConf, Receiver)> { let keeper = ShmKeeper { path: shm_c_path }; let mapping = handle.map().map_err(shm_error_to_io)?; - let conf = ChannelConf { - lock_file_path: lock_file_path.as_os_str().into(), - shm_id: IpcStr::from_os_c_str(keeper.path.as_c_str()).to_boxed(), - }; + let conf = ChannelConf { shm_id: IpcStr::from_os_c_str(keeper.path.as_c_str()).to_boxed() }; - let receiver = Receiver::new(lock_file_path, keeper, mapping)?; - Ok((conf, receiver)) + Ok((conf, Receiver { _keeper: keeper, mapping })) } /// Encodes `path` as an owned NUL-terminated platform C string. @@ -141,15 +137,14 @@ impl Drop for ShmKeeper { impl ChannelConf { /// Creates a sender. /// - /// This doesn't block on the file lock. Instead it returns immediately with error if the receiver is locked or dropped. + /// Never blocks. Fails when the receiver has already closed the channel + /// or dropped: the backing file is then removed (and, for the removal + /// failure edge, the region itself is marked closed). #[expect( clippy::missing_errors_doc, reason = "error conditions are self-evident from return type" )] pub fn sender(&self) -> io::Result { - let lock_file = File::open(self.lock_file_path.to_cow_os_str())?; - lock_file.try_lock_shared()?; - // The arena never touches the process heap, so this stays safe in // the preload contexts that create senders (pre-`main` constructors, // the Windows loader lock). @@ -161,27 +156,22 @@ impl ChannelConf { .map_err(shm_error_to_io)? .map() .map_err(shm_error_to_io)?; - // SAFETY: `mapping` is a freshly mapped shared memory region with valid - // pointer and size. Exclusive write access is ensured by the shared - // file lock held by this sender. + // SAFETY: `mapping` is a freshly mapped shared memory region created + // zero-initialized by `channel` and accessed only through the + // `shm_io` protocol by every attached process. let writer = unsafe { ShmWriter::new(mapping) }; - Ok(Sender { writer, lock_file, lock_file_path: self.lock_file_path.clone() }) + if writer.is_closed() { + return Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "the channel has been closed by the receiver", + )); + } + Ok(Sender { writer }) } } pub struct Sender { writer: ShmWriter, - lock_file_path: Box, - lock_file: File, -} - -impl Drop for Sender { - fn drop(&mut self) { - if let Err(err) = self.lock_file.unlock() { - let lock_file_path = self.lock_file_path.to_cow_os_str(); - debug!("Failed to unlock the shared IPC lock {}: {}", lock_file_path.display(), err); - } - } } impl Deref for Sender { @@ -192,76 +182,58 @@ impl Deref for Sender { } } -/// SAFETY: `Sender` holds a shared file lock that ensures there's no reader, so `shm` can be safely written to. +// SAFETY: `Sender` only accesses the shared mapping through the `shm_io` +// protocol, which synchronizes concurrent writers and the receiver with +// atomic operations; the mapping's address is stable and independently owned. unsafe impl Send for Sender {} -/// SAFETY: `Sender` holds a shared file lock that ensures there's no reader, so `shm` can be safely written to. +// SAFETY: see the `Send` impl; `ShmWriter`'s shared-reference API is +// internally synchronized by the protocol. unsafe impl Sync for Sender {} /// The unique receiver side of an IPC channel. -/// Owns the lock file and removes it on drop. +/// +/// Holds the shared memory and its backing file alive for as long as senders +/// may attach; [`Receiver::close`] (or dropping) removes the backing file. pub struct Receiver { - lock_file_path: PathBuf, - lock_file: File, /// Keeps the shared memory's backing file alive for as long as senders /// may attach. _keeper: ShmKeeper, mapping: Mapping, } -/// SAFETY: `Receiver` doesn't read or write `shm`. It only passes it to `ReceiverLockGuard` under the lock. +// SAFETY: `Receiver` only holds the mapping; it accesses it exclusively +// through the `shm_io` protocol in `close`, which synchronizes with senders +// via atomic operations. The mapping's address is stable and independently +// owned. unsafe impl Send for Receiver {} -/// SAFETY: `Receiver` doesn't read or write `shm`. It only passes it to `ReceiverLockGuard` under the lock. +// SAFETY: see the `Send` impl. unsafe impl Sync for Receiver {} -impl Drop for Receiver { - fn drop(&mut self) { - if let Err(err) = std::fs::remove_file(&self.lock_file_path) { - debug!("Failed to remove IPC lock file {}: {}", self.lock_file_path.display(), err); - } - } -} - impl Receiver { - fn new(lock_file_path: PathBuf, keeper: ShmKeeper, mapping: Mapping) -> io::Result { - let lock_file = File::create(&lock_file_path)?; - Ok(Self { lock_file_path, lock_file, _keeper: keeper, mapping }) - } - - /// Lock the shared memory for unique read access. - /// Blocks until all the senders have dropped (or processes owning them have all exited) so the shared memory can be safely read. - /// During the lifetime of returned `ReceiverReadGuard`, no new senders can be created (`ChannelConf::sender` would fail). - #[expect( - clippy::missing_errors_doc, - reason = "error conditions are self-evident from return type" - )] - pub fn lock(&self) -> io::Result> { - self.lock_file.lock()?; - // SAFETY: The exclusive file lock is held, so no writers can access the shared memory. - // The lock ensures all prior writes are visible to this thread. - let reader = ShmReader::new(unsafe { self.mapping.as_slice() }); - Ok(ReceiverLockGuard { reader, lock_file: &self.lock_file }) - } -} - -pub struct ReceiverLockGuard<'a> { - reader: ShmReader<&'a [u8]>, - lock_file: &'a File, -} - -impl Drop for ReceiverLockGuard<'_> { - fn drop(&mut self) { - if let Err(err) = self.lock_file.unlock() { - debug!("Failed to unlock IPC lock file: {}", err); - } - } -} -impl<'a> Deref for ReceiverLockGuard<'a> { - type Target = ShmReader<&'a [u8]>; - - fn deref(&self) -> &Self::Target { - &self.reader + /// Closes the channel and collects every committed frame. + /// + /// Never blocks on senders: new claims are rejected from this point on, + /// unfinished frames are atomically aborted, and committed frames are + /// copied out and returned. A sender process that is still alive keeps + /// running; anything it reports after this point is outside the + /// channel's boundary by design. + /// + /// # Errors + /// + /// Fails only when the shared-memory metadata was corrupted (a protocol + /// impossibility for correct senders); the trace is then unusable. + pub fn close(self) -> io::Result { + let Self { _keeper: keeper, mapping } = self; + // Remove the backing file first so no new process attaches while the + // channel closes. + drop(keeper); + // SAFETY: `mapping` was created zero-initialized by `channel`, its + // address is stable, and all attached processes access it only + // through the `shm_io` protocol. + unsafe { shm_io::close(&mapping) } + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err)) } } @@ -269,6 +241,7 @@ impl<'a> Deref for ReceiverLockGuard<'a> { mod tests { use std::{ffi::OsString, fs, num::NonZeroUsize, str::from_utf8}; + use assert2::assert; use bstr::B; use subprocess_test::command_for_fn; @@ -288,6 +261,7 @@ mod tests { let frame_size = NonZeroUsize::new(2).unwrap(); let mut frame = sender.claim_frame(frame_size).unwrap(); frame.copy_from_slice(&[4, 2]); + frame.finish(); }); command.cwd = changed_cwd.clone(); for name in ["TMPDIR", "TMP", "TEMP"] { @@ -297,8 +271,9 @@ mod tests { fs::remove_dir(changed_cwd).unwrap(); assert!(succeeded); - let lock = receiver.lock().unwrap(); - assert_eq!(lock.iter_frames().next().unwrap(), &[4, 2]); + let frames = receiver.close().unwrap(); + assert!(frames.iter().next().unwrap() == &[4, 2]); + assert!(frames.is_complete()); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -309,42 +284,64 @@ mod tests { let frame_size = NonZeroUsize::new(2).unwrap(); let mut frame = sender.claim_frame(frame_size).unwrap(); frame.copy_from_slice(&[4, 2]); + frame.finish(); }); assert!(std::process::Command::from(cmd).status().unwrap().success()); - let lock = receiver.lock().unwrap(); - let mut frames = lock.iter_frames(); + let frames = receiver.close().unwrap(); + let mut iter = frames.iter(); - let received_frame = frames.next().unwrap(); - assert_eq!(received_frame, &[4, 2]); + let received_frame = iter.next().unwrap(); + assert!(received_frame == &[4, 2]); - assert!(frames.next().is_none()); + assert!(iter.next().is_none()); + assert!(frames.is_complete()); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[expect(clippy::print_stdout, reason = "test diagnostics")] - async fn forbid_new_senders_after_locked() { - let (conf, receiver) = channel(42).unwrap(); - let _lock = receiver.lock().unwrap(); + async fn forbid_new_senders_after_close() { + let (conf, receiver) = channel(4096).unwrap(); + let _frames = receiver.close().unwrap(); let cmd = command_for_fn!(conf, |conf: ChannelConf| { print!("{}", conf.sender().is_ok()); }); let output = std::process::Command::from(cmd).output().unwrap(); - assert_eq!(B(&output.stdout), B("false")); + assert!(B(&output.stdout) == B("false")); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[expect(clippy::print_stdout, reason = "test diagnostics")] async fn forbid_new_senders_after_receiver_dropped() { - let (conf, receiver) = channel(42).unwrap(); + let (conf, receiver) = channel(4096).unwrap(); drop(receiver); let cmd = command_for_fn!(conf, |conf: ChannelConf| { print!("{}", conf.sender().is_ok()); }); let output = std::process::Command::from(cmd).output().unwrap(); - assert_eq!(B(&output.stdout), B("false")); + assert!(B(&output.stdout) == B("false")); + } + + /// A sender that attached before close keeps its mapping but cannot + /// claim any new frame afterwards. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn attached_sender_cannot_claim_after_close() { + let (conf, receiver) = channel(4096).unwrap(); + let sender = conf.sender().unwrap(); + + let mut frame = sender.claim_frame(NonZeroUsize::new(2).unwrap()).unwrap(); + frame.copy_from_slice(&[4, 2]); + frame.finish(); + + let frames = receiver.close().unwrap(); + assert!(frames.iter().next().unwrap() == &[4, 2]); + assert!(frames.is_complete()); + + assert!( + sender.claim_frame(NonZeroUsize::new(2).unwrap()).unwrap_err() == ClaimError::Closed + ); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -354,10 +351,10 @@ mod tests { let cmd = command_for_fn!((conf.clone(), i), |(conf, i): (ChannelConf, u16)| { let sender = conf.sender().unwrap(); let data_to_send = i.to_string(); - sender - .claim_frame(NonZeroUsize::new(data_to_send.len()).unwrap()) - .unwrap() - .copy_from_slice(data_to_send.as_bytes()); + let mut frame = + sender.claim_frame(NonZeroUsize::new(data_to_send.len()).unwrap()).unwrap(); + frame.copy_from_slice(data_to_send.as_bytes()); + frame.finish(); }); let output = std::process::Command::from(cmd).output().unwrap(); assert!( @@ -367,12 +364,11 @@ mod tests { B(&output.stderr) ); } - let lock = receiver.lock().unwrap(); - let mut received_values: Vec = lock - .iter_frames() - .map(|frame| from_utf8(frame).unwrap().parse::().unwrap()) - .collect(); + let frames = receiver.close().unwrap(); + let mut received_values: Vec = + frames.iter().map(|frame| from_utf8(frame).unwrap().parse::().unwrap()).collect(); received_values.sort_unstable(); - assert_eq!(received_values, (0u16..200).collect::>()); + assert!(received_values == (0u16..200).collect::>()); + assert!(frames.is_complete()); } } diff --git a/crates/fspy_shared/src/ipc/channel/shm_io.rs b/crates/fspy_shared/src/ipc/channel/shm_io.rs deleted file mode 100644 index 916df30d7..000000000 --- a/crates/fspy_shared/src/ipc/channel/shm_io.rs +++ /dev/null @@ -1,727 +0,0 @@ -//! Provides lock-free concurrent writing and reading of frames in a shared memory region. - -use core::iter::from_fn; -use std::{ - num::NonZeroUsize, - ops::{Deref, DerefMut}, - ptr::slice_from_raw_parts_mut, - sync::atomic::{AtomicI32, AtomicUsize, Ordering, fence}, -}; - -use bytemuck::must_cast; -use fspy_shm::Mapping; -use wincode::{SchemaWrite, Serialize as _, config::DefaultConfig}; - -// `ShmWriter` writes headers using atomic operations to prevent partial writes due to crashes, -// while `ShmReader` reads headers by simple pointer dereferences. -// This is safe because `ShmReader` is only used after all writing is done and visible to the calling thread (see docs of `ShmReader::new`). -// To ensure that the layouts of atomic types and their non-atomic counterparts are the same: -const _: () = { - assert!(size_of::() == size_of::()); - assert!(align_of::() == align_of::()); - assert!(size_of::() == size_of::()); - assert!(align_of::() == align_of::()); -}; - -/// A trait to borrow a raw memory region. -pub trait AsRawSlice { - fn as_raw_slice(&self) -> *mut [u8]; -} - -impl AsRawSlice for Mapping { - fn as_raw_slice(&self) -> *mut [u8] { - slice_from_raw_parts_mut(self.as_ptr(), self.len()) - } -} - -/// A concurrent shared memory writer. -/// -/// It's lock-free and safe to use across multiple threads/processes at the same time. -/// Internally it uses atomic operations to ensure that multiple writers can write to the shared memory without -/// overwriting each other's data. -pub struct ShmWriter { - /* - Layout of the whole shared memory: - | total byte size of frames(AtomicUsize) | frame 1 | frame 2 | ..... | - - Possible layout states of each frame: - - | 0(AtomicI32) | 0000...... | all zero. This happens when the thread/process crashed right after the frame is claimed. - - | byte size of the frame (AtomicI32) | partially written data | extra 0s to align to next frame header | This happens when the thread/process crashed during writing. - - | negative byte size of the frame (AtomicI32) | fully written data | extra 0s to align to next frame header | This is the normal case (negative size indicates completion). - */ - mem: M, - - #[cfg(test)] - fail_on_claim: bool, -} - -// unsafe impl Send for ShmWriter {} -// unsafe impl Sync for ShmWriter {} - -#[track_caller] -fn assert_alignment(ptr: *const u8) { - // Assert that the header of the shm is aligned to usize - assert_eq!(ptr as usize % align_of::(), 0); - // Assert that the content after whole shm header is aligned to i32 - assert_eq!((ptr as usize + size_of::()) % align_of::(), 0); -} - -const fn roundup_to_align_frame_header(mut size: usize) -> usize { - // round up new_end so that the next frame header is aligned - const FRAME_HEADER_ALIGN: usize = align_of::(); - if !size.is_multiple_of(FRAME_HEADER_ALIGN) { - size += FRAME_HEADER_ALIGN - (size % FRAME_HEADER_ALIGN); - } - size -} - -pub struct FrameMut<'a> { - header: &'a AtomicI32, - content: &'a mut [u8], -} -impl Deref for FrameMut<'_> { - type Target = [u8]; - - fn deref(&self) -> &Self::Target { - self.content - } -} -impl DerefMut for FrameMut<'_> { - fn deref_mut(&mut self) -> &mut Self::Target { - self.content - } -} - -impl Drop for FrameMut<'_> { - fn drop(&mut self) { - // Prevents compiler from ordering memory operations. Ensure the data is visible before marking as fully written - fence(Ordering::Release); - - // Mark as fully written (negative size indicates completion) - let frame_size_i32 = - i32::try_from(self.content.len()).expect("frame size checked in `append_frame`"); - self.header.store(-frame_size_i32, Ordering::Relaxed); - } -} - -#[derive(thiserror::Error, Debug)] -pub enum WriteEncodedError { - #[error("Failed to encode value into shared memory")] - EncodeError(#[from] wincode::error::WriteError), - #[error("Tried to write a frame of zero size into shared memory")] - ZeroSizedFrame, - #[error("Not enough space in shared memory to write the encoded frame")] - InsufficientSpace, -} - -impl ShmWriter { - /// Create a new `ShmWriter` backed by a shared memory region. - /// - /// # Safety - /// - `mem.as_raw_slice()` must return a stable valid pointer to a memory region of `total` bytes, - /// - the memory region must only be accessed via `ShmWriter` across all the processes. - /// - The unused region of the shared memory must be initialized to zero. - pub unsafe fn new(mem: M) -> Self { - assert_alignment(mem.as_raw_slice() as *const u8); - Self { - mem, - #[cfg(test)] - fail_on_claim: false, - } - } - - // Unwrap `self` and return the underlying memory. - #[cfg(test)] - pub fn into_memory(self) -> M { - self.mem - } - - #[cfg(test)] - const fn set_fail_on_claim(&mut self, fail_on_claim: bool) { - self.fail_on_claim = fail_on_claim; - } - - /// Claim a frame of size `frame_size`. - /// - /// Returns `None` if there is no sufficient remaining space (or simulated crash in tests) - /// `frame_size` must be non-zero because frame header being 0 would be ambiguous. - pub fn claim_frame(&self, frame_size: NonZeroUsize) -> Option> { - let shm_slice: *mut [u8] = self.mem.as_raw_slice(); - let shm_ptr = shm_slice.cast::(); - let shm_len = self.mem.as_raw_slice().len(); - - let frame_size = frame_size.get(); - let Ok(frame_size_i32) = i32::try_from(frame_size) else { - // The frame header uses a signed 32-bit integer (i32) to store the frame size. - // Negative values are reserved to indicate completion, so only positive values are valid. - // Therefore, the maximum allowed frame size is i32::MAX (2^31-1), approximately 2GB. - // Attempting to claim a frame larger than this will fail. - return None; - }; - - // Get the atomic value of the end position (first 8 bytes of shared memory) - // SAFETY: `shm_ptr` points to the start of the shared memory region, which is properly - // aligned to `usize` (verified by `assert_alignment` in `new`), and the allocation is - // large enough to contain at least a `usize` header. - let atomic_header = unsafe { AtomicUsize::from_ptr(shm_ptr.cast()) }; - - let frame_with_header_size = size_of::() + frame_size; - - // Try to atomically claim the space - // Different writers only share the header, not each other's content. so relaxed ordering is sufficient. - let current_end = - atomic_header.try_update(Ordering::Relaxed, Ordering::Relaxed, |current_end| { - let new_end = roundup_to_align_frame_header(current_end + frame_with_header_size); - - // Check if we have enough space - if size_of::() + new_end > shm_len { - return None; - } - - Some(new_end) - }); - - let Ok(current_end) = current_end else { - return None; // Not enough space - }; - - #[cfg(test)] - if self.fail_on_claim { - // Simulate crash right after claiming the space - return None; - } - - // Successfully claimed the space, now write the data - - // SAFETY: The atomic try_update above guaranteed that `size_of::() + current_end` - // is within the shared memory bounds, so this pointer arithmetic stays within the allocation. - let frame_start = unsafe { - shm_ptr.add(/* shm header */ size_of::() + current_end) - }; - - // SAFETY: `frame_start` is properly aligned to `i32` (ensured by `roundup_to_align_frame_header`) - // and points within the shared memory allocation (bounds checked by the atomic try_update). - let frame_header = unsafe { AtomicI32::from_ptr(frame_start.cast()) }; - - // Mark as partially written with positive size - // Atomic operations on the frame header is only for preventing partial writes of the frame header itself (possibly due to crashes), - // not for synchronization of frame contents, so relaxed ordering is sufficient - frame_header.store(frame_size_i32, Ordering::Relaxed); - - // Prevents compiler from re-ordering memory operations. Ensure the size is visible before writing the data - fence(Ordering::Release); - - // SAFETY: `frame_start` is within bounds and adding `size_of::()` skips the frame - // header to reach the content area, which is still within the claimed space. - let frame_content_ptr = unsafe { frame_start.add(size_of::()) }; // skip the frame header - Some(FrameMut { - header: frame_header, - // SAFETY: `frame_content_ptr` is valid for `frame_size` bytes (guaranteed by the - // atomic space claim), properly aligned for `u8`, and no other writer will access - // this region because each writer atomically claims a unique range. - content: unsafe { std::slice::from_raw_parts_mut(frame_content_ptr, frame_size) }, - }) - } - - /// Append an encoded value into the shared memory. - pub fn write_encoded>( - &self, - value: &T, - ) -> Result<(), WriteEncodedError> { - let serialized_size = - usize::try_from(T::serialized_size(value)?).expect("serialized size exceeds usize"); - - let Some(frame_size) = NonZeroUsize::new(serialized_size) else { - return Err(WriteEncodedError::ZeroSizedFrame); - }; - let Some(mut frame) = self.claim_frame(frame_size) else { - return Err(WriteEncodedError::InsufficientSpace); - }; - - let mut writer: &mut [u8] = &mut frame; - T::serialize_into(&mut writer, value)?; - assert_eq!(writer.len(), 0); - - Ok(()) - } - - #[cfg(test)] - pub fn try_write_frame(&self, frame: &[u8]) -> bool { - let Some(frame_size) = NonZeroUsize::new(frame.len()) else { - return false; - }; - let Some(mut frame_mut) = self.claim_frame(frame_size) else { - return false; - }; - frame_mut.copy_from_slice(frame); - true - } -} - -/// Reader of frames in shared memory created by `ShmWriter`. -pub struct ShmReader> { - mem: M, -} - -impl> ShmReader { - /// The content of `mem` should be created by `ShmWriter`. - /// Failing to do so may result in panics (mostly out-of-bounds), but won't trigger undefined behavior. - /// - /// The `ShmReader` must be created after all writing to the shared memory is done and visible to the calling thread. - /// This is guaranteed by `M: AsRef<[u8]>`, which means the memory region is immutable during the lifetime of `ShmReader`, - /// so no need to mark `ShmReader::new` as unsafe, but care must be taken to create a safe `M` from the shared memory. - pub fn new(mem: M) -> Self { - assert_alignment(mem.as_ref().as_ptr()); - Self { mem } - } - - /// Iterate over all the frames in the shared memory. - pub fn iter_frames(&self) -> impl Iterator { - let mem = self.mem.as_ref(); - let (header, content) = mem - .split_first_chunk::<{ size_of::() }>() - .expect("mem too small to contain header"); - let content_size: usize = must_cast(*header); - let mut remaining_content = &content[..content_size]; - - from_fn(move || { - let frame_size = loop { - // looking for the next valid frame - let (frame_header, next_remaining_content) = - remaining_content.split_first_chunk::<{ size_of::() }>()?; - remaining_content = next_remaining_content; - let frame_header: i32 = must_cast(*frame_header); - match frame_header { - 0 => { - // frame was claimed but never written (crashed process) - // Keep reading until we find a non-zero header - } - 1.. => { - // Partially written frame - skip it and continue - let size = usize::try_from(frame_header).unwrap(); - remaining_content = - &remaining_content[roundup_to_align_frame_header(size)..]; - } - ..0 => { - // Fully written frame (negative size indicates completion) - break usize::try_from(-frame_header).unwrap(); - } - } - }; - - let (frame_with_padding, next_remaining_content) = - remaining_content.split_at(roundup_to_align_frame_header(frame_size)); - remaining_content = next_remaining_content; - - Some(&frame_with_padding[..frame_size]) - }) - } -} - -#[cfg(test)] -mod tests { - use std::{ - process::{Child, Command}, - sync::Arc, - thread, - }; - - use assert2::assert; - use bstr::BStr; - use rustc_hash::FxHashSet; - - use super::*; - - /// A mocked shared memory region for testing. - /// - /// To be testable for miri, the shared memory is allocated using `Arc` instead of real shared memory APIs. - #[derive(Clone)] - struct MockedShm { - // Why usize: to ensure alignment - // - // Why not Arc<[usize]>: - // According to miri, from the perspective of data racing, incrementing ref count of Arc<[T]> - // is considered the same as reading the content of [T], which conflicts with writing to [T] by `ShmWriter`. - // This problem is unrelated to real shared memory. - mem: Arc>, - /// The actual requested byte length. - /// - /// over-allocation might happen to ensure alignment of `usize`, so `mem.len()` might be inaccurate. - len: usize, - } - // SAFETY: `MockedShm` uses `Arc>` for its backing memory, which is safe to send - // across threads. The raw pointer access through `AsRawSlice` is synchronized by `ShmWriter`'s - // atomic operations. - unsafe impl Send for MockedShm {} - // SAFETY: Concurrent access to the shared memory is synchronized by `ShmWriter`'s atomic - // operations. The `Arc` wrapper ensures the allocation remains valid. - unsafe impl Sync for MockedShm {} - impl MockedShm { - fn alloc(len: usize) -> Self { - // allocates this many of usize to fit the requested byte size - let size_in_usize = len / size_of::() + 1; - - let mem: Vec = std::iter::repeat_n(0usize, size_in_usize).collect(); - - Self { mem: Arc::new(mem), len } - } - } - impl AsRef<[u8]> for MockedShm { - fn as_ref(&self) -> &[u8] { - // SAFETY: `Vec::as_ptr` returns a valid pointer to the vec's buffer. The vec is - // allocated with enough `usize` elements to cover `self.len` bytes, and the pointer - // is valid for reads of `self.len` bytes. The `Arc` ensures the allocation is alive. - unsafe { std::slice::from_raw_parts(Vec::as_ptr(&self.mem).cast(), self.len) } - } - } - - impl AsRawSlice for MockedShm { - fn as_raw_slice(&self) -> *mut [u8] { - slice_from_raw_parts_mut(Vec::as_ptr(&self.mem).cast::().cast_mut(), self.len) - } - } - - #[test] - fn single_thread_basic() { - // SAFETY: `MockedShm::alloc` provides a valid, properly-sized, zero-initialized allocation. - let writer = unsafe { ShmWriter::new(MockedShm::alloc(1024)) }; - assert!(writer.try_write_frame(b"hello")); - assert!(writer.try_write_frame(b"world")); - assert!(writer.try_write_frame(b"this is a test")); - assert!(!writer.try_write_frame(&vec![0u8; 2048])); // too large - - let reader = ShmReader::new(writer.into_memory()); - let mut frames = reader.iter_frames(); - assert_eq!(frames.next().unwrap(), b"hello"); - assert_eq!(frames.next().unwrap(), b"world"); - assert_eq!(frames.next().unwrap(), b"this is a test"); - assert_eq!(frames.next(), None); - } - #[test] - fn single_thread_empty() { - // SAFETY: `MockedShm::alloc` provides a valid, properly-sized, zero-initialized allocation. - let writer = unsafe { ShmWriter::new(MockedShm::alloc(1024)) }; - assert!(writer.try_write_frame(b"hello")); - assert!(!writer.try_write_frame(b"")); - assert!(writer.try_write_frame(b"this is a test")); - - let reader = ShmReader::new(writer.into_memory()); - let mut frames = reader.iter_frames(); - assert_eq!(frames.next().unwrap(), b"hello"); - assert_eq!(frames.next().unwrap(), b"this is a test"); - assert_eq!(frames.next(), None); - } - - #[test] - fn single_thread_crash_after_claim() { - // SAFETY: `MockedShm::alloc` provides a valid, properly-sized, zero-initialized allocation. - let mut writer = unsafe { ShmWriter::new(MockedShm::alloc(1024)) }; - assert!(writer.try_write_frame(b"foo")); - - // Simulate crash during writing - writer.set_fail_on_claim(true); - assert!(!writer.try_write_frame(b"hello")); - - writer.set_fail_on_claim(false); - assert!(writer.try_write_frame(b"bar")); - - let reader = ShmReader::new(writer.into_memory()); - let mut frames = reader.iter_frames(); - assert_eq!(frames.next().unwrap(), b"foo"); - assert_eq!(frames.next().unwrap(), b"bar"); - assert_eq!(frames.next(), None); - } - - #[test] - fn single_thread_crash_partial_write() { - // SAFETY: `MockedShm::alloc` provides a valid, properly-sized, zero-initialized allocation. - let writer = unsafe { ShmWriter::new(MockedShm::alloc(1024)) }; - assert!(writer.try_write_frame(b"foo")); - - // Simulate crash during writing - let mut frame = writer.claim_frame(5.try_into().unwrap()).unwrap(); - frame[..3].copy_from_slice(b"wor"); - std::mem::forget(frame); - - assert!(writer.try_write_frame(b"bar")); - - let reader = ShmReader::new(writer.into_memory()); - let mut frames = reader.iter_frames(); - assert_eq!(frames.next().unwrap(), b"foo"); - assert_eq!(frames.next().unwrap(), b"bar"); - assert_eq!(frames.next(), None); - } - - #[test] - fn single_thread_two_crashes_after_claim_and_partial_write() { - // This test verifies that ShmReader::iter correctly handles MULTIPLE consecutive - // invalid frames by continuing the loop. It's crucial for testing - // that the reader doesn't stop at the first invalid frame but keeps processing - // through multiple crash scenarios to find valid frames beyond them. - - // SAFETY: `MockedShm::alloc` provides a valid, properly-sized, zero-initialized allocation. - let mut writer = unsafe { ShmWriter::new(MockedShm::alloc(1024)) }; - - assert!(writer.try_write_frame(b"foo")); - - // First crash: AfterClaim (leaves frame header as 0) - writer.set_fail_on_claim(true); - assert!(!writer.try_write_frame(b"world")); - writer.set_fail_on_claim(false); - - // Second crash: PartialWrite (leaves positive frame header) - let mut frame = writer.claim_frame(5.try_into().unwrap()).unwrap(); - frame[..3].copy_from_slice(b"wor"); - std::mem::forget(frame); - - assert!(writer.try_write_frame(b"bar")); - - // ShmReader must skip BOTH invalid frames (0 header + partial header) - // and find the valid frame beyond them - this tests the loop continuation - - let reader = ShmReader::new(writer.into_memory()); - let mut frames = reader.iter_frames(); - assert_eq!(frames.next().unwrap(), b"foo"); - assert_eq!(frames.next().unwrap(), b"bar"); - assert_eq!(frames.next(), None); - } - - #[test] - fn single_thread_two_crashes_partial_write_and_after_claim() { - // SAFETY: `MockedShm::alloc` provides a valid, properly-sized, zero-initialized allocation. - let mut writer = unsafe { ShmWriter::new(MockedShm::alloc(1024)) }; - // This test verifies the same loop continuation behavior but with crashes - // in reverse order. This ensures the loop correctly handles different - // sequences of invalid frame types (partial write -> after claim). - - assert!(writer.try_write_frame(b"foo")); - - // First crash: PartialWrite (leaves positive frame header) - let mut frame = writer.claim_frame(5.try_into().unwrap()).unwrap(); - frame[..3].copy_from_slice(b"wor"); - std::mem::forget(frame); - - // Second crash: AfterClaim (leaves frame header as 0) - writer.set_fail_on_claim(true); - assert!(!writer.try_write_frame(b"world")); - writer.set_fail_on_claim(false); - - assert!(writer.try_write_frame(b"bar")); - - let reader = ShmReader::new(writer.into_memory()); - // ShmReader must skip BOTH invalid frames in this order and continue - // processing to find valid frames - tests loop robustness - let mut frames = reader.iter_frames(); - assert_eq!(frames.next().unwrap(), b"foo"); - assert_eq!(frames.next().unwrap(), b"bar"); - assert_eq!(frames.next(), None); - } - - #[test] - fn concurrent() { - let shm = MockedShm::alloc(1024 * 4); - - thread::scope(|s| { - for _ in 0..4 { - s.spawn(|| { - // SAFETY: `MockedShm::alloc` provides a valid, properly-sized, zero-initialized - // allocation. The clone shares the same backing memory, which is safe because - // `ShmWriter` uses atomic operations for concurrent access. - let writer = unsafe { ShmWriter::new(shm.clone()) }; - for _ in 0..10 { - assert!(writer.try_write_frame(b"hello")); - assert!(writer.try_write_frame(b"foo")); - assert!(writer.try_write_frame(b"this is a test")); - } - }); - } - }); - let mut count = 0; - let reader = ShmReader::new(shm); - for frame in reader.iter_frames() { - count += 1; - let frame = BStr::new(frame); - assert!(frame == b"hello" || frame == b"foo" || frame == b"this is a test"); - } - assert_eq!(count, 120); - } - - #[test] - fn concurrent_exceeded_size() { - // SAFETY: `MockedShm::alloc` provides a valid, properly-sized, zero-initialized allocation. - let writer = unsafe { ShmWriter::new(MockedShm::alloc(1024)) }; - thread::scope(|s| { - for _ in 0..4 { - s.spawn(|| { - for _ in 0..10 { - writer.try_write_frame(b"hello"); - writer.try_write_frame(b"foo"); - writer.try_write_frame(b"this is a test"); - } - }); - } - }); - let mut count = 0; - let reader = ShmReader::new(writer.into_memory()); - for frame in reader.iter_frames() { - count += 1; - let frame = BStr::new(frame); - assert!(frame == b"hello" || frame == b"foo" || frame == b"this is a test"); - } - assert!(count > 50); - } - - #[test] - fn test_integer_overflow_space_calculation() { - // Test case for potential integer overflow in space calculation - - // SAFETY: `MockedShm::alloc` provides a valid, properly-sized, zero-initialized allocation. - let writer = unsafe { ShmWriter::new(MockedShm::alloc(1024)) }; - - // Try to trigger integer overflow by using maximum values - let large_frame = vec![0u8; (i32::MAX as usize) - 100]; - - // This should fail safely, not cause overflow - assert!(!writer.try_write_frame(&large_frame)); - - // Small frame should still work - assert!(writer.try_write_frame(b"test")); - - let reader = ShmReader::new(writer.into_memory()); - let mut frames = reader.iter_frames(); - assert_eq!(frames.next().unwrap(), b"test"); - assert_eq!(frames.next(), None); - } - - #[test] - fn test_space_calculation_race_condition() { - // Test for race condition in space calculation where multiple threads - // might calculate overlapping space requirements - - // SAFETY: `MockedShm::alloc` provides a valid, properly-sized, zero-initialized allocation. - let writer = unsafe { ShmWriter::new(MockedShm::alloc(200)) }; - - // Very small buffer - thread::scope(|s| { - for _ in 0..10 { - s.spawn(|| { - // Many threads trying to write large-ish frames - writer - .try_write_frame(b"this_is_a_moderately_long_frame_that_might_cause_races"); - }); - } - }); - - // The exact count doesn't matter, but the reader should not panic - // and should handle any race conditions gracefully - - let reader = ShmReader::new(writer.into_memory()); - let mut count = 0; - for _frame in reader.iter_frames() { - count += 1; - } - // At least some but not all writes should succeed - assert!(count > 0); - assert!(count < 10); - } - - #[test] - fn test_alignment_violation_detection() { - struct Misaligned(MockedShm); - impl AsRawSlice for Misaligned { - fn as_raw_slice(&self) -> *mut [u8] { - let raw_slice = self.0.as_raw_slice(); - slice_from_raw_parts_mut( - // SAFETY: Adding 1 byte to create a deliberately misaligned pointer for testing. - // The original allocation is large enough that adding 1 byte stays within bounds. - unsafe { raw_slice.cast::().add(1) }, - raw_slice.len() - 1, - ) - } - } - // Test that alignment violations are properly detected - - // Allocate memory with proper alignment first - let shm = MockedShm::alloc(64); - - // Create a deliberately misaligned pointer by adding 1 byte - // This ensures the pointer is NOT aligned to usize boundary - let misaligned_shm = Misaligned(shm); - - // Verify the pointer is actually misaligned - assert_ne!(misaligned_shm.as_raw_slice().cast::() as usize % align_of::(), 0); - - // This should panic due to alignment assertion - let result = std::panic::catch_unwind(|| { - // SAFETY: Intentionally passing a misaligned pointer to test that the alignment - // assertion in `ShmWriter::new` correctly panics. This is expected to panic. - unsafe { ShmWriter::new(misaligned_shm) }; - }); - - // Verify that the alignment check properly caught the violation - assert!(result.is_err(), "Should panic on misaligned pointer"); - } - - #[test] - #[cfg(not(miri))] - fn real_shm_across_processes() { - use subprocess_test::command_for_fn; - - const CHILD_COUNT: usize = 12; - const FRAME_COUNT_EACH_CHILD: usize = 100; - - const SHM_SIZE: usize = 1024 * 1024; - - let shm_path = crate::ipc::channel::shm_backing_path().unwrap(); - let shm_name = shm_path.to_str().expect("test temp dir is UTF-8").to_owned(); - let c_path = crate::ipc::channel::os_c_string(shm_path.as_os_str()).unwrap(); - let handle = fspy_shm::create(c_path.as_c_str().as_thin(), SHM_SIZE).unwrap(); - let _keeper = crate::ipc::channel::ShmKeeper { path: c_path }; - // Map before the children run. Windows keeps views coherent while they - // exist at the same time; a view created after every writer exited can - // observe the file before the writers' dirty pages reach it. - let mapping = handle.map().unwrap(); - - let children: Vec = (0..CHILD_COUNT) - .map(|child_index| { - let cmd = command_for_fn!( - (shm_name.clone(), child_index), - |(shm_name, child_index): (String, usize)| { - let c_path = - crate::ipc::channel::os_c_string(std::ffi::OsStr::new(&shm_name)) - .unwrap(); - let mapping = - fspy_shm::open(c_path.as_c_str().as_thin()).unwrap().map().unwrap(); - // SAFETY: `mapping` is a freshly mapped shared memory region with a - // valid pointer and size. Concurrent write access is safe because - // `ShmWriter` uses atomic operations. - let writer = unsafe { ShmWriter::new(mapping) }; - for i in 0..FRAME_COUNT_EACH_CHILD { - let frame_data = std::format!("{child_index} {i}"); - assert!(writer.try_write_frame(frame_data.as_bytes())); - } - } - ); - Command::from(cmd).spawn().unwrap() - }) - .collect(); - - for mut c in children { - let status = c.wait().unwrap(); - assert!(status.success()); - } - - // SAFETY: All child processes have exited (waited above), so no concurrent writers exist. - // The shared memory is valid and fully written. - let shm = unsafe { mapping.as_slice() }; - let reader = ShmReader::new(shm); - let frames = reader.iter_frames().map(BStr::new).collect::>(); - assert_eq!(frames.len(), CHILD_COUNT * FRAME_COUNT_EACH_CHILD); - for child_index in 0..CHILD_COUNT { - for i in 0..FRAME_COUNT_EACH_CHILD { - let frame_data = format!("{child_index} {i}"); - assert!(frames.contains(&BStr::new(frame_data.as_bytes()))); - } - } - } -} diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/alloc_word.rs b/crates/fspy_shared/src/ipc/channel/shm_io/alloc_word.rs new file mode 100644 index 000000000..cd862bf50 --- /dev/null +++ b/crates/fspy_shared/src/ipc/channel/shm_io/alloc_word.rs @@ -0,0 +1,225 @@ +//! Encoding of the allocator word — the single 64-bit value that admits +//! claims, closes the channel, and records lost frames. +//! +//! ```text +//! bit 63 bit 62 bits 32..=61 bits 0..=31 +//! CLOSED INCOMPLETE slot count (30) reserved payload bytes (32) +//! ``` +//! +//! - `CLOSED`: set once by the receiver; no claim is admitted afterwards. +//! - `INCOMPLETE`: set by a writer that lost a frame it may still act on +//! (capacity exhaustion, or abandoning a claimed frame while alive). The +//! receiver treats the trace as unusable for caching when this is set. +//! A frame lost to process death deliberately does *not* set this flag: +//! frames are published before the traced operation is performed, so a +//! process that died mid-frame never performed the operation. +//! - slot count / reserved payload bytes: the two region frontiers. Claims +//! move both in one compare-and-swap, so the regions can never overlap and +//! the receiver's close snapshot counts every admitted slot. +//! +//! This module is pure bit manipulation; the atomic operations applying these +//! values live in [`super::state`]. + +use super::layout; + +pub(super) const CLOSED: u64 = 1 << 63; +pub(super) const INCOMPLETE: u64 = 1 << 62; +const SLOT_COUNT_SHIFT: u32 = 32; +const SLOT_COUNT_MAX: u64 = (1 << 30) - 1; +const PAYLOAD_BYTES_MAX: u64 = u32::MAX as u64; + +/// A decoded snapshot of the allocator word. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(super) struct AllocWord(u64); + +/// Why a claim was not admitted. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(super) enum ReserveError { + /// The receiver has closed the channel. + Closed, + /// The frame does not fit: it is larger than [`layout::MAX_PAYLOAD_LEN`], + /// or the descriptor-table and payload frontiers would meet. + Capacity, +} + +/// A successful reservation of one descriptor slot and one payload span. +#[derive(Debug)] +pub(super) struct Reservation { + /// The allocator word to install with compare-and-swap. + pub(super) new_word: AllocWord, + /// Index of the reserved descriptor slot. + pub(super) slot_index: usize, + /// Byte offset of the reserved payload span. Word-aligned. + pub(super) payload_offset: usize, +} + +impl AllocWord { + pub(super) const fn from_bits(bits: u64) -> Self { + Self(bits) + } + + pub(super) const fn bits(self) -> u64 { + self.0 + } + + pub(super) const fn is_closed(self) -> bool { + self.0 & CLOSED != 0 + } + + pub(super) const fn is_incomplete(self) -> bool { + self.0 & INCOMPLETE != 0 + } + + pub(super) const fn slot_count(self) -> usize { + ((self.0 >> SLOT_COUNT_SHIFT) & SLOT_COUNT_MAX) as usize + } + + pub(super) const fn reserved_payload_bytes(self) -> usize { + (self.0 & PAYLOAD_BYTES_MAX) as usize + } + + /// Whether this word describes in-bounds regions of a `mapping_len`-byte + /// mapping. False only for words a correct writer never produces. + pub(super) const fn is_valid_for(self, mapping_len: usize) -> bool { + layout::fits(mapping_len, self.slot_count(), self.reserved_payload_bytes()) + } + + /// Computes the reservation of one slot and a word-aligned span for a + /// `payload_len`-byte payload, or reports why the claim is not admitted. + /// + /// Pure: the caller must install `new_word` with a compare-and-swap + /// against the word this was computed from. + pub(super) const fn reserve( + self, + payload_len: usize, + mapping_len: usize, + ) -> Result { + if self.is_closed() { + return Err(ReserveError::Closed); + } + if payload_len > layout::MAX_PAYLOAD_LEN { + return Err(ReserveError::Capacity); + } + let slot_index = self.slot_count(); + let new_slot_count = slot_index + 1; + let new_payload_bytes = + self.reserved_payload_bytes() + layout::reserved_payload_len(payload_len); + if new_slot_count as u64 > SLOT_COUNT_MAX + || new_payload_bytes as u64 > PAYLOAD_BYTES_MAX + || !layout::fits(mapping_len, new_slot_count, new_payload_bytes) + { + return Err(ReserveError::Capacity); + } + Ok(Reservation { + new_word: Self( + (self.0 & INCOMPLETE) + | ((new_slot_count as u64) << SLOT_COUNT_SHIFT) + | new_payload_bytes as u64, + ), + slot_index, + // `fits` guarantees `new_payload_bytes <= mapping_len`. + payload_offset: mapping_len - new_payload_bytes, + }) + } +} + +#[cfg(test)] +mod tests { + use assert2::assert; + + use super::*; + + const MAPPING_LEN: usize = 1024; + + #[test] + fn zero_word_is_open_and_empty() { + let word = AllocWord::from_bits(0); + assert!(!word.is_closed()); + assert!(!word.is_incomplete()); + assert!(word.slot_count() == 0); + assert!(word.reserved_payload_bytes() == 0); + assert!(word.is_valid_for(layout::HEADER_LEN)); + } + + #[test] + fn reserve_advances_both_frontiers() { + let word = AllocWord::from_bits(0); + let reservation = word.reserve(5, MAPPING_LEN).unwrap(); + assert!(reservation.slot_index == 0); + assert!(reservation.payload_offset == MAPPING_LEN - 8); + assert!(reservation.new_word.slot_count() == 1); + assert!(reservation.new_word.reserved_payload_bytes() == 8); + + let second = reservation.new_word.reserve(9, MAPPING_LEN).unwrap(); + assert!(second.slot_index == 1); + assert!(second.payload_offset == MAPPING_LEN - 8 - 16); + assert!(second.new_word.slot_count() == 2); + assert!(second.new_word.reserved_payload_bytes() == 24); + } + + #[test] + fn reserve_rejects_closed() { + let word = AllocWord::from_bits(CLOSED); + assert!(word.reserve(1, MAPPING_LEN).unwrap_err() == ReserveError::Closed); + } + + #[test] + fn reserve_preserves_incomplete() { + let word = AllocWord::from_bits(INCOMPLETE); + let reservation = word.reserve(1, MAPPING_LEN).unwrap(); + assert!(reservation.new_word.is_incomplete()); + assert!(!reservation.new_word.is_closed()); + } + + #[test] + fn reserve_rejects_oversized_payloads() { + let word = AllocWord::from_bits(0); + assert!( + word.reserve(layout::MAX_PAYLOAD_LEN + 1, usize::MAX).unwrap_err() + == ReserveError::Capacity + ); + } + + #[test] + fn reserve_stops_at_the_frontier_collision() { + // 80 bytes fit the header, one slot, and one 8-byte payload span. + let word = AllocWord::from_bits(0); + let reservation = word.reserve(8, 80).unwrap(); + assert!(reservation.payload_offset == 72); + assert!(reservation.new_word.reserve(1, 80).unwrap_err() == ReserveError::Capacity); + // A failed reservation leaves the word untouched by construction: + // `reserve` is pure and the caller never installs a failed result. + } + + #[test] + fn reserve_handles_the_4_gib_mapping_edge() { + let mapping_len = layout::MAX_MAPPING_LEN; + let max_payload = mapping_len - layout::HEADER_LEN - layout::SLOT_LEN; + // One frame cannot exceed MAX_PAYLOAD_LEN even if the mapping has room. + assert!( + AllocWord::from_bits(0).reserve(max_payload, mapping_len).unwrap_err() + == ReserveError::Capacity + ); + + // Fill the payload region with maximal frames until the 32-bit + // reserved-bytes field would have to exceed its width; the capacity + // check must fail first. + let mut word = AllocWord::from_bits(0); + loop { + match word.reserve(layout::MAX_PAYLOAD_LEN, mapping_len) { + Ok(reservation) => { + assert!(reservation.new_word.is_valid_for(mapping_len)); + word = reservation.new_word; + } + Err(err) => { + assert!(err == ReserveError::Capacity); + break; + } + } + } + assert!(word.slot_count() == 1); + // The remaining space still admits smaller frames. + let reservation = word.reserve(1024, mapping_len).unwrap(); + assert!(reservation.new_word.is_valid_for(mapping_len)); + } +} diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs new file mode 100644 index 000000000..552fa5d5c --- /dev/null +++ b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs @@ -0,0 +1,191 @@ +//! Pure geometry of the shared-memory region. +//! +//! The mapping is divided into three areas: +//! +//! ```text +//! low addresses high addresses +//! | header | descriptor table (grows up) | free | payloads (grow down) | +//! ``` +//! +//! Everything in this module is arithmetic on plain integers — no atomics, +//! no pointers, no shared state. Overflow safety follows from two bounds +//! enforced at construction time and re-validated on every value read back +//! from shared memory: the mapping length never exceeds [`MAX_MAPPING_LEN`] +//! (so all offsets fit in the 32-bit descriptor fields) and all region +//! arithmetic is performed in `usize` on 64-bit targets (asserted in the +//! parent module), where sums of 32-bit-bounded quantities cannot overflow. + +/// Byte size of the region header. +/// +/// Only the first 8 bytes (the allocator word) are used; the rest keeps the +/// descriptor table off the allocator word's cache line and leaves room for +/// future header fields, which must start zeroed. +pub(super) const HEADER_LEN: usize = 64; + +/// Byte size of one descriptor slot. +pub(super) const SLOT_LEN: usize = size_of::(); + +/// Maximum payload size of a single frame. +/// +/// Committed lengths are stored in the 31-bit length field of a descriptor, +/// which caps them at `i32::MAX` — the same frame-size limit as the previous +/// inline-header format. +pub(super) const MAX_PAYLOAD_LEN: usize = i32::MAX as usize; + +/// Maximum supported mapping size. +/// +/// Payload offsets and the reserved-payload counter are stored in 32 bits, so +/// every byte offset into the mapping must fit in `u32` arithmetic; a mapping +/// of exactly 4 GiB works because no payload can start at the very end. +pub(super) const MAX_MAPPING_LEN: usize = 1 << 32; + +/// Rounds a payload length up to a multiple of the word size. +/// +/// Payload reservations are word-aligned — combined with the word-aligned +/// [`usable_len`] they grow down from, this keeps every payload offset +/// word-aligned so the receiver can copy payloads with aligned 64-bit atomic +/// loads, and the sub-word padding stays inside the frame's own reservation. +pub(super) const fn reserved_payload_len(payload_len: usize) -> usize { + payload_len.next_multiple_of(SLOT_LEN) +} + +/// The protocol-usable prefix of a mapping: its length rounded down to word +/// alignment, so payload spans growing from the end stay word-aligned even +/// when the creator requested an odd capacity. +pub(super) const fn usable_len(mapping_len: usize) -> usize { + mapping_len - mapping_len % SLOT_LEN +} + +/// First byte offset after a descriptor table of `slot_count` slots. +pub(super) const fn table_end(slot_count: usize) -> usize { + HEADER_LEN + slot_count * SLOT_LEN +} + +/// Whether a mapping of `mapping_len` bytes can hold `slot_count` descriptors +/// and `payload_bytes` reserved payload bytes without the regions meeting. +/// +/// Also the validity check for an allocator word read back from shared +/// memory: any word this function accepts yields in-bounds table and payload +/// regions. +pub(super) const fn fits(mapping_len: usize, slot_count: usize, payload_bytes: usize) -> bool { + // Both operands are bounded by their allocator-word field widths (30 and + // 32 bits), so the sum cannot overflow 64-bit `usize` arithmetic. + table_end(slot_count) + payload_bytes <= mapping_len +} + +/// A validated payload byte range: the witness that offset arithmetic on this +/// span cannot leave the mapping or touch the descriptor table. +/// +/// Constructing a `PayloadSpan` through [`PayloadSpan::validate`] is the +/// single validation point for descriptor metadata read back from shared +/// memory; code holding a span may rely on its bounds without re-checking. +#[derive(Clone, Copy, Debug)] +pub(super) struct PayloadSpan { + /// Byte offset of the payload from the start of the mapping. + /// Always word-aligned. + pub(super) offset: usize, + /// Exact (unpadded) byte length of the payload. + pub(super) len: usize, +} + +impl PayloadSpan { + /// Validates a committed descriptor's payload range against the final + /// region layout. Returns `None` if the range could not have been + /// produced by a correct writer. + pub(super) const fn validate( + mapping_len: usize, + table_end: usize, + offset: usize, + len: usize, + ) -> Option { + if len == 0 || len > MAX_PAYLOAD_LEN { + return None; + } + // Writers reserve word-aligned spans from the word-aligned mapping + // end, so a valid offset is word-aligned and its padded length stays + // inside the mapping. + if !offset.is_multiple_of(SLOT_LEN) { + return None; + } + // `offset` and `len` come from 32-bit descriptor fields, so this sum + // cannot overflow `usize`. + if offset < table_end || offset + reserved_payload_len(len) > mapping_len { + return None; + } + Some(Self { offset, len }) + } + + /// The word-aligned length of the reservation containing this payload. + pub(super) const fn reserved_len(self) -> usize { + reserved_payload_len(self.len) + } +} + +#[cfg(test)] +mod tests { + use assert2::assert; + + use super::*; + + #[test] + fn reserved_payload_len_rounds_up_to_words() { + assert!(reserved_payload_len(1) == 8); + assert!(reserved_payload_len(7) == 8); + assert!(reserved_payload_len(8) == 8); + assert!(reserved_payload_len(9) == 16); + assert!(reserved_payload_len(MAX_PAYLOAD_LEN) == MAX_PAYLOAD_LEN + 1); + } + + #[test] + fn usable_len_rounds_down_to_words() { + assert!(usable_len(100) == 96); + assert!(usable_len(96) == 96); + assert!(usable_len(MAX_MAPPING_LEN) == MAX_MAPPING_LEN); + } + + #[test] + fn table_end_starts_after_header() { + assert!(table_end(0) == HEADER_LEN); + assert!(table_end(3) == HEADER_LEN + 24); + } + + #[test] + fn fits_detects_frontier_collision() { + // 64-byte header + 1 slot + 8 payload bytes exactly fill 80 bytes. + assert!(fits(80, 1, 8)); + assert!(!fits(80, 1, 16)); + assert!(!fits(80, 2, 8)); + assert!(!fits(72, 1, 8)); + } + + #[test] + fn fits_handles_the_4_gib_mapping() { + let max_payload = MAX_MAPPING_LEN - HEADER_LEN - SLOT_LEN; + assert!(fits(MAX_MAPPING_LEN, 1, max_payload)); + assert!(!fits(MAX_MAPPING_LEN, 1, max_payload + 8)); + // The largest admissible slot count leaves no payload space. + let max_slots = (MAX_MAPPING_LEN - HEADER_LEN) / SLOT_LEN; + assert!(fits(MAX_MAPPING_LEN, max_slots, 0)); + assert!(!fits(MAX_MAPPING_LEN, max_slots + 1, 0)); + } + + #[test] + fn payload_span_validates_bounds() { + let table_end = table_end(2); + // A word-aligned span inside the payload region. + assert!(PayloadSpan::validate(1024, table_end, 1016, 8).is_some()); + // Exact end of the mapping. + assert!(PayloadSpan::validate(1024, table_end, 1016, 5).is_some()); + // Zero length is never committed. + assert!(PayloadSpan::validate(1024, table_end, 512, 0).is_none()); + // Padded length may not cross the end of the mapping. + assert!(PayloadSpan::validate(1024, table_end, 1020, 8).is_none()); + assert!(PayloadSpan::validate(1024, table_end, 1016, 9).is_none()); + // Payloads may not reach into the descriptor table. + assert!(PayloadSpan::validate(1024, table_end, table_end - 8, 8).is_none()); + // Unaligned offsets cannot come from a correct writer. + assert!(PayloadSpan::validate(1024, table_end, 1017, 7).is_none()); + // Oversized lengths are rejected before any arithmetic. + assert!(PayloadSpan::validate(1024, table_end, 512, MAX_PAYLOAD_LEN + 1).is_none()); + } +} diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs new file mode 100644 index 000000000..edc3aeab4 --- /dev/null +++ b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs @@ -0,0 +1,696 @@ +//! Crash-tolerant, nonblocking frame publication in a shared memory region. +//! +//! Multiple writer processes append variable-length frames concurrently; one +//! receiver closes the channel and collects every committed frame without +//! waiting for any writer. A process may die at any instruction — mid-claim, +//! mid-write, pre-commit — and only its own unfinished frame is lost. +//! +//! # Region layout +//! +//! ```text +//! low addresses high addresses +//! +--------+--------+--------+-------+------+-----------+-----------+ +//! | header | slot 0 | slot 1 | ... | free | payload 1 | payload 0 | +//! +--------+--------+--------+-------+------+-----------+-----------+ +//! descriptor table grows -> <- payloads grow +//! ``` +//! +//! The header holds one allocator word ([`alloc_word`]) that admits claims, +//! closes the channel, and records lost frames. Each frame owns one atomic +//! descriptor slot ([`slot`]) and one payload span; a claim reserves both +//! with a single compare-and-swap, so the two regions never overlap and an +//! unfinished frame can never hide a later one. +//! +//! # Frame lifecycle +//! +//! ```text +//! writer commit CAS wins +//! +-----------------------------> COMMITTED (readable) +//! CLAIMED (slot 0) ---+ +//! +-----------------------------> ABORTED (ignored) +//! receiver freeze CAS wins +//! ``` +//! +//! A payload becomes reachable only through its committed descriptor, and a +//! descriptor is committed only after the payload is fully written +//! ([`state`]'s ordering contract). The receiver never derives frame +//! locations from payload bytes and never references memory a live writer +//! may still mutate — committed payloads are copied out with atomic loads. +//! +//! # Close boundary +//! +//! [`close`] admits no further claims; an already admitted writer races the +//! freeze pass per slot and its frame is either included (commit won) or +//! ignored (abort won) — never torn. Ignoring unfinished frames is sound +//! because writers publish a record *before* performing the recorded +//! operation: a process that died mid-frame never performed the operation, +//! and one that lost the close race performs it outside the run's tracking +//! boundary. A live writer that loses a record *before* close (capacity, +//! abandonment) flags the trace incomplete ([`Frames::is_complete`]). +//! +//! Correctness never depends on writer-side cleanup: no exit hooks, PID +//! checks, heartbeats, or timeouts. + +mod alloc_word; +mod layout; +mod reader; +mod slot; +mod state; +mod writer; + +use std::ptr::slice_from_raw_parts_mut; + +use fspy_shm::Mapping; +pub use reader::{Frames, ProtocolError}; +pub use writer::{ClaimError, FrameMut, ShmWriter, WriteEncodedError}; + +// The region arithmetic in `layout` relies on `usize` accommodating sums of +// 32-bit-bounded quantities, and the descriptor protocol on native 64-bit +// atomics. +const _: () = assert!( + size_of::() >= size_of::(), + "the shared-memory frame protocol requires a 64-bit target" +); + +/// A trait to borrow a raw memory region. +pub trait AsRawSlice { + fn as_raw_slice(&self) -> *mut [u8]; +} + +impl AsRawSlice for Mapping { + fn as_raw_slice(&self) -> *mut [u8] { + slice_from_raw_parts_mut(self.as_ptr(), self.len()) + } +} + +/// Closes the channel and collects the committed frames without waiting for +/// writers. See [`reader::close`]. +/// +/// # Safety +/// +/// Same contract as [`ShmWriter::new`]: the region must be stable and valid, +/// zero-initialized at creation, and accessed only through this protocol. +pub unsafe fn close(mem: &impl AsRawSlice) -> Result { + // SAFETY: forwarded from this function's contract. + unsafe { reader::close(mem.as_raw_slice()) } +} + +#[cfg(test)] +mod tests { + use std::{ + sync::{ + Arc, Barrier, + atomic::{AtomicU64, Ordering}, + }, + thread, + }; + + use assert2::assert; + use bstr::BStr; + + use super::*; + + /// A mocked shared memory region for testing. + /// + /// To be testable for miri, the shared memory is allocated using `Arc` + /// instead of real shared memory APIs. + #[derive(Clone)] + struct MockedShm { + // Why usize: to ensure alignment + // + // Why not Arc<[T]>: + // According to miri, from the perspective of data racing, incrementing + // the ref count of Arc<[T]> is considered the same as reading the + // content of [T], which conflicts with writing to [T] by `ShmWriter`. + // This problem is unrelated to real shared memory. + mem: Arc>, + /// The actual requested byte length. + /// + /// Over-allocation might happen to ensure alignment of `usize`, so + /// `mem.len()` might be inaccurate. + len: usize, + } + // SAFETY: `MockedShm` uses `Arc>` for its backing memory, which + // is safe to send across threads. The raw pointer access through + // `AsRawSlice` is synchronized by the protocol's atomic operations. + unsafe impl Send for MockedShm {} + // SAFETY: Concurrent access to the shared memory is synchronized by the + // protocol's atomic operations. The `Arc` keeps the allocation alive. + unsafe impl Sync for MockedShm {} + impl MockedShm { + fn alloc(len: usize) -> Self { + // allocates this many of usize to fit the requested byte size + let size_in_usize = len / size_of::() + 1; + + let mem: Vec = std::iter::repeat_n(0usize, size_in_usize).collect(); + + Self { mem: Arc::new(mem), len } + } + + /// Overwrites a raw word of the region, simulating foreign-process + /// corruption of protocol metadata. + fn poke_word(&self, byte_offset: usize, value: u64) { + // SAFETY: the offsets used by tests lie within the allocation and + // are word-aligned; the atomic store synchronizes with the + // protocol's atomic accesses of the same word. + let atomic = unsafe { + AtomicU64::from_ptr(self.as_raw_slice().cast::().add(byte_offset).cast()) + }; + atomic.store(value, Ordering::Relaxed); + } + } + + impl AsRawSlice for MockedShm { + fn as_raw_slice(&self) -> *mut [u8] { + slice_from_raw_parts_mut(Vec::as_ptr(&self.mem).cast::().cast_mut(), self.len) + } + } + + fn collect_frames(shm: &MockedShm) -> Frames { + // SAFETY: `MockedShm` provides a stable, zero-initialized allocation + // accessed only through the protocol. + unsafe { close(shm) }.unwrap() + } + + #[test] + fn single_thread_basic() { + let shm = MockedShm::alloc(1024); + // SAFETY: `MockedShm::alloc` provides a valid, properly-sized, + // zero-initialized allocation. + let writer = unsafe { ShmWriter::new(shm.clone()) }; + assert!(writer.try_write_frame(b"hello")); + assert!(writer.try_write_frame(b"world")); + assert!(writer.try_write_frame(b"this is a test")); + + let frames = collect_frames(&shm); + let mut iter = frames.iter(); + assert!(iter.next().unwrap() == b"hello"); + assert!(iter.next().unwrap() == b"world"); + assert!(iter.next().unwrap() == b"this is a test"); + assert!(iter.next() == None); + assert!(frames.is_complete()); + } + + #[test] + fn zero_sized_frames_are_rejected() { + let shm = MockedShm::alloc(1024); + // SAFETY: see `single_thread_basic`. + let writer = unsafe { ShmWriter::new(shm.clone()) }; + assert!(writer.try_write_frame(b"hello")); + assert!(!writer.try_write_frame(b"")); + + let frames = collect_frames(&shm); + let mut iter = frames.iter(); + assert!(iter.next().unwrap() == b"hello"); + assert!(iter.next() == None); + } + + #[test] + fn multi_word_frame_roundtrips_exactly() { + let shm = MockedShm::alloc(1024); + // SAFETY: see `single_thread_basic`. + let writer = unsafe { ShmWriter::new(shm.clone()) }; + let pattern: Vec = (0..=99).collect(); + assert!(writer.try_write_frame(&pattern)); + + let frames = collect_frames(&shm); + let mut iter = frames.iter(); + assert!(iter.next().unwrap() == pattern.as_slice()); + assert!(iter.next() == None); + } + + #[test] + fn oversized_frame_fails_and_marks_incomplete() { + let shm = MockedShm::alloc(1024); + // SAFETY: see `single_thread_basic`. + let writer = unsafe { ShmWriter::new(shm.clone()) }; + + // Larger than the mapping, and larger than the absolute frame limit: + // both fail the claim without touching the frontiers. + assert!(!writer.try_write_frame(&vec![0u8; 2048])); + assert!( + writer.claim_frame(((i32::MAX as usize) + 1).try_into().unwrap()).unwrap_err() + == ClaimError::Capacity + ); + + // Small frames still fit afterwards. + assert!(writer.try_write_frame(b"test")); + + let frames = collect_frames(&shm); + let mut iter = frames.iter(); + assert!(iter.next().unwrap() == b"test"); + assert!(iter.next() == None); + // The failed claims lost records while their process lived on. + assert!(!frames.is_complete()); + } + + #[test] + fn crash_after_claim_is_skipped() { + let shm = MockedShm::alloc(1024); + // SAFETY: see `single_thread_basic`. + let writer = unsafe { ShmWriter::new(shm.clone()) }; + assert!(writer.try_write_frame(b"foo")); + + // Simulate a crash right after claiming: no drop code runs. + let frame = writer.claim_frame(5.try_into().unwrap()).unwrap(); + std::mem::forget(frame); + + assert!(writer.try_write_frame(b"bar")); + + let frames = collect_frames(&shm); + let mut iter = frames.iter(); + assert!(iter.next().unwrap() == b"foo"); + assert!(iter.next().unwrap() == b"bar"); + assert!(iter.next() == None); + // Death loses no performed operation, so the trace stays complete. + assert!(frames.is_complete()); + } + + #[test] + fn crash_during_partial_write_is_skipped() { + let shm = MockedShm::alloc(1024); + // SAFETY: see `single_thread_basic`. + let writer = unsafe { ShmWriter::new(shm.clone()) }; + assert!(writer.try_write_frame(b"foo")); + + // Simulate a crash during writing. + let mut frame = writer.claim_frame(5.try_into().unwrap()).unwrap(); + frame[..3].copy_from_slice(b"wor"); + std::mem::forget(frame); + + assert!(writer.try_write_frame(b"bar")); + + let frames = collect_frames(&shm); + let mut iter = frames.iter(); + assert!(iter.next().unwrap() == b"foo"); + assert!(iter.next().unwrap() == b"bar"); + assert!(iter.next() == None); + assert!(frames.is_complete()); + } + + #[test] + fn consecutive_crashes_do_not_hide_later_frames() { + // Two unfinished slots in a row, in both orders, must not prevent the + // receiver from finding the valid frames around them. + let shm = MockedShm::alloc(1024); + // SAFETY: see `single_thread_basic`. + let writer = unsafe { ShmWriter::new(shm.clone()) }; + + assert!(writer.try_write_frame(b"foo")); + + // Crash after claim (slot stays zero, payload untouched). + std::mem::forget(writer.claim_frame(5.try_into().unwrap()).unwrap()); + + // Crash mid-write (slot stays zero, payload partially filled). + let mut frame = writer.claim_frame(7.try_into().unwrap()).unwrap(); + frame[..3].copy_from_slice(b"wor"); + std::mem::forget(frame); + + assert!(writer.try_write_frame(b"bar")); + + let frames = collect_frames(&shm); + let mut iter = frames.iter(); + assert!(iter.next().unwrap() == b"foo"); + assert!(iter.next().unwrap() == b"bar"); + assert!(iter.next() == None); + assert!(frames.is_complete()); + } + + #[test] + fn abandoned_frame_marks_the_trace_incomplete() { + let shm = MockedShm::alloc(1024); + // SAFETY: see `single_thread_basic`. + let writer = unsafe { ShmWriter::new(shm.clone()) }; + assert!(writer.try_write_frame(b"foo")); + + // A live writer dropping an unfinished frame abandons a record it + // may still act on. + drop(writer.claim_frame(5.try_into().unwrap()).unwrap()); + + let frames = collect_frames(&shm); + let mut iter = frames.iter(); + assert!(iter.next().unwrap() == b"foo"); + assert!(iter.next() == None); + assert!(!frames.is_complete()); + } + + #[test] + fn claim_after_close_fails_without_poisoning() { + let shm = MockedShm::alloc(1024); + // SAFETY: see `single_thread_basic`. + let writer = unsafe { ShmWriter::new(shm.clone()) }; + assert!(writer.try_write_frame(b"foo")); + + assert!(!writer.is_closed()); + let frames = collect_frames(&shm); + assert!(frames.iter().count() == 1); + assert!(frames.is_complete()); + + // Claims of an admitted-but-late writer fail cleanly and do not mark + // the trace incomplete: the access is outside the closed boundary. + assert!(writer.is_closed()); + assert!(writer.claim_frame(5.try_into().unwrap()).unwrap_err() == ClaimError::Closed); + + let frames = collect_frames(&shm); + assert!(frames.iter().count() == 1); + assert!(frames.is_complete()); + } + + #[test] + fn commit_after_abort_publishes_nothing() { + let shm = MockedShm::alloc(1024); + // SAFETY: see `single_thread_basic`. + let writer = unsafe { ShmWriter::new(shm.clone()) }; + + let mut frame = writer.claim_frame(5.try_into().unwrap()).unwrap(); + frame.copy_from_slice(b"late!"); + + // The receiver closes while the frame is unfinished and aborts it. + let frames = collect_frames(&shm); + assert!(frames.iter().count() == 0); + assert!(frames.is_complete()); + + // The late commit loses the race silently; the abandoned-frame flag + // must not fire either, because the frame *was* explicitly finished. + frame.finish(); + + let frames = collect_frames(&shm); + assert!(frames.iter().count() == 0); + assert!(frames.is_complete()); + } + + #[test] + fn concurrent() { + let shm = MockedShm::alloc(1024 * 4); + + thread::scope(|s| { + for _ in 0..4 { + s.spawn(|| { + // SAFETY: see `single_thread_basic`. The clone shares the + // same backing memory, which is safe because the protocol + // synchronizes concurrent access with atomics. + let writer = unsafe { ShmWriter::new(shm.clone()) }; + for _ in 0..10 { + assert!(writer.try_write_frame(b"hello")); + assert!(writer.try_write_frame(b"foo")); + assert!(writer.try_write_frame(b"this is a test")); + } + }); + } + }); + + let frames = collect_frames(&shm); + let mut count = 0; + for frame in frames.iter() { + count += 1; + let frame = BStr::new(frame); + assert!(frame == b"hello" || frame == b"foo" || frame == b"this is a test"); + } + assert!(count == 120); + assert!(frames.is_complete()); + } + + #[test] + fn concurrent_exceeded_size() { + let shm = MockedShm::alloc(1024); + // SAFETY: see `single_thread_basic`. + let writer = unsafe { ShmWriter::new(shm.clone()) }; + thread::scope(|s| { + for _ in 0..4 { + s.spawn(|| { + for _ in 0..10 { + writer.try_write_frame(b"hello"); + writer.try_write_frame(b"foo"); + writer.try_write_frame(b"this is a test"); + } + }); + } + }); + + let frames = collect_frames(&shm); + let mut count = 0; + for frame in frames.iter() { + count += 1; + let frame = BStr::new(frame); + assert!(frame == b"hello" || frame == b"foo" || frame == b"this is a test"); + } + // Some writes must have succeeded, some must have failed on capacity; + // the failures poison completeness. + assert!(count > 20); + assert!(count < 120); + assert!(!frames.is_complete()); + } + + #[test] + fn close_races_with_active_writers() { + let shm = MockedShm::alloc(1024 * 64); + let barrier = Barrier::new(3); + + let (frames, results) = thread::scope(|s| { + let writers = [(); 2].map(|()| { + s.spawn(|| { + // SAFETY: see `concurrent`. + let writer = unsafe { ShmWriter::new(shm.clone()) }; + barrier.wait(); + let mut written = 0usize; + // Bounded so the test terminates even if close is slow; + // the region is large enough that capacity never fails. + for _ in 0..200 { + match writer.claim_frame(5.try_into().unwrap()) { + Ok(mut frame) => { + frame.copy_from_slice(b"hello"); + frame.finish(); + written += 1; + } + Err(ClaimError::Closed) => break, + Err(ClaimError::Capacity) => panic!("region unexpectedly full"), + } + } + written + }) + }); + + barrier.wait(); + let frames = collect_frames(&shm); + let results = writers.map(|writer| writer.join().unwrap()); + (frames, results) + }); + + // Every admitted slot resolved to a whole frame or was aborted: + // the receiver observed only complete payloads. + let mut count = 0; + for frame in frames.iter() { + count += 1; + assert!(frame == b"hello"); + } + // Only commits that lost the freeze race may be missing, and no + // frame can appear that was never finished. + let written: usize = results.into_iter().sum(); + assert!(count <= written); + // Writers either finished or cleanly observed `Closed`; nothing was + // abandoned, so completeness holds. + assert!(frames.is_complete()); + } + + #[test] + fn corrupt_committed_descriptor_is_a_protocol_error() { + let shm = MockedShm::alloc(1024); + // SAFETY: see `single_thread_basic`. + let writer = unsafe { ShmWriter::new(shm.clone()) }; + assert!(writer.try_write_frame(b"hello")); + + // Point slot 0 at a span escaping the mapping. + let bogus_len = 8u64; + let bogus_offset = 1020u64; + shm.poke_word(64, (bogus_len << 32) | bogus_offset); + + // SAFETY: see `collect_frames`. + let result = unsafe { close(&shm) }; + assert!(result.unwrap_err() == ProtocolError::CorruptDescriptor { slot_index: 0 }); + } + + #[test] + fn corrupt_aborted_descriptor_is_a_protocol_error() { + let shm = MockedShm::alloc(1024); + // SAFETY: see `single_thread_basic`. + let writer = unsafe { ShmWriter::new(shm.clone()) }; + assert!(writer.try_write_frame(b"hello")); + + // The aborted bit combined with payload bits is a value no protocol + // operation produces. + shm.poke_word(64, (1 << 63) | (8u64 << 32) | 8); + + // SAFETY: see `collect_frames`. + let result = unsafe { close(&shm) }; + assert!(result.unwrap_err() == ProtocolError::CorruptDescriptor { slot_index: 0 }); + } + + #[test] + fn corrupt_allocator_word_is_a_protocol_error() { + let shm = MockedShm::alloc(1024); + // A slot count whose table exceeds the mapping. + shm.poke_word(0, ((1u64 << 30) - 1) << 32); + + // SAFETY: see `collect_frames`. + let result = unsafe { close(&shm) }; + assert!(result.unwrap_err() == ProtocolError::CorruptAllocator); + } + + #[test] + fn misaligned_region_is_rejected() { + struct Misaligned(MockedShm); + impl AsRawSlice for Misaligned { + fn as_raw_slice(&self) -> *mut [u8] { + let raw_slice = self.0.as_raw_slice(); + slice_from_raw_parts_mut( + // SAFETY: Adding 1 byte to create a deliberately + // misaligned pointer for testing. The original allocation + // is large enough that adding 1 byte stays within bounds. + unsafe { raw_slice.cast::().add(1) }, + raw_slice.len() - 1, + ) + } + } + + let misaligned_shm = Misaligned(MockedShm::alloc(1024)); + assert!(misaligned_shm.as_raw_slice().cast::() as usize % align_of::() != 0); + + let result = std::panic::catch_unwind(|| { + // SAFETY: Intentionally passing a misaligned pointer to test that + // the geometry assertion correctly panics. + unsafe { ShmWriter::new(misaligned_shm) }; + }); + assert!(result.is_err(), "should panic on a misaligned region"); + } + + #[test] + #[cfg(not(miri))] + fn real_shm_across_processes() { + use std::process::{Child, Command}; + + use rustc_hash::FxHashSet; + use subprocess_test::command_for_fn; + + const CHILD_COUNT: usize = 12; + const FRAME_COUNT_EACH_CHILD: usize = 100; + + const SHM_SIZE: usize = 1024 * 1024; + + let shm_path = crate::ipc::channel::shm_backing_path().unwrap(); + let shm_name = shm_path.to_str().expect("test temp dir is UTF-8").to_owned(); + let c_path = crate::ipc::channel::os_c_string(shm_path.as_os_str()).unwrap(); + let handle = fspy_shm::create(c_path.as_c_str().as_thin(), SHM_SIZE).unwrap(); + let _keeper = crate::ipc::channel::ShmKeeper { path: c_path }; + // Map before the children run. Windows keeps views coherent while they + // exist at the same time; a view created after every writer exited can + // observe the file before the writers' dirty pages reach it. + let mapping = handle.map().unwrap(); + + let children: Vec = (0..CHILD_COUNT) + .map(|child_index| { + let cmd = command_for_fn!( + (shm_name.clone(), child_index), + |(shm_name, child_index): (String, usize)| { + let c_path = + crate::ipc::channel::os_c_string(std::ffi::OsStr::new(&shm_name)) + .unwrap(); + let mapping = + fspy_shm::open(c_path.as_c_str().as_thin()).unwrap().map().unwrap(); + // SAFETY: `mapping` is a freshly mapped shared memory + // region with a valid pointer and size; the protocol + // synchronizes concurrent access. + let writer = unsafe { ShmWriter::new(mapping) }; + for i in 0..FRAME_COUNT_EACH_CHILD { + let frame_data = std::format!("{child_index} {i}"); + assert!(writer.try_write_frame(frame_data.as_bytes())); + } + } + ); + Command::from(cmd).spawn().unwrap() + }) + .collect(); + + for mut c in children { + let status = c.wait().unwrap(); + assert!(status.success()); + } + + // SAFETY: the mapping is a valid shared-memory region created zeroed + // and accessed only through the protocol. + let frames = unsafe { close(&mapping) }.unwrap(); + assert!(frames.is_complete()); + let collected = frames.iter().map(BStr::new).collect::>(); + assert!(collected.len() == CHILD_COUNT * FRAME_COUNT_EACH_CHILD); + for child_index in 0..CHILD_COUNT { + for i in 0..FRAME_COUNT_EACH_CHILD { + let frame_data = format!("{child_index} {i}"); + assert!(collected.contains(&BStr::new(frame_data.as_bytes()))); + } + } + } + + /// A writer killed mid-frame (SIGKILL on Unix, `TerminateProcess` on + /// Windows — both via `Child::kill`) must not lose other writers' frames + /// or completeness: no cleanup code runs in the killed process. + #[test] + #[cfg(not(miri))] + fn killed_writer_does_not_poison_the_channel() { + use std::{ + io::{BufRead as _, BufReader}, + process::{Command, Stdio}, + }; + + use subprocess_test::command_for_fn; + + const SHM_SIZE: usize = 1024 * 1024; + + let shm_path = crate::ipc::channel::shm_backing_path().unwrap(); + let shm_name = shm_path.to_str().expect("test temp dir is UTF-8").to_owned(); + let c_path = crate::ipc::channel::os_c_string(shm_path.as_os_str()).unwrap(); + let handle = fspy_shm::create(c_path.as_c_str().as_thin(), SHM_SIZE).unwrap(); + let _keeper = crate::ipc::channel::ShmKeeper { path: c_path }; + let mapping = handle.map().unwrap(); + + let cmd = command_for_fn!(shm_name, |shm_name: String| { + let c_path = crate::ipc::channel::os_c_string(std::ffi::OsStr::new(&shm_name)).unwrap(); + let child_mapping = fspy_shm::open(c_path.as_c_str().as_thin()).unwrap().map().unwrap(); + // SAFETY: see `real_shm_across_processes`. + let writer = unsafe { ShmWriter::new(child_mapping) }; + let mut frame = writer.claim_frame(5.try_into().unwrap()).unwrap(); + frame[..3].copy_from_slice(b"wor"); + // Signal the parent that the frame is claimed and partially + // written, then wait to be killed. + #[expect(clippy::print_stdout, reason = "readiness handshake with the parent")] + { + println!("claimed"); + } + std::mem::forget(frame); + // Wait to be killed; nothing ever unparks this thread. + loop { + std::thread::park(); + } + }); + let mut command = Command::from(cmd); + command.stdout(Stdio::piped()); + let mut child = command.spawn().unwrap(); + let mut line = String::new(); + BufReader::new(child.stdout.take().unwrap()).read_line(&mut line).unwrap(); + assert!(line.trim() == "claimed"); + child.kill().unwrap(); + child.wait().unwrap(); + + // A surviving writer keeps working after the kill. + // SAFETY: see `real_shm_across_processes`. + let writer = unsafe { ShmWriter::new(mapping) }; + assert!(writer.try_write_frame(b"alive")); + + // SAFETY: see `real_shm_across_processes`. + let frames = unsafe { close(&writer.into_memory()) }.unwrap(); + let mut iter = frames.iter(); + assert!(iter.next().unwrap() == b"alive"); + assert!(iter.next() == None); + // Death runs no drop code, so the killed writer's lost frame does not + // mark the trace incomplete. + assert!(frames.is_complete()); + } +} diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs new file mode 100644 index 000000000..400da3a89 --- /dev/null +++ b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs @@ -0,0 +1,130 @@ +//! The receiver side: closing the channel and collecting committed frames. +//! +//! Closing never waits for writers. It prevents new claims, atomically +//! freezes every slot that never committed, validates the committed +//! descriptors, and copies their payloads out of the shared mapping. +//! +//! The copy is deliberate: the mapping stays writable in every traced +//! process, so this module never creates a reference into shared memory +//! whose validity would depend on another process's compliance. Committed +//! spans are read with atomic loads (see [`super::state`]) into an owned +//! buffer, and everything downstream parses the private copy. + +use std::ops::Range; + +use super::{ + layout::{self, PayloadSpan}, + slot::{self, SlotState}, + state::SharedState, +}; + +/// The committed frames of a closed channel, copied out of shared memory. +#[derive(Debug)] +pub struct Frames { + bytes: Vec, + bounds: Vec>, + complete: bool, +} + +impl Frames { + /// Iterates over the committed frames in claim order. + pub fn iter(&self) -> impl Iterator { + self.bounds.iter().map(|bounds| &self.bytes[bounds.clone()]) + } + + /// Whether the trace contains every reported access. + /// + /// False when a live writer lost a record before the channel closed + /// (capacity exhaustion or an abandoned frame): the trace then + /// under-reports the run's accesses and must not back a cache entry. + #[must_use] + pub const fn is_complete(&self) -> bool { + self.complete + } +} + +/// A trace whose shared-memory metadata could not have been produced by this +/// protocol. The mapping was corrupted; the trace is unusable. +#[derive(thiserror::Error, Clone, Copy, PartialEq, Eq, Debug)] +pub enum ProtocolError { + #[error("corrupt shared-memory allocator word")] + CorruptAllocator, + #[error("corrupt shared-memory frame descriptor at slot {slot_index}")] + CorruptDescriptor { slot_index: usize }, +} + +/// Closes the channel and collects the committed frames. +/// +/// Never blocks on writers: writers admitted before the close race per slot, +/// and each raced slot independently ends up committed (included) or aborted +/// (excluded). See the crate-level protocol docs in [`super`]. +/// +/// # Safety +/// +/// Same contract as [`super::ShmWriter::new`]: `mem` must be a stable, valid +/// pointer to the whole region, zero-initialized at creation and accessed +/// only through this protocol. +/// +/// # Panics +/// +/// Panics when the region is not word-aligned or its size is outside the +/// supported range (see [`SharedState::borrow`]) — a broken caller, not +/// corrupt shared data, which is reported as [`ProtocolError`] instead. +pub(super) unsafe fn close(mem: *mut [u8]) -> Result { + // SAFETY: forwarded from this function's contract. + let state = unsafe { SharedState::borrow(mem) }; + + // Snapshot the admitted slots and stop admitting new ones. Claims and + // close totally order on the allocator word: every claim admitted before + // this operation is counted, every later one fails with `Closed`. + let snapshot = state.close(); + if !snapshot.is_valid_for(state.mapping_len()) { + return Err(ProtocolError::CorruptAllocator); + } + let slot_count = snapshot.slot_count(); + let table_end = layout::table_end(slot_count); + + // Freeze pass: drive every admitted slot to a terminal state and collect + // the committed spans. After this loop the descriptor table can no + // longer change — late writers lose their commit race against `ABORTED`. + let mut spans = Vec::new(); + let mut payload_total = 0usize; + for slot_index in 0..slot_count { + match slot::decode(state.freeze(slot_index)) { + SlotState::Aborted => {} + SlotState::Committed { payload_offset, payload_len } => { + let span = PayloadSpan::validate( + state.mapping_len(), + table_end, + payload_offset, + payload_len, + ) + .ok_or(ProtocolError::CorruptDescriptor { slot_index })?; + spans.push(span); + payload_total += span.len; + } + // `freeze` only returns terminal values, so `Unfinished` is + // unreachable and grouped with the corrupt case. + SlotState::Unfinished | SlotState::Corrupt => { + return Err(ProtocolError::CorruptDescriptor { slot_index }); + } + } + } + + // Copy pass: move the committed payloads into an owned buffer. + let mut bytes = Vec::with_capacity(payload_total); + let mut bounds = Vec::with_capacity(spans.len()); + for span in spans { + let start = bytes.len(); + state.read_payload(span, &mut bytes); + bounds.push(start..bytes.len()); + } + + // Re-read the incomplete flag only after freezing: a writer sets it + // before performing an operation whose record was lost, so any flag this + // load misses belongs to an operation performed after close — outside + // the tracking boundary (rule 1 in `state`'s ordering contract). + let complete = !state.reload().is_incomplete(); + + Ok(Frames { bytes, bounds, complete }) +} diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/slot.rs b/crates/fspy_shared/src/ipc/channel/shm_io/slot.rs new file mode 100644 index 000000000..937af4fae --- /dev/null +++ b/crates/fspy_shared/src/ipc/channel/shm_io/slot.rs @@ -0,0 +1,109 @@ +//! Encoding of one descriptor slot — the 64-bit value that publishes a frame. +//! +//! ```text +//! bit 63 bits 32..=62 bits 0..=31 +//! ABORTED payload length (31) payload offset (32) +//! ``` +//! +//! | Value | State | +//! | ------------------------- | ------------------------------------------- | +//! | `0` | Unfinished: slot reserved, nothing published | +//! | `1 << 63` | Aborted: the receiver froze the unfinished slot | +//! | nonzero, bit 63 clear | Committed: offset and length of the payload | +//! +//! Committed lengths are nonzero (a zero-length frame is never claimed), so a +//! committed value is always nonzero and the three states are disjoint. +//! Committed and aborted are terminal: no protocol operation overwrites them. +//! +//! Like [`super::alloc_word`], this module is pure; the compare-and-swap +//! transitions live in [`super::state`]. + +pub(super) const UNFINISHED: u64 = 0; +pub(super) const ABORTED: u64 = 1 << 63; +const LEN_SHIFT: u32 = 32; +const OFFSET_MAX: u64 = u32::MAX as u64; + +/// A decoded descriptor slot value. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(super) enum SlotState { + /// The slot was reserved but no payload has been committed. + Unfinished, + /// The receiver froze the slot; its payload is permanently unreachable. + Aborted, + /// A payload was committed. The range is *unvalidated*: it must pass + /// [`super::layout::PayloadSpan::validate`] before any access. + Committed { payload_offset: usize, payload_len: usize }, + /// A value no protocol operation produces. The trace is corrupt. + Corrupt, +} + +/// Encodes a committed descriptor. +/// +/// The caller guarantees `payload_len` is `1..=MAX_PAYLOAD_LEN` and +/// `payload_offset` fits 32 bits; both hold for any admitted reservation. +pub(super) fn committed(payload_offset: usize, payload_len: usize) -> u64 { + debug_assert!(payload_len > 0 && payload_len <= super::layout::MAX_PAYLOAD_LEN); + debug_assert!(payload_offset as u64 <= OFFSET_MAX); + ((payload_len as u64) << LEN_SHIFT) | payload_offset as u64 +} + +/// Decodes a slot value read back from shared memory. +pub(super) const fn decode(bits: u64) -> SlotState { + match bits { + UNFINISHED => SlotState::Unfinished, + ABORTED => SlotState::Aborted, + _ if bits & ABORTED != 0 => SlotState::Corrupt, + _ => { + // Bit 63 is clear, so the length field is at most 31 bits and + // cannot exceed `MAX_PAYLOAD_LEN`. + let payload_len = (bits >> LEN_SHIFT) as usize; + let payload_offset = (bits & OFFSET_MAX) as usize; + if payload_len == 0 { + // A nonzero offset with a zero length: not a committed value, + // because committed lengths are nonzero. + SlotState::Corrupt + } else { + SlotState::Committed { payload_offset, payload_len } + } + } + } +} + +#[cfg(test)] +mod tests { + use assert2::assert; + + use super::*; + + #[test] + fn decode_recognizes_the_three_states() { + assert!(decode(UNFINISHED) == SlotState::Unfinished); + assert!(decode(ABORTED) == SlotState::Aborted); + assert!( + decode(committed(1016, 5)) + == SlotState::Committed { payload_offset: 1016, payload_len: 5 } + ); + } + + #[test] + fn committed_roundtrips_the_extremes() { + let max_offset = u32::MAX as usize; + let max_len = super::super::layout::MAX_PAYLOAD_LEN; + assert!( + decode(committed(max_offset, max_len)) + == SlotState::Committed { payload_offset: max_offset, payload_len: max_len } + ); + assert!( + decode(committed(0, 1)) == SlotState::Committed { payload_offset: 0, payload_len: 1 } + ); + } + + #[test] + fn decode_rejects_values_no_writer_produces() { + // Aborted bit combined with other bits. + assert!(decode(ABORTED | 1) == SlotState::Corrupt); + assert!(decode(ABORTED | (1 << 62)) == SlotState::Corrupt); + // Zero length with a nonzero offset. + assert!(decode(42) == SlotState::Corrupt); + } +} diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/state.rs b/crates/fspy_shared/src/ipc/channel/shm_io/state.rs new file mode 100644 index 000000000..65c66d4ba --- /dev/null +++ b/crates/fspy_shared/src/ipc/channel/shm_io/state.rs @@ -0,0 +1,210 @@ +//! The only module that touches shared-memory bytes. +//! +//! [`SharedState`] wraps the raw mapping and exposes the protocol's atomic +//! operations. Every unsafe pointer derivation lives here, justified by the +//! construction contract of [`SharedState::borrow`]; no other module reads or +//! writes the mapping directly. +//! +//! # Memory-ordering contract +//! +//! Three synchronization rules cover the whole protocol: +//! +//! 1. **Claim versus close** — both are read-modify-writes of the allocator +//! word, so its modification order alone decides whether a claim is +//! admitted before the close snapshot. A claim publishes no payload data, +//! so `Relaxed` suffices ([`SharedState::try_claim`]). The `INCOMPLETE` +//! flag rides the same word: a writer sets it (`Relaxed` RMW) before +//! performing the operation whose record was lost, and the receiver +//! re-reads the word after freezing ([`SharedState::reload`]); a flag the +//! receiver's reload misses therefore belongs to an operation performed +//! after close, outside the tracking boundary. +//! 2. **Writer commit** — the slot compare-and-swap uses `Release` +//! ([`SharedState::commit`]): every payload write happens-before the +//! committed descriptor becomes visible. +//! 3. **Receiver observation** — the freeze compare-and-swap uses `Acquire` +//! on failure ([`SharedState::freeze`]): observing a committed descriptor +//! also makes the payload writes it published visible, so the copy in +//! [`SharedState::read_payload`] reads settled bytes. +//! +//! Payload reads use `Relaxed` atomic loads rather than plain loads: committed +//! payloads are immutable under the protocol, but the mapping is writable in +//! every traced process, so a buggy foreign process can scribble concurrently. +//! Atomic loads keep such races from being undefined behavior in this +//! process — each load returns *some* value, and torn garbage surfaces as a +//! frame-decoding error instead of a crash. + +use std::{ + marker::PhantomData, + sync::atomic::{AtomicU64, Ordering}, +}; + +use super::{ + alloc_word::{self, AllocWord, Reservation, ReserveError}, + layout::{self, PayloadSpan}, + slot, +}; + +/// A borrowed view of the shared mapping with protocol-level operations. +#[derive(Clone, Copy)] +pub(super) struct SharedState<'m> { + base: *mut u8, + len: usize, + _mapping: PhantomData<&'m ()>, +} + +impl<'m> SharedState<'m> { + /// Borrows a shared mapping. + /// + /// # Safety + /// + /// - `mem` must be valid for reads and writes for the lifetime `'m` and + /// its address must be stable. + /// - The memory must have been zero-initialized when the region was + /// created, and accessed only through this protocol since. + /// - Other processes may access the region concurrently, but only through + /// this protocol. + /// + /// # Panics + /// + /// Panics when the mapping cannot host the protocol at all: base not + /// word-aligned, or length outside + /// `[layout::HEADER_LEN, layout::MAX_MAPPING_LEN]`. These indicate a + /// broken caller, not runtime data. + pub(super) unsafe fn borrow(mem: *mut [u8]) -> Self { + let base = mem.cast::(); + assert!(base.addr().is_multiple_of(align_of::())); + assert!(mem.len() <= layout::MAX_MAPPING_LEN); + // Ignore any sub-word tail so payload spans growing from the end + // stay word-aligned. + let len = layout::usable_len(mem.len()); + assert!(len >= layout::HEADER_LEN); + Self { base, len, _mapping: PhantomData } + } + + pub(super) const fn mapping_len(self) -> usize { + self.len + } + + /// The allocator word at the start of the header. + const fn word(self) -> &'m AtomicU64 { + // SAFETY: `borrow` checked that the mapping is word-aligned and at + // least `HEADER_LEN` bytes, so the first 8 bytes are a valid, aligned + // `AtomicU64` for `'m`. + unsafe { AtomicU64::from_ptr(self.base.cast()) } + } + + /// The descriptor slot at `index`. + /// + /// # Panics + /// + /// Panics when the slot lies outside the mapping. Callers only pass + /// indices below an admitted (writer) or validated (receiver) slot + /// count, so the assertion documents an invariant rather than guarding + /// runtime data. + fn slot_atomic(self, index: usize) -> &'m AtomicU64 { + assert!(layout::table_end(index + 1) <= self.len); + // SAFETY: the assertion keeps the slot inside the mapping, and the + // table consists of word-aligned 8-byte slots after the aligned + // header. + unsafe { AtomicU64::from_ptr(self.base.add(layout::table_end(index)).cast()) } + } + + /// Reads the allocator word without synchronization (rule 1: claims and + /// flags need no payload visibility). + pub(super) fn load_word(self) -> AllocWord { + AllocWord::from_bits(self.word().load(Ordering::Relaxed)) + } + + /// Atomically reserves one descriptor slot and one payload span. + pub(super) fn try_claim(self, payload_len: usize) -> Result { + let word = self.word(); + let mut current = word.load(Ordering::Relaxed); + loop { + let reservation = AllocWord::from_bits(current).reserve(payload_len, self.len)?; + // Rule 1: `Relaxed` — admission is decided by the modification + // order of the allocator word alone. + match word.compare_exchange_weak( + current, + reservation.new_word.bits(), + Ordering::Relaxed, + Ordering::Relaxed, + ) { + Ok(_) => return Ok(reservation), + Err(actual) => current = actual, + } + } + } + + /// Records that a frame this process may still act on was lost. + /// + /// Must be called before the operation whose record was lost is + /// performed (rule 1). + pub(super) fn flag_incomplete(self) { + self.word().fetch_or(alloc_word::INCOMPLETE, Ordering::Relaxed); + } + + /// Closes the channel and snapshots the admitted slot count. + pub(super) fn close(self) -> AllocWord { + AllocWord::from_bits(self.word().fetch_or(alloc_word::CLOSED, Ordering::AcqRel)) + } + + /// Re-reads the allocator word; used after the freeze pass to observe + /// `INCOMPLETE` flags set while closing (rule 1). + pub(super) fn reload(self) -> AllocWord { + AllocWord::from_bits(self.word().load(Ordering::Acquire)) + } + + /// Publishes a committed descriptor into an unfinished slot. + /// + /// Returns false when the receiver aborted the slot first; the payload is + /// then permanently unreachable and the writer must not touch it again + /// either way. + pub(super) fn commit(self, slot_index: usize, descriptor: u64) -> bool { + // Rule 2: `Release` orders every payload write before the descriptor. + self.slot_atomic(slot_index) + .compare_exchange(slot::UNFINISHED, descriptor, Ordering::Release, Ordering::Relaxed) + .is_ok() + } + + /// Freezes one slot during close and returns its terminal value: `ABORTED` + /// when the receiver won the race, the committed descriptor otherwise. + pub(super) fn freeze(self, slot_index: usize) -> u64 { + // Rule 3: `Acquire` on failure makes a committed payload visible. + match self.slot_atomic(slot_index).compare_exchange( + slot::UNFINISHED, + slot::ABORTED, + Ordering::AcqRel, + Ordering::Acquire, + ) { + Ok(_) => slot::ABORTED, + Err(terminal) => terminal, + } + } + + /// Pointer to a reserved payload span. The caller owns the span's + /// exclusivity argument. + pub(super) fn payload_ptr(self, offset: usize) -> *mut u8 { + debug_assert!(offset <= self.len); + // SAFETY: callers pass offsets of admitted reservations, which + // `layout::fits` keeps inside the mapping. + unsafe { self.base.add(offset) } + } + + /// Appends the payload bytes of a validated span to `out`. + /// + /// Reads the span's word-aligned reservation with `Relaxed` atomic loads + /// (see the module docs) and appends exactly `span.len` bytes. + pub(super) fn read_payload(self, span: PayloadSpan, out: &mut Vec) { + let keep = out.len() + span.len; + for word_index in 0..span.reserved_len() / layout::SLOT_LEN { + let offset = span.offset + word_index * layout::SLOT_LEN; + // SAFETY: `PayloadSpan::validate` checked that the word-aligned + // reservation `[span.offset, span.offset + span.reserved_len())` + // lies inside the mapping, and `span.offset` is word-aligned. + let word = unsafe { AtomicU64::from_ptr(self.base.add(offset).cast()) }; + out.extend_from_slice(&word.load(Ordering::Relaxed).to_ne_bytes()); + } + // Drop the sub-word padding bytes of the final word. + out.truncate(keep); + } +} diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs new file mode 100644 index 000000000..a745882ad --- /dev/null +++ b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs @@ -0,0 +1,229 @@ +//! The writer side: claiming, filling, and committing frames. + +use std::{ + mem::ManuallyDrop, + num::NonZeroUsize, + ops::{Deref, DerefMut}, +}; + +use wincode::{SchemaWrite, Serialize as _, config::DefaultConfig}; + +use super::{AsRawSlice, alloc_word::ReserveError, slot, state::SharedState}; + +/// A concurrent shared-memory frame writer. +/// +/// Safe to use across threads and processes at the same time: frames are +/// reserved with atomic operations, filled in uniquely owned payload spans, +/// and published with an atomic commit (see the module docs of +/// [`super::state`]). +pub struct ShmWriter { + mem: M, +} + +/// Why a frame could not be claimed. +#[derive(thiserror::Error, Clone, Copy, PartialEq, Eq, Debug)] +pub enum ClaimError { + /// The receiver closed the channel; the run is over and the access is + /// outside its tracking boundary. + #[error("the channel has been closed by the receiver")] + Closed, + /// The frame is oversized or the region is full. The claim has already + /// recorded the loss, so the trace will be reported as incomplete. + #[error("no space left in the shared-memory region")] + Capacity, +} + +#[derive(thiserror::Error, Debug)] +pub enum WriteEncodedError { + #[error("failed to encode value into shared memory")] + EncodeError(#[from] wincode::error::WriteError), + #[error("tried to write a frame of zero size into shared memory")] + ZeroSizedFrame, + #[error("encoded size diverged from the declared serialized size")] + SizeMismatch, + #[error(transparent)] + Claim(#[from] ClaimError), +} + +impl ShmWriter { + /// Creates a writer backed by a shared-memory region. + /// + /// # Safety + /// + /// - `mem.as_raw_slice()` must return a stable, valid pointer to the + /// whole region for the writer's lifetime. + /// - The region must have been zero-initialized when it was created and + /// accessed only through this protocol since. + /// + /// # Panics + /// + /// Panics when the region is not word-aligned or its size is outside the + /// supported range (see [`SharedState::borrow`]). + pub unsafe fn new(mem: M) -> Self { + // Validate the region geometry eagerly so misuse fails at + // construction, not at the first claim. + // SAFETY: forwarded from this function's contract. + let _ = unsafe { SharedState::borrow(mem.as_raw_slice()) }; + Self { mem } + } + + fn state(&self) -> SharedState<'_> { + // SAFETY: `new` requires the region to stay valid and + // protocol-governed for the writer's lifetime, and it validated the + // geometry. + unsafe { SharedState::borrow(self.mem.as_raw_slice()) } + } + + /// Whether the receiver has closed the channel. + pub fn is_closed(&self) -> bool { + self.state().load_word().is_closed() + } + + /// Claims a frame of exactly `frame_size` bytes. + /// + /// The frame is invisible to the receiver until [`FrameMut::finish`] + /// commits it. Dropping the frame without finishing abandons the claim + /// and marks the trace incomplete. + pub fn claim_frame(&self, frame_size: NonZeroUsize) -> Result, ClaimError> { + let state = self.state(); + let reservation = state.try_claim(frame_size.get()).map_err(|err| match err { + ReserveError::Closed => ClaimError::Closed, + ReserveError::Capacity => { + // The record is lost but this process lives on to perform the + // operation, so poison the trace before the caller proceeds. + state.flag_incomplete(); + ClaimError::Capacity + } + })?; + + let content_ptr = state.payload_ptr(reservation.payload_offset); + // SAFETY: the allocator compare-and-swap reserved + // `[payload_offset, payload_offset + frame_size)` exclusively for + // this frame: other writers reserve disjoint spans, and the receiver + // never reads a payload before observing its committed descriptor — + // which `finish` publishes only when it consumes this borrow. + let content = unsafe { std::slice::from_raw_parts_mut(content_ptr, frame_size.get()) }; + Ok(FrameMut { + state, + slot_index: reservation.slot_index, + descriptor: slot::committed(reservation.payload_offset, frame_size.get()), + content, + }) + } + + /// Writes one encoded value as a committed frame. + pub fn write_encoded>( + &self, + value: &T, + ) -> Result<(), WriteEncodedError> { + let result = self.write_encoded_inner(value); + if let Err(err) = &result + && !matches!(err, WriteEncodedError::Claim(_)) + { + // A pre-claim failure also loses a record this live process will + // still act on; claim errors have already been recorded (or are + // an ordinary post-close skip). + self.state().flag_incomplete(); + } + result + } + + fn write_encoded_inner>( + &self, + value: &T, + ) -> Result<(), WriteEncodedError> { + let serialized_size = + usize::try_from(T::serialized_size(value)?).expect("serialized size exceeds usize"); + + let Some(frame_size) = NonZeroUsize::new(serialized_size) else { + return Err(WriteEncodedError::ZeroSizedFrame); + }; + let mut frame = self.claim_frame(frame_size)?; + + let mut writer: &mut [u8] = &mut frame; + T::serialize_into(&mut writer, value)?; + if !writer.is_empty() { + // Dropping the partially filled frame leaves it unpublished. + return Err(WriteEncodedError::SizeMismatch); + } + + frame.finish(); + Ok(()) + } + + // Unwrap `self` and return the underlying memory. + #[cfg(test)] + pub fn into_memory(self) -> M { + self.mem + } + + #[cfg(test)] + pub fn try_write_frame(&self, frame: &[u8]) -> bool { + let Some(frame_size) = NonZeroUsize::new(frame.len()) else { + return false; + }; + let Ok(mut frame_mut) = self.claim_frame(frame_size) else { + return false; + }; + frame_mut.copy_from_slice(frame); + frame_mut.finish(); + true + } +} + +/// An exclusively owned, claimed-but-unpublished frame. +/// +/// [`FrameMut::finish`] commits the frame; it is the only way to make the +/// payload visible to the receiver. Dropping the frame instead abandons the +/// claim: the slot stays unfinished (the receiver will abort and ignore it) +/// and the trace is marked incomplete, because the dropping process is alive +/// to perform the operation this record was meant to describe. A process +/// that dies mid-frame runs no drop code and marks nothing — correctly so, +/// since records are published before the recorded operation is performed. +pub struct FrameMut<'a> { + state: SharedState<'a>, + slot_index: usize, + descriptor: u64, + content: &'a mut [u8], +} + +impl std::fmt::Debug for FrameMut<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("FrameMut") + .field("slot_index", &self.slot_index) + .field("len", &self.content.len()) + .finish_non_exhaustive() + } +} + +impl Deref for FrameMut<'_> { + type Target = [u8]; + + fn deref(&self) -> &Self::Target { + self.content + } +} + +impl DerefMut for FrameMut<'_> { + fn deref_mut(&mut self) -> &mut Self::Target { + self.content + } +} + +impl FrameMut<'_> { + /// Commits the frame, making it visible to the receiver. + /// + /// If the receiver closed the channel and aborted this frame's slot + /// first, the frame is silently discarded: the access belongs to the + /// closed run's boundary race and is intentionally excluded either way. + pub fn finish(self) { + let this = ManuallyDrop::new(self); + this.state.commit(this.slot_index, this.descriptor); + } +} + +impl Drop for FrameMut<'_> { + fn drop(&mut self) { + self.state.flag_incomplete(); + } +} From 2f5d38952903e2bcc6e4190719cab58e71bc6b57 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Fri, 14 Aug 2026 16:13:51 +0800 Subject: [PATCH 02/92] docs: add changelog entry for crash-tolerant file-access tracking Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ba0419ab..2750451f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Changelog +- **Fixed** Automatic file-access tracking no longer panics or blocks task completion when a tracked process crashes mid-record, closes inherited file descriptors, or keeps running after the task exits; such runs now finish immediately with every completed record intact ([#544](https://github.com/voidzero-dev/vite-task/issues/544), [#675](https://github.com/voidzero-dev/vite-task/pull/675)). - **Fixed** Vite+ diagnostics now display individual paths and working directories without Rust debug formatting such as quoted paths or escaped Windows backslashes ([#534](https://github.com/voidzero-dev/vite-task/pull/534)). - **Fixed** Automatic file-access tracking now works inside the default Codex CLI and Claude Code sandboxes ([#562](https://github.com/voidzero-dev/vite-task/issues/562), [#563](https://github.com/voidzero-dev/vite-task/issues/563), [#576](https://github.com/voidzero-dev/vite-task/pull/576), [#569](https://github.com/voidzero-dev/vite-task/pull/569)). - **Fixed** Broad workspace globs no longer discover and run package scripts inside `node_modules` ([#539](https://github.com/voidzero-dev/vite-task/pull/539)). From d2fa1dec53d60f4abf31f7e23978e8765ba71d1f Mon Sep 17 00:00:00 2001 From: wan9chi Date: Fri, 14 Aug 2026 16:35:15 +0800 Subject: [PATCH 03/92] temp(fspy): benchmark phase instrumentation Temporary stderr phase timings to locate the CI launch regression on the Linux benchmark runner. Will be dropped before merge. Co-Authored-By: Claude Fable 5 --- crates/fspy/src/ipc.rs | 3 +++ crates/fspy/src/unix/mod.rs | 17 +++++++++++++++++ crates/fspy_shared/src/ipc/channel/mod.rs | 16 ++++++++++++++-- 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/crates/fspy/src/ipc.rs b/crates/fspy/src/ipc.rs index 2a649155c..33ba0ff21 100644 --- a/crates/fspy/src/ipc.rs +++ b/crates/fspy/src/ipc.rs @@ -27,8 +27,11 @@ impl CollectedAccesses { /// was lost before close, or the shared memory was corrupted. Failing /// here — instead of returning a silently short trace — keeps the /// tracking result trustworthy for caching. + #[expect(clippy::print_stderr, reason = "temporary benchmark phase instrumentation")] pub fn collect(receiver: Receiver) -> io::Result { + let phase_start = std::time::Instant::now(); let frames = receiver.close()?; + eprintln!("fspy-collect close={}", phase_start.elapsed().as_nanos()); if !frames.is_complete() { return Err(io::Error::new( io::ErrorKind::InvalidData, diff --git a/crates/fspy/src/unix/mod.rs b/crates/fspy/src/unix/mod.rs index 944cec7c4..a8b29dee4 100644 --- a/crates/fspy/src/unix/mod.rs +++ b/crates/fspy/src/unix/mod.rs @@ -70,17 +70,25 @@ impl SpyImpl { }) } + #[expect(clippy::print_stderr, reason = "temporary benchmark phase instrumentation")] pub(crate) async fn spawn( &self, mut command: Command, cancellation_token: CancellationToken, ) -> Result { + let phase_start = std::time::Instant::now(); #[cfg(target_os = "linux")] let supervisor = supervise::().map_err(SpawnError::Supervisor)?; + let supervise_ns = phase_start.elapsed().as_nanos(); + let phase_start = std::time::Instant::now(); #[cfg(not(target_env = "musl"))] let (ipc_channel_conf, ipc_receiver) = channel(SHM_CAPACITY).map_err(SpawnError::ChannelCreation)?; + eprintln!( + "fspy-spawn supervise={supervise_ns} channel={}", + phase_start.elapsed().as_nanos() + ); let payload = Payload { #[cfg(not(target_env = "musl"))] @@ -139,6 +147,7 @@ impl SpyImpl { // Keep polling for the child to exit in the background even if `wait_handle` is not awaited, // because we need to stop the supervisor and close the channel as soon as the child exits. wait_handle: tokio::spawn(async move { + let phase_start = std::time::Instant::now(); let status = tokio::select! { status = child.wait() => status?, () = cancellation_token.cancelled() => { @@ -146,7 +155,9 @@ impl SpyImpl { child.wait().await? } }; + let child_ns = phase_start.elapsed().as_nanos(); + let phase_start = std::time::Instant::now(); let arenas = std::iter::once(exec_resolve_accesses); // Stop the supervisor and collect path accesses from it. #[cfg(target_os = "linux")] @@ -158,11 +169,17 @@ impl SpyImpl { .map(syscall_handler::SyscallHandler::into_arena), ); let arenas = arenas.collect::>(); + let stop_ns = phase_start.elapsed().as_nanos(); + let phase_start = std::time::Instant::now(); // Close the ipc channel after the child has exited. // We are not interested in path accesses from descendants after the main child has exited. #[cfg(not(target_env = "musl"))] let ipc_accesses = CollectedAccesses::collect_async(ipc_receiver).await?; + eprintln!( + "fspy-phase child={child_ns} stop={stop_ns} collect={}", + phase_start.elapsed().as_nanos() + ); let path_accesses = PathAccessIterable { arenas, #[cfg(not(target_env = "musl"))] diff --git a/crates/fspy_shared/src/ipc/channel/mod.rs b/crates/fspy_shared/src/ipc/channel/mod.rs index 6ca83e1d8..2511f7b1d 100644 --- a/crates/fspy_shared/src/ipc/channel/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/mod.rs @@ -224,16 +224,28 @@ impl Receiver { /// /// Fails only when the shared-memory metadata was corrupted (a protocol /// impossibility for correct senders); the trace is then unusable. + #[expect(clippy::print_stderr, reason = "temporary benchmark phase instrumentation")] pub fn close(self) -> io::Result { let Self { _keeper: keeper, mapping } = self; + let phase_start = std::time::Instant::now(); // Remove the backing file first so no new process attaches while the // channel closes. drop(keeper); + let keeper_ns = phase_start.elapsed().as_nanos(); + let phase_start = std::time::Instant::now(); // SAFETY: `mapping` was created zero-initialized by `channel`, its // address is stable, and all attached processes access it only // through the `shm_io` protocol. - unsafe { shm_io::close(&mapping) } - .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err)) + let frames = unsafe { shm_io::close(&mapping) } + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err)); + let shm_ns = phase_start.elapsed().as_nanos(); + let phase_start = std::time::Instant::now(); + drop(mapping); + eprintln!( + "fspy-close keeper={keeper_ns} shm={shm_ns} munmap={}", + phase_start.elapsed().as_nanos() + ); + frames } } From ab2749f498f693ea78848da9a3566758be0c31ce Mon Sep 17 00:00:00 2001 From: wan9chi Date: Fri, 14 Aug 2026 18:45:19 +0800 Subject: [PATCH 04/92] Revert "temp(fspy): benchmark phase instrumentation" This reverts commit d2fa1dec53d60f4abf31f7e23978e8765ba71d1f. --- crates/fspy/src/ipc.rs | 3 --- crates/fspy/src/unix/mod.rs | 17 ----------------- crates/fspy_shared/src/ipc/channel/mod.rs | 16 ++-------------- 3 files changed, 2 insertions(+), 34 deletions(-) diff --git a/crates/fspy/src/ipc.rs b/crates/fspy/src/ipc.rs index 33ba0ff21..2a649155c 100644 --- a/crates/fspy/src/ipc.rs +++ b/crates/fspy/src/ipc.rs @@ -27,11 +27,8 @@ impl CollectedAccesses { /// was lost before close, or the shared memory was corrupted. Failing /// here — instead of returning a silently short trace — keeps the /// tracking result trustworthy for caching. - #[expect(clippy::print_stderr, reason = "temporary benchmark phase instrumentation")] pub fn collect(receiver: Receiver) -> io::Result { - let phase_start = std::time::Instant::now(); let frames = receiver.close()?; - eprintln!("fspy-collect close={}", phase_start.elapsed().as_nanos()); if !frames.is_complete() { return Err(io::Error::new( io::ErrorKind::InvalidData, diff --git a/crates/fspy/src/unix/mod.rs b/crates/fspy/src/unix/mod.rs index a8b29dee4..944cec7c4 100644 --- a/crates/fspy/src/unix/mod.rs +++ b/crates/fspy/src/unix/mod.rs @@ -70,25 +70,17 @@ impl SpyImpl { }) } - #[expect(clippy::print_stderr, reason = "temporary benchmark phase instrumentation")] pub(crate) async fn spawn( &self, mut command: Command, cancellation_token: CancellationToken, ) -> Result { - let phase_start = std::time::Instant::now(); #[cfg(target_os = "linux")] let supervisor = supervise::().map_err(SpawnError::Supervisor)?; - let supervise_ns = phase_start.elapsed().as_nanos(); - let phase_start = std::time::Instant::now(); #[cfg(not(target_env = "musl"))] let (ipc_channel_conf, ipc_receiver) = channel(SHM_CAPACITY).map_err(SpawnError::ChannelCreation)?; - eprintln!( - "fspy-spawn supervise={supervise_ns} channel={}", - phase_start.elapsed().as_nanos() - ); let payload = Payload { #[cfg(not(target_env = "musl"))] @@ -147,7 +139,6 @@ impl SpyImpl { // Keep polling for the child to exit in the background even if `wait_handle` is not awaited, // because we need to stop the supervisor and close the channel as soon as the child exits. wait_handle: tokio::spawn(async move { - let phase_start = std::time::Instant::now(); let status = tokio::select! { status = child.wait() => status?, () = cancellation_token.cancelled() => { @@ -155,9 +146,7 @@ impl SpyImpl { child.wait().await? } }; - let child_ns = phase_start.elapsed().as_nanos(); - let phase_start = std::time::Instant::now(); let arenas = std::iter::once(exec_resolve_accesses); // Stop the supervisor and collect path accesses from it. #[cfg(target_os = "linux")] @@ -169,17 +158,11 @@ impl SpyImpl { .map(syscall_handler::SyscallHandler::into_arena), ); let arenas = arenas.collect::>(); - let stop_ns = phase_start.elapsed().as_nanos(); - let phase_start = std::time::Instant::now(); // Close the ipc channel after the child has exited. // We are not interested in path accesses from descendants after the main child has exited. #[cfg(not(target_env = "musl"))] let ipc_accesses = CollectedAccesses::collect_async(ipc_receiver).await?; - eprintln!( - "fspy-phase child={child_ns} stop={stop_ns} collect={}", - phase_start.elapsed().as_nanos() - ); let path_accesses = PathAccessIterable { arenas, #[cfg(not(target_env = "musl"))] diff --git a/crates/fspy_shared/src/ipc/channel/mod.rs b/crates/fspy_shared/src/ipc/channel/mod.rs index 2511f7b1d..6ca83e1d8 100644 --- a/crates/fspy_shared/src/ipc/channel/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/mod.rs @@ -224,28 +224,16 @@ impl Receiver { /// /// Fails only when the shared-memory metadata was corrupted (a protocol /// impossibility for correct senders); the trace is then unusable. - #[expect(clippy::print_stderr, reason = "temporary benchmark phase instrumentation")] pub fn close(self) -> io::Result { let Self { _keeper: keeper, mapping } = self; - let phase_start = std::time::Instant::now(); // Remove the backing file first so no new process attaches while the // channel closes. drop(keeper); - let keeper_ns = phase_start.elapsed().as_nanos(); - let phase_start = std::time::Instant::now(); // SAFETY: `mapping` was created zero-initialized by `channel`, its // address is stable, and all attached processes access it only // through the `shm_io` protocol. - let frames = unsafe { shm_io::close(&mapping) } - .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err)); - let shm_ns = phase_start.elapsed().as_nanos(); - let phase_start = std::time::Instant::now(); - drop(mapping); - eprintln!( - "fspy-close keeper={keeper_ns} shm={shm_ns} munmap={}", - phase_start.elapsed().as_nanos() - ); - frames + unsafe { shm_io::close(&mapping) } + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err)) } } From b1babb7b36e75f3bab281d6951b0298b2086648a Mon Sep 17 00:00:00 2001 From: wan9chi Date: Fri, 14 Aug 2026 18:47:54 +0800 Subject: [PATCH 05/92] perf(fspy): keep first-touch allocation and unmapping off the launch path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The crash-tolerant close moved two hidden costs into the tracked child's launch window on journalling filesystems: the first write to the sparse 4 GiB backing file (a millisecond-scale block allocation, previously paid lazily or never) and unmapping the receiver's view (previously after access collection). The Linux benchmark runner priced them at ~2.2 ms and ~0.6 ms per launch. Pre-fault the header page on a background thread at channel creation — a protocol-neutral compare-exchange of zero with zero, run concurrently with process startup — and release the receiver's mapping on a detached thread after the frames are copied out. Co-Authored-By: Claude Fable 5 --- crates/fspy_shared/src/ipc/channel/mod.rs | 24 +++++++++++-- .../fspy_shared/src/ipc/channel/shm_io/mod.rs | 36 +++++++++++++++++++ .../src/ipc/channel/shm_io/state.rs | 14 ++++++++ 3 files changed, 72 insertions(+), 2 deletions(-) diff --git a/crates/fspy_shared/src/ipc/channel/mod.rs b/crates/fspy_shared/src/ipc/channel/mod.rs index 6ca83e1d8..051431f89 100644 --- a/crates/fspy_shared/src/ipc/channel/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/mod.rs @@ -44,6 +44,19 @@ pub fn channel(capacity: usize) -> io::Result<(ChannelConf, Receiver)> { let keeper = ShmKeeper { path: shm_c_path }; let mapping = handle.map().map_err(shm_error_to_io)?; + // Allocating the first block of the sparse backing file can cost + // milliseconds on journalling filesystems. Touch the header page through + // a second view concurrently with process startup, so neither a sender's + // first record nor `Receiver::close` pays that latency. Best-effort: a + // channel without the pre-fault is merely slower. + if let Ok(prefault_mapping) = handle.map() { + let _ = std::thread::Builder::new().name("fspy-shm-prefault".into()).spawn(move || { + // SAFETY: the mapping views the region created zero-initialized + // above, which is only accessed through the `shm_io` protocol. + unsafe { shm_io::pre_fault(&prefault_mapping) }; + }); + } + let conf = ChannelConf { shm_id: IpcStr::from_os_c_str(keeper.path.as_c_str()).to_boxed() }; Ok((conf, Receiver { _keeper: keeper, mapping })) @@ -232,8 +245,15 @@ impl Receiver { // SAFETY: `mapping` was created zero-initialized by `channel`, its // address is stable, and all attached processes access it only // through the `shm_io` protocol. - unsafe { shm_io::close(&mapping) } - .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err)) + let frames = unsafe { shm_io::close(&mapping) } + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err)); + // Unmapping a multi-gigabyte view can cost a millisecond or more. + // The frames are already copied out and nothing reads the mapping + // again, so release it off the caller's path. Best-effort: if the + // thread cannot spawn, its dropped closure unmaps inline instead. + let _ = + std::thread::Builder::new().name("fspy-shm-unmap".into()).spawn(move || drop(mapping)); + frames } } diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs index edc3aeab4..1c8063396 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs @@ -95,6 +95,19 @@ pub unsafe fn close(mem: &impl AsRawSlice) -> Result { unsafe { reader::close(mem.as_raw_slice()) } } +/// Materializes the page backing the protocol header without changing +/// protocol state, so that the first claim and [`close`] never pay for the +/// backing file's first block allocation — a millisecond-scale cost on some +/// journalling filesystems. Run it off any latency-sensitive path. +/// +/// # Safety +/// +/// Same contract as [`ShmWriter::new`]. +pub unsafe fn pre_fault(mem: &impl AsRawSlice) { + // SAFETY: forwarded from this function's contract. + unsafe { state::SharedState::borrow(mem.as_raw_slice()) }.pre_fault(); +} + #[cfg(test)] mod tests { use std::{ @@ -334,6 +347,29 @@ mod tests { assert!(!frames.is_complete()); } + #[test] + fn pre_fault_does_not_disturb_protocol_state() { + let shm = MockedShm::alloc(1024); + // SAFETY: see `single_thread_basic`. + let writer = unsafe { ShmWriter::new(shm.clone()) }; + + // On the untouched region, before any claim. + // SAFETY: see `collect_frames`. + unsafe { pre_fault(&shm) }; + assert!(writer.try_write_frame(b"foo")); + // Racing an already claimed region must change nothing either. + // SAFETY: see `collect_frames`. + unsafe { pre_fault(&shm) }; + assert!(writer.try_write_frame(b"bar")); + + let frames = collect_frames(&shm); + let mut iter = frames.iter(); + assert!(iter.next().unwrap() == b"foo"); + assert!(iter.next().unwrap() == b"bar"); + assert!(iter.next() == None); + assert!(frames.is_complete()); + } + #[test] fn claim_after_close_fails_without_poisoning() { let shm = MockedShm::alloc(1024); diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/state.rs b/crates/fspy_shared/src/ipc/channel/shm_io/state.rs index 65c66d4ba..0aacc9e81 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/state.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/state.rs @@ -115,6 +115,20 @@ impl<'m> SharedState<'m> { AllocWord::from_bits(self.word().load(Ordering::Relaxed)) } + /// Forces the page holding the allocator word to be materialized by the + /// operating system before a latency-sensitive path writes it. + /// + /// A compare-exchange of zero with zero: on an untouched page it performs + /// a real write — allocating the first block of a sparse backing file, + /// which can cost milliseconds on journalling filesystems — without + /// changing protocol state. If a claim got there first, the page is + /// already backed and the failed exchange changes nothing. (An `or` of + /// zero would not do: the compiler may lower it to a plain load, which + /// maps the shared zero page without allocating anything.) + pub(super) fn pre_fault(self) { + let _ = self.word().compare_exchange(0, 0, Ordering::Relaxed, Ordering::Relaxed); + } + /// Atomically reserves one descriptor slot and one payload span. pub(super) fn try_claim(self, payload_len: usize) -> Result { let word = self.word(); From 3a84bc044c8820ce2db74ec48f5bda07da28787b Mon Sep 17 00:00:00 2001 From: wan9chi Date: Fri, 14 Aug 2026 19:33:34 +0800 Subject: [PATCH 06/92] perf(fspy): make frame claims wait-free and channel close write-free MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the packed allocator word and its compare-and-swap loop with two monotonic counters over a fixed table/payload partition. A claim is two wait-free fetch_adds validated against the fixed region bounds; failed claims overshoot the counters harmlessly because committed descriptors are self-describing and readers clamp to the region capacities. The close boundary becomes a snapshot load: claims that arrive later land in slots the receiver never visits and are dropped under the same publish-before-perform argument as freeze-race losses. The CLOSED gate — whose write materializes the counter page, a millisecond-scale first-block allocation on journalling filesystems when the trace is empty — moves onto the deferred teardown thread, which lets the pre-fault machinery from the previous commit be deleted outright. Co-Authored-By: Claude Fable 5 --- crates/fspy_shared/src/ipc/channel/mod.rs | 80 ++++--- .../src/ipc/channel/shm_io/alloc_word.rs | 225 ------------------ .../src/ipc/channel/shm_io/layout.rs | 201 +++++++++------- .../fspy_shared/src/ipc/channel/shm_io/mod.rs | 145 ++++++----- .../src/ipc/channel/shm_io/reader.rs | 72 +++--- .../src/ipc/channel/shm_io/slot.rs | 2 +- .../src/ipc/channel/shm_io/state.rs | 211 ++++++++++------ .../src/ipc/channel/shm_io/writer.rs | 7 +- 8 files changed, 433 insertions(+), 510 deletions(-) delete mode 100644 crates/fspy_shared/src/ipc/channel/shm_io/alloc_word.rs diff --git a/crates/fspy_shared/src/ipc/channel/mod.rs b/crates/fspy_shared/src/ipc/channel/mod.rs index 051431f89..5619d523c 100644 --- a/crates/fspy_shared/src/ipc/channel/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/mod.rs @@ -44,19 +44,6 @@ pub fn channel(capacity: usize) -> io::Result<(ChannelConf, Receiver)> { let keeper = ShmKeeper { path: shm_c_path }; let mapping = handle.map().map_err(shm_error_to_io)?; - // Allocating the first block of the sparse backing file can cost - // milliseconds on journalling filesystems. Touch the header page through - // a second view concurrently with process startup, so neither a sender's - // first record nor `Receiver::close` pays that latency. Best-effort: a - // channel without the pre-fault is merely slower. - if let Ok(prefault_mapping) = handle.map() { - let _ = std::thread::Builder::new().name("fspy-shm-prefault".into()).spawn(move || { - // SAFETY: the mapping views the region created zero-initialized - // above, which is only accessed through the `shm_io` protocol. - unsafe { shm_io::pre_fault(&prefault_mapping) }; - }); - } - let conf = ChannelConf { shm_id: IpcStr::from_os_c_str(keeper.path.as_c_str()).to_boxed() }; Ok((conf, Receiver { _keeper: keeper, mapping })) @@ -247,16 +234,35 @@ impl Receiver { // through the `shm_io` protocol. let frames = unsafe { shm_io::close(&mapping) } .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err)); - // Unmapping a multi-gigabyte view can cost a millisecond or more. - // The frames are already copied out and nothing reads the mapping - // again, so release it off the caller's path. Best-effort: if the - // thread cannot spawn, its dropped closure unmaps inline instead. + // Two millisecond-scale teardown steps run off the caller's path: + // setting the claim gate (which write-faults the counter page — a + // first-block allocation on an otherwise-untouched region) and + // unmapping the multi-gigabyte view. Claims racing the gate land + // beyond the close snapshot and are never observed. Dropping the + // teardown performs both steps, so a failed thread spawn falls back + // to paying the latency here rather than leaving the gate unset. + let teardown = ChannelTeardown { mapping }; let _ = - std::thread::Builder::new().name("fspy-shm-unmap".into()).spawn(move || drop(mapping)); + std::thread::Builder::new().name("fspy-shm-close".into()).spawn(move || drop(teardown)); frames } } +/// Deferred channel teardown: sets the claim gate, then unmaps. +struct ChannelTeardown { + mapping: Mapping, +} + +impl Drop for ChannelTeardown { + fn drop(&mut self) { + // SAFETY: `mapping` is the receiver's view of the region created + // zero-initialized by `channel` and accessed only through the + // `shm_io` protocol; it stays mapped until this struct's fields + // drop below. + unsafe { shm_io::close_claims(&self.mapping) }; + } +} + #[cfg(test)] mod tests { use std::{ffi::OsString, fs, num::NonZeroUsize, str::from_utf8}; @@ -272,7 +278,7 @@ mod tests { /// must still attach. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn sender_ignores_changed_temp_and_working_directory() { - let (conf, receiver) = channel(100).unwrap(); + let (conf, receiver) = channel(4096).unwrap(); let changed_cwd = temp_dir().join(format!("fspy-ipc-changed-cwd-{}", Uuid::new_v4())); fs::create_dir(&changed_cwd).unwrap(); @@ -298,7 +304,9 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn smoke() { - let (conf, receiver) = channel(100).unwrap(); + // A deliberately odd, small capacity: the fixed partition must + // still yield a usable channel. + let (conf, receiver) = channel(1000).unwrap(); let cmd = command_for_fn!(conf, |conf: ChannelConf| { let sender = conf.sender().unwrap(); let frame_size = NonZeroUsize::new(2).unwrap(); @@ -344,11 +352,12 @@ mod tests { assert!(B(&output.stdout) == B("false")); } - /// A sender that attached before close keeps its mapping but cannot - /// claim any new frame afterwards. + /// A sender that attached before close keeps its mapping; its claims + /// after close are either gated (once the deferred CLOSED gate lands) + /// or dropped without ever appearing in the collected frames. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn attached_sender_cannot_claim_after_close() { - let (conf, receiver) = channel(4096).unwrap(); + async fn attached_sender_is_gated_after_close() { + let (conf, receiver) = channel(1024 * 1024).unwrap(); let sender = conf.sender().unwrap(); let mut frame = sender.claim_frame(NonZeroUsize::new(2).unwrap()).unwrap(); @@ -359,14 +368,29 @@ mod tests { assert!(frames.iter().next().unwrap() == &[4, 2]); assert!(frames.is_complete()); - assert!( - sender.claim_frame(NonZeroUsize::new(2).unwrap()).unwrap_err() == ClaimError::Closed - ); + // The gate is set on the deferred teardown thread; a 1 MiB channel + // holds thousands of slots, far more claims than the gate needs + // scheduling opportunities to land, so exhausting capacity first + // would mean the gate never fired. + loop { + match sender.claim_frame(NonZeroUsize::new(2).unwrap()) { + Err(ClaimError::Closed) => break, + Err(ClaimError::Capacity) => panic!("claim gate never landed"), + Ok(frame) => { + // Dropped claims land beyond the close snapshot; the + // collected frames stay as they were. + frame.finish(); + assert!(frames.iter().count() == 1); + std::thread::yield_now(); + } + } + } } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn concurrent_senders() { - let (conf, receiver) = channel(8192).unwrap(); + // 64 KiB: a 1023-slot table for the 200 frames sent below. + let (conf, receiver) = channel(64 * 1024).unwrap(); for i in 0u16..200 { let cmd = command_for_fn!((conf.clone(), i), |(conf, i): (ChannelConf, u16)| { let sender = conf.sender().unwrap(); diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/alloc_word.rs b/crates/fspy_shared/src/ipc/channel/shm_io/alloc_word.rs deleted file mode 100644 index cd862bf50..000000000 --- a/crates/fspy_shared/src/ipc/channel/shm_io/alloc_word.rs +++ /dev/null @@ -1,225 +0,0 @@ -//! Encoding of the allocator word — the single 64-bit value that admits -//! claims, closes the channel, and records lost frames. -//! -//! ```text -//! bit 63 bit 62 bits 32..=61 bits 0..=31 -//! CLOSED INCOMPLETE slot count (30) reserved payload bytes (32) -//! ``` -//! -//! - `CLOSED`: set once by the receiver; no claim is admitted afterwards. -//! - `INCOMPLETE`: set by a writer that lost a frame it may still act on -//! (capacity exhaustion, or abandoning a claimed frame while alive). The -//! receiver treats the trace as unusable for caching when this is set. -//! A frame lost to process death deliberately does *not* set this flag: -//! frames are published before the traced operation is performed, so a -//! process that died mid-frame never performed the operation. -//! - slot count / reserved payload bytes: the two region frontiers. Claims -//! move both in one compare-and-swap, so the regions can never overlap and -//! the receiver's close snapshot counts every admitted slot. -//! -//! This module is pure bit manipulation; the atomic operations applying these -//! values live in [`super::state`]. - -use super::layout; - -pub(super) const CLOSED: u64 = 1 << 63; -pub(super) const INCOMPLETE: u64 = 1 << 62; -const SLOT_COUNT_SHIFT: u32 = 32; -const SLOT_COUNT_MAX: u64 = (1 << 30) - 1; -const PAYLOAD_BYTES_MAX: u64 = u32::MAX as u64; - -/// A decoded snapshot of the allocator word. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub(super) struct AllocWord(u64); - -/// Why a claim was not admitted. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub(super) enum ReserveError { - /// The receiver has closed the channel. - Closed, - /// The frame does not fit: it is larger than [`layout::MAX_PAYLOAD_LEN`], - /// or the descriptor-table and payload frontiers would meet. - Capacity, -} - -/// A successful reservation of one descriptor slot and one payload span. -#[derive(Debug)] -pub(super) struct Reservation { - /// The allocator word to install with compare-and-swap. - pub(super) new_word: AllocWord, - /// Index of the reserved descriptor slot. - pub(super) slot_index: usize, - /// Byte offset of the reserved payload span. Word-aligned. - pub(super) payload_offset: usize, -} - -impl AllocWord { - pub(super) const fn from_bits(bits: u64) -> Self { - Self(bits) - } - - pub(super) const fn bits(self) -> u64 { - self.0 - } - - pub(super) const fn is_closed(self) -> bool { - self.0 & CLOSED != 0 - } - - pub(super) const fn is_incomplete(self) -> bool { - self.0 & INCOMPLETE != 0 - } - - pub(super) const fn slot_count(self) -> usize { - ((self.0 >> SLOT_COUNT_SHIFT) & SLOT_COUNT_MAX) as usize - } - - pub(super) const fn reserved_payload_bytes(self) -> usize { - (self.0 & PAYLOAD_BYTES_MAX) as usize - } - - /// Whether this word describes in-bounds regions of a `mapping_len`-byte - /// mapping. False only for words a correct writer never produces. - pub(super) const fn is_valid_for(self, mapping_len: usize) -> bool { - layout::fits(mapping_len, self.slot_count(), self.reserved_payload_bytes()) - } - - /// Computes the reservation of one slot and a word-aligned span for a - /// `payload_len`-byte payload, or reports why the claim is not admitted. - /// - /// Pure: the caller must install `new_word` with a compare-and-swap - /// against the word this was computed from. - pub(super) const fn reserve( - self, - payload_len: usize, - mapping_len: usize, - ) -> Result { - if self.is_closed() { - return Err(ReserveError::Closed); - } - if payload_len > layout::MAX_PAYLOAD_LEN { - return Err(ReserveError::Capacity); - } - let slot_index = self.slot_count(); - let new_slot_count = slot_index + 1; - let new_payload_bytes = - self.reserved_payload_bytes() + layout::reserved_payload_len(payload_len); - if new_slot_count as u64 > SLOT_COUNT_MAX - || new_payload_bytes as u64 > PAYLOAD_BYTES_MAX - || !layout::fits(mapping_len, new_slot_count, new_payload_bytes) - { - return Err(ReserveError::Capacity); - } - Ok(Reservation { - new_word: Self( - (self.0 & INCOMPLETE) - | ((new_slot_count as u64) << SLOT_COUNT_SHIFT) - | new_payload_bytes as u64, - ), - slot_index, - // `fits` guarantees `new_payload_bytes <= mapping_len`. - payload_offset: mapping_len - new_payload_bytes, - }) - } -} - -#[cfg(test)] -mod tests { - use assert2::assert; - - use super::*; - - const MAPPING_LEN: usize = 1024; - - #[test] - fn zero_word_is_open_and_empty() { - let word = AllocWord::from_bits(0); - assert!(!word.is_closed()); - assert!(!word.is_incomplete()); - assert!(word.slot_count() == 0); - assert!(word.reserved_payload_bytes() == 0); - assert!(word.is_valid_for(layout::HEADER_LEN)); - } - - #[test] - fn reserve_advances_both_frontiers() { - let word = AllocWord::from_bits(0); - let reservation = word.reserve(5, MAPPING_LEN).unwrap(); - assert!(reservation.slot_index == 0); - assert!(reservation.payload_offset == MAPPING_LEN - 8); - assert!(reservation.new_word.slot_count() == 1); - assert!(reservation.new_word.reserved_payload_bytes() == 8); - - let second = reservation.new_word.reserve(9, MAPPING_LEN).unwrap(); - assert!(second.slot_index == 1); - assert!(second.payload_offset == MAPPING_LEN - 8 - 16); - assert!(second.new_word.slot_count() == 2); - assert!(second.new_word.reserved_payload_bytes() == 24); - } - - #[test] - fn reserve_rejects_closed() { - let word = AllocWord::from_bits(CLOSED); - assert!(word.reserve(1, MAPPING_LEN).unwrap_err() == ReserveError::Closed); - } - - #[test] - fn reserve_preserves_incomplete() { - let word = AllocWord::from_bits(INCOMPLETE); - let reservation = word.reserve(1, MAPPING_LEN).unwrap(); - assert!(reservation.new_word.is_incomplete()); - assert!(!reservation.new_word.is_closed()); - } - - #[test] - fn reserve_rejects_oversized_payloads() { - let word = AllocWord::from_bits(0); - assert!( - word.reserve(layout::MAX_PAYLOAD_LEN + 1, usize::MAX).unwrap_err() - == ReserveError::Capacity - ); - } - - #[test] - fn reserve_stops_at_the_frontier_collision() { - // 80 bytes fit the header, one slot, and one 8-byte payload span. - let word = AllocWord::from_bits(0); - let reservation = word.reserve(8, 80).unwrap(); - assert!(reservation.payload_offset == 72); - assert!(reservation.new_word.reserve(1, 80).unwrap_err() == ReserveError::Capacity); - // A failed reservation leaves the word untouched by construction: - // `reserve` is pure and the caller never installs a failed result. - } - - #[test] - fn reserve_handles_the_4_gib_mapping_edge() { - let mapping_len = layout::MAX_MAPPING_LEN; - let max_payload = mapping_len - layout::HEADER_LEN - layout::SLOT_LEN; - // One frame cannot exceed MAX_PAYLOAD_LEN even if the mapping has room. - assert!( - AllocWord::from_bits(0).reserve(max_payload, mapping_len).unwrap_err() - == ReserveError::Capacity - ); - - // Fill the payload region with maximal frames until the 32-bit - // reserved-bytes field would have to exceed its width; the capacity - // check must fail first. - let mut word = AllocWord::from_bits(0); - loop { - match word.reserve(layout::MAX_PAYLOAD_LEN, mapping_len) { - Ok(reservation) => { - assert!(reservation.new_word.is_valid_for(mapping_len)); - word = reservation.new_word; - } - Err(err) => { - assert!(err == ReserveError::Capacity); - break; - } - } - } - assert!(word.slot_count() == 1); - // The remaining space still admits smaller frames. - let reservation = word.reserve(1024, mapping_len).unwrap(); - assert!(reservation.new_word.is_valid_for(mapping_len)); - } -} diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs index 552fa5d5c..6717b09e7 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs @@ -1,27 +1,41 @@ //! Pure geometry of the shared-memory region. //! -//! The mapping is divided into three areas: +//! The mapping is divided into three fixed areas: //! //! ```text -//! low addresses high addresses -//! | header | descriptor table (grows up) | free | payloads (grow down) | +//! | header | descriptor table (fixed capacity) | payloads (grow up) | //! ``` //! +//! The table gets an eighth of the space after the header (with a small +//! floor so tiny regions stay usable). An eighth is generous slack for +//! typical record shapes — one 8-byte descriptor per payload of a few +//! hundred bytes — and the region is sparse, so an oversized table costs +//! address space, not memory. +//! //! Everything in this module is arithmetic on plain integers — no atomics, -//! no pointers, no shared state. Overflow safety follows from two bounds -//! enforced at construction time and re-validated on every value read back -//! from shared memory: the mapping length never exceeds [`MAX_MAPPING_LEN`] -//! (so all offsets fit in the 32-bit descriptor fields) and all region -//! arithmetic is performed in `usize` on 64-bit targets (asserted in the -//! parent module), where sums of 32-bit-bounded quantities cannot overflow. - -/// Byte size of the region header. -/// -/// Only the first 8 bytes (the allocator word) are used; the rest keeps the -/// descriptor table off the allocator word's cache line and leaves room for -/// future header fields, which must start zeroed. +//! no pointers, no shared state. Overflow safety follows from one bound +//! enforced at construction time: the mapping length never exceeds +//! [`MAX_MAPPING_LEN`], so all offsets fit the 32-bit descriptor fields and +//! all sums fit `usize` on the 64-bit targets the parent module asserts. + +/// Byte size of the region header: the slot counter, the incomplete flag, +/// and the payload counter, padded so the descriptor table starts off the +/// counters' cache line and there is room for future header fields, which +/// must start zeroed. pub(super) const HEADER_LEN: usize = 64; +/// Byte offset of the claim counter (one `u64`: the CLOSED gate bit and the +/// number of claims attempted). +pub(super) const SLOT_COUNTER_OFFSET: usize = 0; + +/// Byte offset of the incomplete flag word (nonzero once a live writer lost +/// a record). +pub(super) const INCOMPLETE_OFFSET: usize = 8; + +/// Byte offset of the payload counter (one `u64`: payload bytes reserved, +/// including by failed claims — monotonic, never read by the receiver). +pub(super) const PAYLOAD_COUNTER_OFFSET: usize = 16; + /// Byte size of one descriptor slot. pub(super) const SLOT_LEN: usize = size_of::(); @@ -34,47 +48,50 @@ pub(super) const MAX_PAYLOAD_LEN: usize = i32::MAX as usize; /// Maximum supported mapping size. /// -/// Payload offsets and the reserved-payload counter are stored in 32 bits, so -/// every byte offset into the mapping must fit in `u32` arithmetic; a mapping -/// of exactly 4 GiB works because no payload can start at the very end. +/// Payload offsets are stored in 32 bits, so every byte offset into the +/// mapping must fit `u32` arithmetic. pub(super) const MAX_MAPPING_LEN: usize = 1 << 32; /// Rounds a payload length up to a multiple of the word size. /// /// Payload reservations are word-aligned — combined with the word-aligned -/// [`usable_len`] they grow down from, this keeps every payload offset -/// word-aligned so the receiver can copy payloads with aligned 64-bit atomic -/// loads, and the sub-word padding stays inside the frame's own reservation. +/// region base they grow from, this keeps every payload offset word-aligned +/// so the receiver can copy payloads with aligned 64-bit atomic loads, and +/// the sub-word padding stays inside the frame's own reservation. pub(super) const fn reserved_payload_len(payload_len: usize) -> usize { payload_len.next_multiple_of(SLOT_LEN) } -/// The protocol-usable prefix of a mapping: its length rounded down to word -/// alignment, so payload spans growing from the end stay word-aligned even -/// when the creator requested an odd capacity. -pub(super) const fn usable_len(mapping_len: usize) -> usize { - mapping_len - mapping_len % SLOT_LEN +/// Byte size of the descriptor table in a mapping of `mapping_len` bytes. +pub(super) const fn table_len(mapping_len: usize) -> usize { + let available = mapping_len - HEADER_LEN; + // An eighth for descriptors, floored at eight slots so small (test) + // regions hold a few frames, and never more than the available space. + let len = available / 8; + let len = if len < 8 * SLOT_LEN { 8 * SLOT_LEN } else { len }; + let len = if len > available { available } else { len }; + len - len % SLOT_LEN } -/// First byte offset after a descriptor table of `slot_count` slots. -pub(super) const fn table_end(slot_count: usize) -> usize { - HEADER_LEN + slot_count * SLOT_LEN +/// Number of descriptor slots in a mapping of `mapping_len` bytes. +pub(super) const fn max_slots(mapping_len: usize) -> usize { + table_len(mapping_len) / SLOT_LEN } -/// Whether a mapping of `mapping_len` bytes can hold `slot_count` descriptors -/// and `payload_bytes` reserved payload bytes without the regions meeting. -/// -/// Also the validity check for an allocator word read back from shared -/// memory: any word this function accepts yields in-bounds table and payload -/// regions. -pub(super) const fn fits(mapping_len: usize, slot_count: usize, payload_bytes: usize) -> bool { - // Both operands are bounded by their allocator-word field widths (30 and - // 32 bits), so the sum cannot overflow 64-bit `usize` arithmetic. - table_end(slot_count) + payload_bytes <= mapping_len +/// Byte offset where the payload region starts. Word-aligned. +pub(super) const fn payload_base(mapping_len: usize) -> usize { + HEADER_LEN + table_len(mapping_len) +} + +/// Byte size of the payload region. A multiple of the word size, so a +/// word-aligned reservation inside it never reaches past `mapping_len`. +pub(super) const fn payload_region_len(mapping_len: usize) -> usize { + let len = mapping_len - payload_base(mapping_len); + len - len % SLOT_LEN } /// A validated payload byte range: the witness that offset arithmetic on this -/// span cannot leave the mapping or touch the descriptor table. +/// span cannot leave the payload region. /// /// Constructing a `PayloadSpan` through [`PayloadSpan::validate`] is the /// single validation point for descriptor metadata read back from shared @@ -89,27 +106,25 @@ pub(super) struct PayloadSpan { } impl PayloadSpan { - /// Validates a committed descriptor's payload range against the final - /// region layout. Returns `None` if the range could not have been - /// produced by a correct writer. - pub(super) const fn validate( - mapping_len: usize, - table_end: usize, - offset: usize, - len: usize, - ) -> Option { + /// Validates a committed descriptor's payload range against the payload + /// region of a `mapping_len`-byte mapping. Returns `None` if the range + /// could not have been produced by a correct writer. + pub(super) const fn validate(mapping_len: usize, offset: usize, len: usize) -> Option { if len == 0 || len > MAX_PAYLOAD_LEN { return None; } - // Writers reserve word-aligned spans from the word-aligned mapping - // end, so a valid offset is word-aligned and its padded length stays - // inside the mapping. + // Writers reserve word-aligned spans from the word-aligned region + // base, so a valid offset is word-aligned and its padded length stays + // inside the region. if !offset.is_multiple_of(SLOT_LEN) { return None; } - // `offset` and `len` come from 32-bit descriptor fields, so this sum - // cannot overflow `usize`. - if offset < table_end || offset + reserved_payload_len(len) > mapping_len { + let base = payload_base(mapping_len); + // `offset` and `len` come from 32-bit descriptor fields, so these + // sums cannot overflow `usize`. + if offset < base + || offset + reserved_payload_len(len) > base + payload_region_len(mapping_len) + { return None; } Some(Self { offset, len }) @@ -137,55 +152,59 @@ mod tests { } #[test] - fn usable_len_rounds_down_to_words() { - assert!(usable_len(100) == 96); - assert!(usable_len(96) == 96); - assert!(usable_len(MAX_MAPPING_LEN) == MAX_MAPPING_LEN); - } - - #[test] - fn table_end_starts_after_header() { - assert!(table_end(0) == HEADER_LEN); - assert!(table_end(3) == HEADER_LEN + 24); + fn partition_is_aligned_and_disjoint() { + for mapping_len in [64, 100, 128, 1024, 4096, 1 << 20, MAX_MAPPING_LEN] { + let table = table_len(mapping_len); + let base = payload_base(mapping_len); + let region = payload_region_len(mapping_len); + assert!(table % SLOT_LEN == 0); + assert!(base % SLOT_LEN == 0); + assert!(region % SLOT_LEN == 0); + assert!(base == HEADER_LEN + table); + assert!(base + region <= mapping_len); + } } #[test] - fn fits_detects_frontier_collision() { - // 64-byte header + 1 slot + 8 payload bytes exactly fill 80 bytes. - assert!(fits(80, 1, 8)); - assert!(!fits(80, 1, 16)); - assert!(!fits(80, 2, 8)); - assert!(!fits(72, 1, 8)); + fn partition_gives_an_eighth_to_the_table() { + assert!(table_len(1 << 20) == (1 << 20) / 8 - 8); + assert!(max_slots(1 << 20) == 16383); + // The 4 GiB production mapping: ~67M slots, ~3.4 GiB of payloads. + assert!(max_slots(MAX_MAPPING_LEN) == ((MAX_MAPPING_LEN - HEADER_LEN) / 8) / 8); } #[test] - fn fits_handles_the_4_gib_mapping() { - let max_payload = MAX_MAPPING_LEN - HEADER_LEN - SLOT_LEN; - assert!(fits(MAX_MAPPING_LEN, 1, max_payload)); - assert!(!fits(MAX_MAPPING_LEN, 1, max_payload + 8)); - // The largest admissible slot count leaves no payload space. - let max_slots = (MAX_MAPPING_LEN - HEADER_LEN) / SLOT_LEN; - assert!(fits(MAX_MAPPING_LEN, max_slots, 0)); - assert!(!fits(MAX_MAPPING_LEN, max_slots + 1, 0)); + fn tiny_regions_floor_the_table_at_eight_slots() { + // Enough space: eight slots, remainder to payloads. + assert!(max_slots(1024) == 15); + assert!(max_slots(256) == 8); + // Not enough space for the floor: the table takes what exists and + // payload capacity degrades to zero; claims fail gracefully. + assert!(table_len(100) == 32); + assert!(payload_region_len(100) == 0); + assert!(table_len(64) == 0); + assert!(max_slots(64) == 0); } #[test] fn payload_span_validates_bounds() { - let table_end = table_end(2); - // A word-aligned span inside the payload region. - assert!(PayloadSpan::validate(1024, table_end, 1016, 8).is_some()); - // Exact end of the mapping. - assert!(PayloadSpan::validate(1024, table_end, 1016, 5).is_some()); + let mapping_len = 1024; + let base = payload_base(mapping_len); + let region = payload_region_len(mapping_len); + // A word-aligned span at the region start. + assert!(PayloadSpan::validate(mapping_len, base, 8).is_some()); + // Exact end of the region, with padding inside it. + assert!(PayloadSpan::validate(mapping_len, base + region - 8, 5).is_some()); // Zero length is never committed. - assert!(PayloadSpan::validate(1024, table_end, 512, 0).is_none()); - // Padded length may not cross the end of the mapping. - assert!(PayloadSpan::validate(1024, table_end, 1020, 8).is_none()); - assert!(PayloadSpan::validate(1024, table_end, 1016, 9).is_none()); + assert!(PayloadSpan::validate(mapping_len, base, 0).is_none()); + // Padded length may not cross the end of the region. + assert!(PayloadSpan::validate(mapping_len, base + region - 8, 9).is_none()); // Payloads may not reach into the descriptor table. - assert!(PayloadSpan::validate(1024, table_end, table_end - 8, 8).is_none()); + assert!(PayloadSpan::validate(mapping_len, base - 8, 8).is_none()); + assert!(PayloadSpan::validate(mapping_len, HEADER_LEN, 8).is_none()); // Unaligned offsets cannot come from a correct writer. - assert!(PayloadSpan::validate(1024, table_end, 1017, 7).is_none()); + assert!(PayloadSpan::validate(mapping_len, base + 4, 4).is_none()); // Oversized lengths are rejected before any arithmetic. - assert!(PayloadSpan::validate(1024, table_end, 512, MAX_PAYLOAD_LEN + 1).is_none()); + assert!(PayloadSpan::validate(mapping_len, base, MAX_PAYLOAD_LEN + 1).is_none()); } } diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs index 1c8063396..cdb152b76 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs @@ -8,18 +8,22 @@ //! # Region layout //! //! ```text -//! low addresses high addresses -//! +--------+--------+--------+-------+------+-----------+-----------+ -//! | header | slot 0 | slot 1 | ... | free | payload 1 | payload 0 | -//! +--------+--------+--------+-------+------+-----------+-----------+ -//! descriptor table grows -> <- payloads grow +//! low addresses high addresses +//! +--------+--------+--------+---------+-----------+-----------+------+ +//! | header | slot 0 | slot 1 | ... | payload 0 | payload 1 | ... | +//! +--------+--------+--------+---------+-----------+-----------+------+ +//! fixed descriptor table payloads grow up -> //! ``` //! -//! The header holds one allocator word ([`alloc_word`]) that admits claims, -//! closes the channel, and records lost frames. Each frame owns one atomic -//! descriptor slot ([`slot`]) and one payload span; a claim reserves both -//! with a single compare-and-swap, so the two regions never overlap and an -//! unfinished frame can never hide a later one. +//! The header holds three monotonic words ([`state`]): a claim counter +//! carrying the CLOSED gate bit, a payload counter, and an incomplete flag. +//! A claim is two wait-free `fetch_add`s — one reserves payload bytes, one +//! reserves a descriptor slot — validated against the fixed region bounds +//! from the returned old values. Failed claims overshoot the counters +//! harmlessly: readers clamp to the region capacities, and committed +//! descriptors are self-describing ([`slot`]), so the counters never locate +//! data. Every slot has a fixed location, so an unfinished frame can never +//! hide a later one. //! //! # Frame lifecycle //! @@ -39,19 +43,25 @@ //! //! # Close boundary //! -//! [`close`] admits no further claims; an already admitted writer races the -//! freeze pass per slot and its frame is either included (commit won) or -//! ignored (abort won) — never torn. Ignoring unfinished frames is sound -//! because writers publish a record *before* performing the recorded -//! operation: a process that died mid-frame never performed the operation, -//! and one that lost the close race performs it outside the run's tracking +//! [`close`]'s boundary is a snapshot of the claim counter. A writer +//! admitted before the snapshot races the freeze pass per slot and its +//! frame is either included (commit won) or ignored (abort won) — never +//! torn; a claim after the snapshot lands in a slot the receiver never +//! visits and is dropped. Both drops are sound because writers publish a +//! record *before* performing the recorded operation: a process that died +//! mid-frame never performed the operation, and one that claimed or +//! committed after the snapshot performs it outside the run's tracking //! boundary. A live writer that loses a record *before* close (capacity, //! abandonment) flags the trace incomplete ([`Frames::is_complete`]). //! +//! [`close_claims`] sets the CLOSED gate afterwards, off the +//! latency-sensitive path, so straggler processes eventually stop claiming +//! (and stop materializing pages of the region) rather than writing into +//! the void forever. +//! //! Correctness never depends on writer-side cleanup: no exit hooks, PID //! checks, heartbeats, or timeouts. -mod alloc_word; mod layout; mod reader; mod slot; @@ -95,17 +105,21 @@ pub unsafe fn close(mem: &impl AsRawSlice) -> Result { unsafe { reader::close(mem.as_raw_slice()) } } -/// Materializes the page backing the protocol header without changing -/// protocol state, so that the first claim and [`close`] never pay for the -/// backing file's first block allocation — a millisecond-scale cost on some -/// journalling filesystems. Run it off any latency-sensitive path. +/// Sets the CLOSED gate so writers stop claiming once they observe it. +/// +/// Deliberately separate from [`close`]: the boundary is [`close`]'s +/// snapshot, and this write — which materializes the counter page, a +/// millisecond-scale first-block allocation on some journalling +/// filesystems when the trace is empty — belongs off the latency-sensitive +/// path. Claims landing between the snapshot and the gate are dropped +/// soundly (see the module docs). /// /// # Safety /// /// Same contract as [`ShmWriter::new`]. -pub unsafe fn pre_fault(mem: &impl AsRawSlice) { +pub unsafe fn close_claims(mem: &impl AsRawSlice) { // SAFETY: forwarded from this function's contract. - unsafe { state::SharedState::borrow(mem.as_raw_slice()) }.pre_fault(); + unsafe { reader::close_claims(mem.as_raw_slice()) } } #[cfg(test)] @@ -238,17 +252,18 @@ mod tests { // SAFETY: see `single_thread_basic`. let writer = unsafe { ShmWriter::new(shm.clone()) }; - // Larger than the mapping, and larger than the absolute frame limit: - // both fail the claim without touching the frontiers. + assert!(writer.try_write_frame(b"test")); + + // Larger than the payload region, and larger than the absolute frame + // limit: both fail the claim. The failed reservation stays counted — + // harmless, because the failure already made the trace incomplete + // and therefore uncacheable. assert!(!writer.try_write_frame(&vec![0u8; 2048])); assert!( writer.claim_frame(((i32::MAX as usize) + 1).try_into().unwrap()).unwrap_err() == ClaimError::Capacity ); - // Small frames still fit afterwards. - assert!(writer.try_write_frame(b"test")); - let frames = collect_frames(&shm); let mut iter = frames.iter(); assert!(iter.next().unwrap() == b"test"); @@ -348,30 +363,24 @@ mod tests { } #[test] - fn pre_fault_does_not_disturb_protocol_state() { + fn slot_capacity_failure_marks_incomplete() { + // A 1024-byte region has a 15-slot table; the 16th claim must fail + // on the slot side while payload space remains. let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. let writer = unsafe { ShmWriter::new(shm.clone()) }; - - // On the untouched region, before any claim. - // SAFETY: see `collect_frames`. - unsafe { pre_fault(&shm) }; - assert!(writer.try_write_frame(b"foo")); - // Racing an already claimed region must change nothing either. - // SAFETY: see `collect_frames`. - unsafe { pre_fault(&shm) }; - assert!(writer.try_write_frame(b"bar")); + for _ in 0..15 { + assert!(writer.try_write_frame(b"x")); + } + assert!(writer.claim_frame(1.try_into().unwrap()).unwrap_err() == ClaimError::Capacity); let frames = collect_frames(&shm); - let mut iter = frames.iter(); - assert!(iter.next().unwrap() == b"foo"); - assert!(iter.next().unwrap() == b"bar"); - assert!(iter.next() == None); - assert!(frames.is_complete()); + assert!(frames.iter().count() == 15); + assert!(!frames.is_complete()); } #[test] - fn claim_after_close_fails_without_poisoning() { + fn claims_between_snapshot_and_gate_are_dropped() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. let writer = unsafe { ShmWriter::new(shm.clone()) }; @@ -379,16 +388,25 @@ mod tests { assert!(!writer.is_closed()); let frames = collect_frames(&shm); - assert!(frames.iter().count() == 1); + let mut iter = frames.iter(); + assert!(iter.next().unwrap() == b"foo"); + assert!(iter.next() == None); assert!(frames.is_complete()); - // Claims of an admitted-but-late writer fail cleanly and do not mark + // The gate is not set yet: a straggler's claim is admitted, lands + // beyond the snapshot, and commits into a slot the receiver never + // read — the record is dropped, not torn, and not incomplete. + let mut late = writer.claim_frame(5.try_into().unwrap()).unwrap(); + late.copy_from_slice(b"late!"); + late.finish(); + assert!(frames.iter().count() == 1); + + // Once the gate lands, claims fail cleanly and still do not mark // the trace incomplete: the access is outside the closed boundary. + // SAFETY: see `collect_frames`. + unsafe { close_claims(&shm) }; assert!(writer.is_closed()); assert!(writer.claim_frame(5.try_into().unwrap()).unwrap_err() == ClaimError::Closed); - - let frames = collect_frames(&shm); - assert!(frames.iter().count() == 1); assert!(frames.is_complete()); } @@ -417,7 +435,8 @@ mod tests { #[test] fn concurrent() { - let shm = MockedShm::alloc(1024 * 4); + // 16 KiB: a 255-slot table for the 120 frames written below. + let shm = MockedShm::alloc(1024 * 16); thread::scope(|s| { for _ in 0..4 { @@ -470,9 +489,9 @@ mod tests { let frame = BStr::new(frame); assert!(frame == b"hello" || frame == b"foo" || frame == b"this is a test"); } - // Some writes must have succeeded, some must have failed on capacity; - // the failures poison completeness. - assert!(count > 20); + // Some writes must have succeeded (the table holds 15 slots), some + // must have failed on capacity; the failures poison completeness. + assert!(count > 5); assert!(count < 120); assert!(!frames.is_complete()); } @@ -562,14 +581,22 @@ mod tests { } #[test] - fn corrupt_allocator_word_is_a_protocol_error() { + fn overshot_claim_counter_clamps_to_the_table() { let shm = MockedShm::alloc(1024); - // A slot count whose table exceeds the mapping. - shm.poke_word(0, ((1u64 << 30) - 1) << 32); + // SAFETY: see `single_thread_basic`. + let writer = unsafe { ShmWriter::new(shm.clone()) }; + assert!(writer.try_write_frame(b"hello")); - // SAFETY: see `collect_frames`. - let result = unsafe { close(&shm) }; - assert!(result.unwrap_err() == ProtocolError::CorruptAllocator); + // A wildly inflated claim counter — mass claim failures or a foreign + // scribble — degrades to a full-table sweep, never out-of-bounds + // slot access: the committed frame survives, the untouched slots + // freeze as aborted. + shm.poke_word(0, (1 << 40) | 1); + + let frames = collect_frames(&shm); + let mut iter = frames.iter(); + assert!(iter.next().unwrap() == b"hello"); + assert!(iter.next() == None); } #[test] diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs index 400da3a89..06da49fae 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs @@ -1,8 +1,13 @@ //! The receiver side: closing the channel and collecting committed frames. //! -//! Closing never waits for writers. It prevents new claims, atomically -//! freezes every slot that never committed, validates the committed -//! descriptors, and copies their payloads out of the shared mapping. +//! Closing never waits for writers, and never writes shared memory beyond +//! freezing claimed slots: the close boundary is a snapshot load of the +//! claim counter, unfinished slots inside the snapshot are atomically +//! frozen, committed descriptors are validated, and their payloads are +//! copied out of the shared mapping. Claims that arrive after the snapshot +//! receive slot indices this pass never visits — the CLOSED gate that +//! eventually stops them is set separately, off the latency-sensitive path +//! (see [`super::state`]'s rule 1). //! //! The copy is deliberate: the mapping stays writable in every traced //! process, so this module never creates a reference into shared memory @@ -13,7 +18,7 @@ use std::ops::Range; use super::{ - layout::{self, PayloadSpan}, + layout::PayloadSpan, slot::{self, SlotState}, state::SharedState, }; @@ -47,17 +52,15 @@ impl Frames { /// protocol. The mapping was corrupted; the trace is unusable. #[derive(thiserror::Error, Clone, Copy, PartialEq, Eq, Debug)] pub enum ProtocolError { - #[error("corrupt shared-memory allocator word")] - CorruptAllocator, #[error("corrupt shared-memory frame descriptor at slot {slot_index}")] CorruptDescriptor { slot_index: usize }, } /// Closes the channel and collects the committed frames. /// -/// Never blocks on writers: writers admitted before the close race per slot, -/// and each raced slot independently ends up committed (included) or aborted -/// (excluded). See the crate-level protocol docs in [`super`]. +/// Never blocks on writers: writers admitted before the snapshot race per +/// slot, and each raced slot independently ends up committed (included) or +/// aborted (excluded). See the crate-level protocol docs in [`super`]. /// /// # Safety /// @@ -74,32 +77,24 @@ pub(super) unsafe fn close(mem: *mut [u8]) -> Result { // SAFETY: forwarded from this function's contract. let state = unsafe { SharedState::borrow(mem) }; - // Snapshot the admitted slots and stop admitting new ones. Claims and - // close totally order on the allocator word: every claim admitted before - // this operation is counted, every later one fails with `Closed`. - let snapshot = state.close(); - if !snapshot.is_valid_for(state.mapping_len()) { - return Err(ProtocolError::CorruptAllocator); - } - let slot_count = snapshot.slot_count(); - let table_end = layout::table_end(slot_count); + // The close boundary: claims at or before this snapshot are inside it, + // later ones land in slots this pass never visits. The count is clamped + // to the table capacity, so a counter inflated by failed claims (or by a + // foreign scribble) degrades to a full-table sweep, not an error. + let slot_count = state.snapshot_claims(); // Freeze pass: drive every admitted slot to a terminal state and collect - // the committed spans. After this loop the descriptor table can no - // longer change — late writers lose their commit race against `ABORTED`. + // the committed spans. After this loop the snapshot's slice of the + // descriptor table can no longer change — late writers lose their commit + // race against `ABORTED`. let mut spans = Vec::new(); let mut payload_total = 0usize; for slot_index in 0..slot_count { match slot::decode(state.freeze(slot_index)) { SlotState::Aborted => {} SlotState::Committed { payload_offset, payload_len } => { - let span = PayloadSpan::validate( - state.mapping_len(), - table_end, - payload_offset, - payload_len, - ) - .ok_or(ProtocolError::CorruptDescriptor { slot_index })?; + let span = PayloadSpan::validate(state.mapping_len(), payload_offset, payload_len) + .ok_or(ProtocolError::CorruptDescriptor { slot_index })?; spans.push(span); payload_total += span.len; } @@ -120,11 +115,24 @@ pub(super) unsafe fn close(mem: *mut [u8]) -> Result { bounds.push(start..bytes.len()); } - // Re-read the incomplete flag only after freezing: a writer sets it - // before performing an operation whose record was lost, so any flag this - // load misses belongs to an operation performed after close — outside - // the tracking boundary (rule 1 in `state`'s ordering contract). - let complete = !state.reload().is_incomplete(); + // Read the incomplete flag only after freezing: a writer sets it before + // performing an operation whose record was lost, so any flag this load + // misses belongs to an operation performed after the boundary (rule 1 in + // `state`'s ordering contract). + let complete = !state.is_incomplete(); Ok(Frames { bytes, bounds, complete }) } + +/// Sets the CLOSED gate so writers stop claiming (and stop allocating pages +/// of the region) once they observe it. See [`SharedState::close_claims`]: +/// deliberately separate from [`close`] so the gate's page-materializing +/// write can run off the latency-sensitive path. +/// +/// # Safety +/// +/// Same contract as [`close`]. +pub(super) unsafe fn close_claims(mem: *mut [u8]) { + // SAFETY: forwarded from this function's contract. + unsafe { SharedState::borrow(mem) }.close_claims(); +} diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/slot.rs b/crates/fspy_shared/src/ipc/channel/shm_io/slot.rs index 937af4fae..712f815e0 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/slot.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/slot.rs @@ -15,7 +15,7 @@ //! committed value is always nonzero and the three states are disjoint. //! Committed and aborted are terminal: no protocol operation overwrites them. //! -//! Like [`super::alloc_word`], this module is pure; the compare-and-swap +//! Like [`super::layout`], this module is pure; the compare-and-swap //! transitions live in [`super::state`]. pub(super) const UNFINISHED: u64 = 0; diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/state.rs b/crates/fspy_shared/src/ipc/channel/shm_io/state.rs index 0aacc9e81..610186154 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/state.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/state.rs @@ -5,19 +5,39 @@ //! construction contract of [`SharedState::borrow`]; no other module reads or //! writes the mapping directly. //! +//! # Shared words +//! +//! The header holds three independent monotonic words (offsets in +//! [`layout`]): +//! +//! - the **claim counter**: bit 63 is the CLOSED gate, the low bits count +//! claims ever attempted. Claiming is one wait-free `fetch_add`; the +//! returned old value carries the claim's slot index, the gate, and — by +//! comparison against the fixed table capacity — the capacity verdict. +//! Failed claims still count, so the counter can overshoot the capacity; +//! readers clamp instead of trusting it. +//! - the **payload counter**: payload bytes ever reserved, bumped by another +//! wait-free `fetch_add`. Also overshoots on failure. Never read by the +//! receiver — committed descriptors are self-describing. +//! - the **incomplete flag**: nonzero once a live writer lost a record. +//! //! # Memory-ordering contract //! //! Three synchronization rules cover the whole protocol: //! -//! 1. **Claim versus close** — both are read-modify-writes of the allocator -//! word, so its modification order alone decides whether a claim is -//! admitted before the close snapshot. A claim publishes no payload data, -//! so `Relaxed` suffices ([`SharedState::try_claim`]). The `INCOMPLETE` -//! flag rides the same word: a writer sets it (`Relaxed` RMW) before -//! performing the operation whose record was lost, and the receiver -//! re-reads the word after freezing ([`SharedState::reload`]); a flag the -//! receiver's reload misses therefore belongs to an operation performed -//! after close, outside the tracking boundary. +//! 1. **Claim versus close** — the receiver's close boundary is a plain +//! snapshot load of the claim counter: claims ordered at or before the +//! value it reads (in the counter's modification order) are in the +//! snapshot; later ones receive slot indices the receiver never visits. +//! Claims publish no payload data, so `Relaxed` suffices throughout. +//! The CLOSED gate is set *after* collection (off the latency-sensitive +//! path): it only stops stragglers from working and allocating pages +//! forever; any claim admitted between the snapshot and the gate lands +//! beyond the snapshot and is never observed. The incomplete flag rides +//! the same rule: a writer sets it (`Relaxed` RMW) before performing the +//! operation whose record was lost, and the receiver re-reads it after +//! freezing; a flag the receiver misses therefore belongs to an operation +//! performed after the boundary. //! 2. **Writer commit** — the slot compare-and-swap uses `Release` //! ([`SharedState::commit`]): every payload write happens-before the //! committed descriptor becomes visible. @@ -39,11 +59,32 @@ use std::{ }; use super::{ - alloc_word::{self, AllocWord, Reservation, ReserveError}, layout::{self, PayloadSpan}, slot, }; +/// The CLOSED gate bit of the claim counter. The low 63 bits count claims, +/// so no realistic claim volume can carry into the gate. +const CLOSED: u64 = 1 << 63; + +/// Why a claim was not admitted. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(super) enum ReserveError { + /// The receiver has closed the channel. + Closed, + /// The frame is oversized, or its region is out of capacity. + Capacity, +} + +/// A successful reservation of one descriptor slot and one payload span. +#[derive(Debug)] +pub(super) struct Reservation { + /// Index of the reserved descriptor slot. + pub(super) slot_index: usize, + /// Byte offset of the reserved payload span. Word-aligned. + pub(super) payload_offset: usize, +} + /// A borrowed view of the shared mapping with protocol-level operations. #[derive(Clone, Copy)] pub(super) struct SharedState<'m> { @@ -72,12 +113,9 @@ impl<'m> SharedState<'m> { /// broken caller, not runtime data. pub(super) unsafe fn borrow(mem: *mut [u8]) -> Self { let base = mem.cast::(); + let len = mem.len(); assert!(base.addr().is_multiple_of(align_of::())); - assert!(mem.len() <= layout::MAX_MAPPING_LEN); - // Ignore any sub-word tail so payload spans growing from the end - // stay word-aligned. - let len = layout::usable_len(mem.len()); - assert!(len >= layout::HEADER_LEN); + assert!((layout::HEADER_LEN..=layout::MAX_MAPPING_LEN).contains(&len)); Self { base, len, _mapping: PhantomData } } @@ -85,68 +123,83 @@ impl<'m> SharedState<'m> { self.len } - /// The allocator word at the start of the header. - const fn word(self) -> &'m AtomicU64 { + /// Number of descriptor slots the fixed table holds. + pub(super) const fn max_slots(self) -> usize { + layout::max_slots(self.len) + } + + /// A header word. `offset` must be one of the `layout` header offsets. + fn header_word(self, offset: usize) -> &'m AtomicU64 { + debug_assert!(offset < layout::HEADER_LEN); // SAFETY: `borrow` checked that the mapping is word-aligned and at - // least `HEADER_LEN` bytes, so the first 8 bytes are a valid, aligned - // `AtomicU64` for `'m`. - unsafe { AtomicU64::from_ptr(self.base.cast()) } + // least `HEADER_LEN` bytes, so every word-aligned header offset is a + // valid, aligned `AtomicU64` for `'m`. + unsafe { AtomicU64::from_ptr(self.base.add(offset).cast()) } } /// The descriptor slot at `index`. /// /// # Panics /// - /// Panics when the slot lies outside the mapping. Callers only pass - /// indices below an admitted (writer) or validated (receiver) slot - /// count, so the assertion documents an invariant rather than guarding - /// runtime data. + /// Panics when the slot lies outside the fixed table. Callers only pass + /// indices below an admitted (writer) or clamped (receiver) slot count, + /// so the assertion documents an invariant rather than guarding runtime + /// data. fn slot_atomic(self, index: usize) -> &'m AtomicU64 { - assert!(layout::table_end(index + 1) <= self.len); - // SAFETY: the assertion keeps the slot inside the mapping, and the - // table consists of word-aligned 8-byte slots after the aligned - // header. - unsafe { AtomicU64::from_ptr(self.base.add(layout::table_end(index)).cast()) } - } - - /// Reads the allocator word without synchronization (rule 1: claims and - /// flags need no payload visibility). - pub(super) fn load_word(self) -> AllocWord { - AllocWord::from_bits(self.word().load(Ordering::Relaxed)) + assert!(index < self.max_slots()); + // SAFETY: the assertion keeps the slot inside the fixed table, which + // consists of word-aligned 8-byte slots after the aligned header. + unsafe { + AtomicU64::from_ptr(self.base.add(layout::HEADER_LEN + index * layout::SLOT_LEN).cast()) + } } - /// Forces the page holding the allocator word to be materialized by the - /// operating system before a latency-sensitive path writes it. - /// - /// A compare-exchange of zero with zero: on an untouched page it performs - /// a real write — allocating the first block of a sparse backing file, - /// which can cost milliseconds on journalling filesystems — without - /// changing protocol state. If a claim got there first, the page is - /// already backed and the failed exchange changes nothing. (An `or` of - /// zero would not do: the compiler may lower it to a plain load, which - /// maps the shared zero page without allocating anything.) - pub(super) fn pre_fault(self) { - let _ = self.word().compare_exchange(0, 0, Ordering::Relaxed, Ordering::Relaxed); + /// Whether the CLOSED gate has been set. + pub(super) fn is_closed(self) -> bool { + self.header_word(layout::SLOT_COUNTER_OFFSET).load(Ordering::Relaxed) & CLOSED != 0 } /// Atomically reserves one descriptor slot and one payload span. + /// + /// Wait-free: two `fetch_add`s, no retry loop (rule 1). pub(super) fn try_claim(self, payload_len: usize) -> Result { - let word = self.word(); - let mut current = word.load(Ordering::Relaxed); - loop { - let reservation = AllocWord::from_bits(current).reserve(payload_len, self.len)?; - // Rule 1: `Relaxed` — admission is decided by the modification - // order of the allocator word alone. - match word.compare_exchange_weak( - current, - reservation.new_word.bits(), - Ordering::Relaxed, - Ordering::Relaxed, - ) { - Ok(_) => return Ok(reservation), - Err(actual) => current = actual, - } + if payload_len > layout::MAX_PAYLOAD_LEN { + return Err(ReserveError::Capacity); + } + let reserved_len = layout::reserved_payload_len(payload_len); + + // Payload bytes first, so a payload-capacity failure does not burn a + // slot. A failed reservation stays counted — overshoot is harmless + // because the counter is not what locates payloads (descriptors are) + // and a `u64` cannot realistically wrap. + let payload_start = self + .header_word(layout::PAYLOAD_COUNTER_OFFSET) + .fetch_add(reserved_len as u64, Ordering::Relaxed); + // Checked: a foreign scribble of the counter must fail the claim, + // not wrap the bound into an out-of-bounds reservation. + let payload_end = payload_start.checked_add(reserved_len as u64); + if payload_end.is_none_or(|end| end > layout::payload_region_len(self.len) as u64) { + return Err(ReserveError::Capacity); + } + + let claims = self.header_word(layout::SLOT_COUNTER_OFFSET).fetch_add(1, Ordering::Relaxed); + if claims & CLOSED != 0 { + return Err(ReserveError::Closed); + } + let slot_index = usize::try_from(claims).expect("claim count exceeds usize"); + if slot_index >= self.max_slots() { + return Err(ReserveError::Capacity); } + + // The capacity check bounded `payload_start` by the region length, + // which fits `usize`. + let payload_start = usize::try_from(payload_start).expect("bounded by the payload region"); + Ok(Reservation { + slot_index, + // In bounds: `payload_start + reserved_len` fits the region, and + // the region ends within the mapping (`layout`). + payload_offset: layout::payload_base(self.len) + payload_start, + }) } /// Records that a frame this process may still act on was lost. @@ -154,18 +207,31 @@ impl<'m> SharedState<'m> { /// Must be called before the operation whose record was lost is /// performed (rule 1). pub(super) fn flag_incomplete(self) { - self.word().fetch_or(alloc_word::INCOMPLETE, Ordering::Relaxed); + self.header_word(layout::INCOMPLETE_OFFSET).fetch_or(1, Ordering::Relaxed); } - /// Closes the channel and snapshots the admitted slot count. - pub(super) fn close(self) -> AllocWord { - AllocWord::from_bits(self.word().fetch_or(alloc_word::CLOSED, Ordering::AcqRel)) + /// Whether any live writer lost a record. Read after the freeze pass + /// (rule 1). + pub(super) fn is_incomplete(self) -> bool { + self.header_word(layout::INCOMPLETE_OFFSET).load(Ordering::Relaxed) != 0 } - /// Re-reads the allocator word; used after the freeze pass to observe - /// `INCOMPLETE` flags set while closing (rule 1). - pub(super) fn reload(self) -> AllocWord { - AllocWord::from_bits(self.word().load(Ordering::Acquire)) + /// Snapshots the number of admitted claims: the receiver's close + /// boundary (rule 1). Clamped to the table capacity because failed + /// claims overshoot the counter. + pub(super) fn snapshot_claims(self) -> usize { + let claims = self.header_word(layout::SLOT_COUNTER_OFFSET).load(Ordering::Relaxed); + usize::try_from(claims & !CLOSED).unwrap_or(usize::MAX).min(self.max_slots()) + } + + /// Sets the CLOSED gate so stragglers stop claiming. + /// + /// Not part of the close boundary (rule 1): run it after collection, + /// off the latency-sensitive path — on an otherwise-untouched region + /// this write materializes the counter page, which can cost + /// milliseconds of first-block allocation on journalling filesystems. + pub(super) fn close_claims(self) { + self.header_word(layout::SLOT_COUNTER_OFFSET).fetch_or(CLOSED, Ordering::Relaxed); } /// Publishes a committed descriptor into an unfinished slot. @@ -200,7 +266,7 @@ impl<'m> SharedState<'m> { pub(super) fn payload_ptr(self, offset: usize) -> *mut u8 { debug_assert!(offset <= self.len); // SAFETY: callers pass offsets of admitted reservations, which - // `layout::fits` keeps inside the mapping. + // `layout` keeps inside the mapping. unsafe { self.base.add(offset) } } @@ -214,7 +280,8 @@ impl<'m> SharedState<'m> { let offset = span.offset + word_index * layout::SLOT_LEN; // SAFETY: `PayloadSpan::validate` checked that the word-aligned // reservation `[span.offset, span.offset + span.reserved_len())` - // lies inside the mapping, and `span.offset` is word-aligned. + // lies inside the payload region, and `span.offset` is + // word-aligned. let word = unsafe { AtomicU64::from_ptr(self.base.add(offset).cast()) }; out.extend_from_slice(&word.load(Ordering::Relaxed).to_ne_bytes()); } diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs index a745882ad..860a3cacf 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs @@ -8,7 +8,10 @@ use std::{ use wincode::{SchemaWrite, Serialize as _, config::DefaultConfig}; -use super::{AsRawSlice, alloc_word::ReserveError, slot, state::SharedState}; +use super::{ + AsRawSlice, slot, + state::{ReserveError, SharedState}, +}; /// A concurrent shared-memory frame writer. /// @@ -76,7 +79,7 @@ impl ShmWriter { /// Whether the receiver has closed the channel. pub fn is_closed(&self) -> bool { - self.state().load_word().is_closed() + self.state().is_closed() } /// Claims a frame of exactly `frame_size` bytes. From b53a3fbd73f709d306490ea3c99ed890b89dcc73 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Fri, 14 Aug 2026 19:38:54 +0800 Subject: [PATCH 07/92] perf(fspy): restore the header pre-fault on Linux only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wait-free rework deleted the pre-fault thread on the theory that a write-free close no longer needs the page. The benchmark disagreed: on the Linux runner the first touch of the sparse backing file costs milliseconds whether it is a write (a sender's first claim) or a read (close's snapshot), so Linux launches regressed right back. Windows meanwhile improved once the thread was gone — its first touch is cheap and the spawn was the cost. Restore the concurrent header-page warm-up, gated to Linux. Co-Authored-By: Claude Fable 5 --- crates/fspy_shared/src/ipc/channel/mod.rs | 16 ++++++++ .../fspy_shared/src/ipc/channel/shm_io/mod.rs | 39 +++++++++++++++++++ .../src/ipc/channel/shm_io/state.rs | 24 ++++++++++++ 3 files changed, 79 insertions(+) diff --git a/crates/fspy_shared/src/ipc/channel/mod.rs b/crates/fspy_shared/src/ipc/channel/mod.rs index 5619d523c..0463ee6c8 100644 --- a/crates/fspy_shared/src/ipc/channel/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/mod.rs @@ -44,6 +44,22 @@ pub fn channel(capacity: usize) -> io::Result<(ChannelConf, Receiver)> { let keeper = ShmKeeper { path: shm_c_path }; let mapping = handle.map().map_err(shm_error_to_io)?; + // On Linux, the first touch of the sparse backing file — read or write — + // can cost milliseconds of journalled first-block allocation, and it + // would otherwise be paid by a sender's first record or by close's + // snapshot. Touch the header page through a second view concurrently + // with process startup instead. Only there: on Windows and macOS the + // first touch is cheap and the extra thread costs more than it saves. + // Best-effort — a channel without the pre-fault is merely slower. + #[cfg(target_os = "linux")] + if let Ok(prefault_mapping) = handle.map() { + let _ = std::thread::Builder::new().name("fspy-shm-prefault".into()).spawn(move || { + // SAFETY: the mapping views the region created zero-initialized + // above, which is only accessed through the `shm_io` protocol. + unsafe { shm_io::pre_fault(&prefault_mapping) }; + }); + } + let conf = ChannelConf { shm_id: IpcStr::from_os_c_str(keeper.path.as_c_str()).to_boxed() }; Ok((conf, Receiver { _keeper: keeper, mapping })) diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs index cdb152b76..90f5365bb 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs @@ -105,6 +105,21 @@ pub unsafe fn close(mem: &impl AsRawSlice) -> Result { unsafe { reader::close(mem.as_raw_slice()) } } +/// Materializes the page backing the protocol header without changing +/// protocol state, so that neither a writer's first claim nor [`close`]'s +/// snapshot pays for the backing file's first block allocation — a +/// millisecond-scale cost on some journalling filesystems, for reads of +/// holes as well as writes. Run it off any latency-sensitive path. +/// +/// # Safety +/// +/// Same contract as [`ShmWriter::new`]. +#[cfg(target_os = "linux")] +pub unsafe fn pre_fault(mem: &impl AsRawSlice) { + // SAFETY: forwarded from this function's contract. + unsafe { state::SharedState::borrow(mem.as_raw_slice()) }.pre_fault(); +} + /// Sets the CLOSED gate so writers stop claiming once they observe it. /// /// Deliberately separate from [`close`]: the boundary is [`close`]'s @@ -362,6 +377,30 @@ mod tests { assert!(!frames.is_complete()); } + #[cfg(target_os = "linux")] + #[test] + fn pre_fault_does_not_disturb_protocol_state() { + let shm = MockedShm::alloc(1024); + // SAFETY: see `single_thread_basic`. + let writer = unsafe { ShmWriter::new(shm.clone()) }; + + // On the untouched region, before any claim. + // SAFETY: see `collect_frames`. + unsafe { pre_fault(&shm) }; + assert!(writer.try_write_frame(b"foo")); + // Racing an already claimed region must change nothing either. + // SAFETY: see `collect_frames`. + unsafe { pre_fault(&shm) }; + assert!(writer.try_write_frame(b"bar")); + + let frames = collect_frames(&shm); + let mut iter = frames.iter(); + assert!(iter.next().unwrap() == b"foo"); + assert!(iter.next().unwrap() == b"bar"); + assert!(iter.next() == None); + assert!(frames.is_complete()); + } + #[test] fn slot_capacity_failure_marks_incomplete() { // A 1024-byte region has a 15-slot table; the 16th claim must fail diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/state.rs b/crates/fspy_shared/src/ipc/channel/shm_io/state.rs index 610186154..8a64672d3 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/state.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/state.rs @@ -224,6 +224,30 @@ impl<'m> SharedState<'m> { usize::try_from(claims & !CLOSED).unwrap_or(usize::MAX).min(self.max_slots()) } + /// Forces the page holding the header counters to be materialized by + /// the operating system before anyone touches it on a latency-sensitive + /// path. + /// + /// A compare-exchange of zero with zero on the claim counter: on an + /// untouched region it performs a real write — allocating the first + /// block of a sparse backing file, which can cost milliseconds on + /// journalling filesystems — without changing protocol state. If a + /// claim got there first, the page is already backed and the failed + /// exchange changes nothing. (An `or` of zero would not do: the + /// compiler may lower it to a plain load, which materializes only a + /// hole page without allocating the block.) + /// + /// Only Linux channels use this: elsewhere the first touch is cheap. + #[cfg(target_os = "linux")] + pub(super) fn pre_fault(self) { + let _ = self.header_word(layout::SLOT_COUNTER_OFFSET).compare_exchange( + 0, + 0, + Ordering::Relaxed, + Ordering::Relaxed, + ); + } + /// Sets the CLOSED gate so stragglers stop claiming. /// /// Not part of the close boundary (rule 1): run it after collection, From 528d1d198cbe987da3b4af4c10d22927726b79e8 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sat, 15 Aug 2026 07:46:04 +0800 Subject: [PATCH 08/92] perf(fspy): preallocate first-touch blocks instead of pre-faulting on a thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reserving one block at the header and one at the payload-region start with fallocate(KEEP_SIZE) is a cheap metadata-only operation at channel creation, so the milliseconds of journalled block allocation that some filesystems charge for the first touch of each area no longer need a background thread to hide them — and the payload area, which the thread could not safely touch, is now covered too. KEEP_SIZE because growing the file would desynchronize mapping sizes across processes. Co-Authored-By: Claude Fable 5 --- crates/fspy_nostd/src/fs/unix.rs | 2 + crates/fspy_shared/src/ipc/channel/mod.rs | 21 +++---- .../fspy_shared/src/ipc/channel/shm_io/mod.rs | 60 +++++++++---------- .../src/ipc/channel/shm_io/state.rs | 24 -------- crates/fspy_shm/src/unix.rs | 25 ++++++++ 5 files changed, 64 insertions(+), 68 deletions(-) diff --git a/crates/fspy_nostd/src/fs/unix.rs b/crates/fspy_nostd/src/fs/unix.rs index 05279a393..4d071848d 100644 --- a/crates/fspy_nostd/src/fs/unix.rs +++ b/crates/fspy_nostd/src/fs/unix.rs @@ -1,6 +1,8 @@ use core::mem::MaybeUninit; pub use rustix::fs::{AtFlags, Mode, OFlags, fstat, ftruncate}; +#[cfg(target_os = "linux")] +pub use rustix::fs::{FallocateFlags, fallocate}; #[cfg(target_os = "linux")] use super::linux as imp; diff --git a/crates/fspy_shared/src/ipc/channel/mod.rs b/crates/fspy_shared/src/ipc/channel/mod.rs index 0463ee6c8..3e763d9c6 100644 --- a/crates/fspy_shared/src/ipc/channel/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/mod.rs @@ -44,20 +44,15 @@ pub fn channel(capacity: usize) -> io::Result<(ChannelConf, Receiver)> { let keeper = ShmKeeper { path: shm_c_path }; let mapping = handle.map().map_err(shm_error_to_io)?; - // On Linux, the first touch of the sparse backing file — read or write — - // can cost milliseconds of journalled first-block allocation, and it - // would otherwise be paid by a sender's first record or by close's - // snapshot. Touch the header page through a second view concurrently - // with process startup instead. Only there: on Windows and macOS the - // first touch is cheap and the extra thread costs more than it saves. - // Best-effort — a channel without the pre-fault is merely slower. + // On Linux, the first touch of each area of the sparse backing file can + // cost milliseconds of journalled block allocation, paid inside a + // sender's first record or the receiver's collection. Reserving the + // blocks here is a cheap metadata-only operation that removes that cost. + // Best-effort: filesystems without preallocation merely stay slower. + // Only Linux needs it — elsewhere the first touch is cheap. #[cfg(target_os = "linux")] - if let Ok(prefault_mapping) = handle.map() { - let _ = std::thread::Builder::new().name("fspy-shm-prefault".into()).spawn(move || { - // SAFETY: the mapping views the region created zero-initialized - // above, which is only accessed through the `shm_io` protocol. - unsafe { shm_io::pre_fault(&prefault_mapping) }; - }); + for (offset, len) in shm_io::preallocation_spans(capacity) { + let _ = handle.preallocate(offset, len); } let conf = ChannelConf { shm_id: IpcStr::from_os_c_str(keeper.path.as_c_str()).to_boxed() }; diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs index 90f5365bb..c84fee5e7 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs @@ -105,19 +105,22 @@ pub unsafe fn close(mem: &impl AsRawSlice) -> Result { unsafe { reader::close(mem.as_raw_slice()) } } -/// Materializes the page backing the protocol header without changing -/// protocol state, so that neither a writer's first claim nor [`close`]'s -/// snapshot pays for the backing file's first block allocation — a -/// millisecond-scale cost on some journalling filesystems, for reads of -/// holes as well as writes. Run it off any latency-sensitive path. -/// -/// # Safety -/// -/// Same contract as [`ShmWriter::new`]. +/// The byte ranges of a `mapping_len`-byte region whose backing blocks the +/// creator should preallocate: the header page (counters plus the first +/// slots) and the start of the payload region. Every trace writes both +/// areas first; on filesystems where materializing a new area of the sparse +/// backing file costs milliseconds, reserving one block per area up front +/// keeps that cost out of a writer's first record and out of [`close`] — +/// growing an already-materialized area is cheap. #[cfg(target_os = "linux")] -pub unsafe fn pre_fault(mem: &impl AsRawSlice) { - // SAFETY: forwarded from this function's contract. - unsafe { state::SharedState::borrow(mem.as_raw_slice()) }.pre_fault(); +pub fn preallocation_spans(mapping_len: usize) -> impl Iterator { + // One filesystem block suffices to start an area; clamp inside the + // mapping so tiny (test) regions never reserve past their end. + const SPAN_LEN: usize = 4096; + [0, layout::payload_base(mapping_len)] + .into_iter() + .filter(move |&offset| offset < mapping_len) + .map(move |offset| (offset as u64, SPAN_LEN.min(mapping_len - offset) as u64)) } /// Sets the CLOSED gate so writers stop claiming once they observe it. @@ -379,26 +382,21 @@ mod tests { #[cfg(target_os = "linux")] #[test] - fn pre_fault_does_not_disturb_protocol_state() { - let shm = MockedShm::alloc(1024); - // SAFETY: see `single_thread_basic`. - let writer = unsafe { ShmWriter::new(shm.clone()) }; - - // On the untouched region, before any claim. - // SAFETY: see `collect_frames`. - unsafe { pre_fault(&shm) }; - assert!(writer.try_write_frame(b"foo")); - // Racing an already claimed region must change nothing either. - // SAFETY: see `collect_frames`. - unsafe { pre_fault(&shm) }; - assert!(writer.try_write_frame(b"bar")); + fn preallocation_spans_cover_both_first_touch_areas() { + // The production mapping: one block at the header, one at the start + // of the payload region, both inside the mapping. + let spans: Vec<_> = preallocation_spans(layout::MAX_MAPPING_LEN).collect(); + assert!( + spans == vec![(0, 4096), (layout::payload_base(layout::MAX_MAPPING_LEN) as u64, 4096)] + ); - let frames = collect_frames(&shm); - let mut iter = frames.iter(); - assert!(iter.next().unwrap() == b"foo"); - assert!(iter.next().unwrap() == b"bar"); - assert!(iter.next() == None); - assert!(frames.is_complete()); + // A tiny region: spans clamp to the mapping instead of reserving + // past its end (which would desynchronize mapping sizes across + // processes if the reservation grew the file). + for (offset, len) in preallocation_spans(1024) { + assert!(offset + len <= 1024); + assert!(len > 0); + } } #[test] diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/state.rs b/crates/fspy_shared/src/ipc/channel/shm_io/state.rs index 8a64672d3..610186154 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/state.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/state.rs @@ -224,30 +224,6 @@ impl<'m> SharedState<'m> { usize::try_from(claims & !CLOSED).unwrap_or(usize::MAX).min(self.max_slots()) } - /// Forces the page holding the header counters to be materialized by - /// the operating system before anyone touches it on a latency-sensitive - /// path. - /// - /// A compare-exchange of zero with zero on the claim counter: on an - /// untouched region it performs a real write — allocating the first - /// block of a sparse backing file, which can cost milliseconds on - /// journalling filesystems — without changing protocol state. If a - /// claim got there first, the page is already backed and the failed - /// exchange changes nothing. (An `or` of zero would not do: the - /// compiler may lower it to a plain load, which materializes only a - /// hole page without allocating the block.) - /// - /// Only Linux channels use this: elsewhere the first touch is cheap. - #[cfg(target_os = "linux")] - pub(super) fn pre_fault(self) { - let _ = self.header_word(layout::SLOT_COUNTER_OFFSET).compare_exchange( - 0, - 0, - Ordering::Relaxed, - Ordering::Relaxed, - ); - } - /// Sets the CLOSED gate so stragglers stop claiming. /// /// Not part of the close boundary (rule 1): run it after collection, diff --git a/crates/fspy_shm/src/unix.rs b/crates/fspy_shm/src/unix.rs index f2a1075ed..f6a8d67f2 100644 --- a/crates/fspy_shm/src/unix.rs +++ b/crates/fspy_shm/src/unix.rs @@ -111,6 +111,31 @@ pub fn remove(path: OsCStr<'_, Thin>) -> Result<()> { } impl ShmHandle { + /// Reserves the disk blocks backing `[offset, offset + len)` without + /// writing data or changing the file size. + /// + /// On journalling filesystems, materializing a new area of a sparse file + /// on first touch can cost milliseconds; reserving the blocks up front + /// moves that work to creation time, where it is a cheap metadata-only + /// operation. + /// + /// # Errors + /// + /// Returns the error reported by the filesystem — some (e.g. network + /// filesystems) do not support preallocation. + #[cfg(target_os = "linux")] + pub fn preallocate(&self, offset: u64, len: u64) -> Result<()> { + // KEEP_SIZE: allocation must never grow the file — other processes + // size their mappings from the file size, and the protocol requires + // every process to see the same region layout. + fspy_nostd::fs::fallocate( + &self.file, + fspy_nostd::fs::FallocateFlags::KEEP_SIZE, + offset, + len, + ) + } + /// Maps the shared bytes. /// /// # Errors From 3e5bea8d775b794414ee970ec4d21c72ee59acc3 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sat, 15 Aug 2026 07:49:39 +0800 Subject: [PATCH 09/92] Revert "perf(fspy): preallocate first-touch blocks instead of pre-faulting on a thread" This reverts commit 528d1d198cbe987da3b4af4c10d22927726b79e8. --- crates/fspy_nostd/src/fs/unix.rs | 2 - crates/fspy_shared/src/ipc/channel/mod.rs | 21 ++++--- .../fspy_shared/src/ipc/channel/shm_io/mod.rs | 60 ++++++++++--------- .../src/ipc/channel/shm_io/state.rs | 24 ++++++++ crates/fspy_shm/src/unix.rs | 25 -------- 5 files changed, 68 insertions(+), 64 deletions(-) diff --git a/crates/fspy_nostd/src/fs/unix.rs b/crates/fspy_nostd/src/fs/unix.rs index 4d071848d..05279a393 100644 --- a/crates/fspy_nostd/src/fs/unix.rs +++ b/crates/fspy_nostd/src/fs/unix.rs @@ -1,8 +1,6 @@ use core::mem::MaybeUninit; pub use rustix::fs::{AtFlags, Mode, OFlags, fstat, ftruncate}; -#[cfg(target_os = "linux")] -pub use rustix::fs::{FallocateFlags, fallocate}; #[cfg(target_os = "linux")] use super::linux as imp; diff --git a/crates/fspy_shared/src/ipc/channel/mod.rs b/crates/fspy_shared/src/ipc/channel/mod.rs index 3e763d9c6..0463ee6c8 100644 --- a/crates/fspy_shared/src/ipc/channel/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/mod.rs @@ -44,15 +44,20 @@ pub fn channel(capacity: usize) -> io::Result<(ChannelConf, Receiver)> { let keeper = ShmKeeper { path: shm_c_path }; let mapping = handle.map().map_err(shm_error_to_io)?; - // On Linux, the first touch of each area of the sparse backing file can - // cost milliseconds of journalled block allocation, paid inside a - // sender's first record or the receiver's collection. Reserving the - // blocks here is a cheap metadata-only operation that removes that cost. - // Best-effort: filesystems without preallocation merely stay slower. - // Only Linux needs it — elsewhere the first touch is cheap. + // On Linux, the first touch of the sparse backing file — read or write — + // can cost milliseconds of journalled first-block allocation, and it + // would otherwise be paid by a sender's first record or by close's + // snapshot. Touch the header page through a second view concurrently + // with process startup instead. Only there: on Windows and macOS the + // first touch is cheap and the extra thread costs more than it saves. + // Best-effort — a channel without the pre-fault is merely slower. #[cfg(target_os = "linux")] - for (offset, len) in shm_io::preallocation_spans(capacity) { - let _ = handle.preallocate(offset, len); + if let Ok(prefault_mapping) = handle.map() { + let _ = std::thread::Builder::new().name("fspy-shm-prefault".into()).spawn(move || { + // SAFETY: the mapping views the region created zero-initialized + // above, which is only accessed through the `shm_io` protocol. + unsafe { shm_io::pre_fault(&prefault_mapping) }; + }); } let conf = ChannelConf { shm_id: IpcStr::from_os_c_str(keeper.path.as_c_str()).to_boxed() }; diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs index c84fee5e7..90f5365bb 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs @@ -105,22 +105,19 @@ pub unsafe fn close(mem: &impl AsRawSlice) -> Result { unsafe { reader::close(mem.as_raw_slice()) } } -/// The byte ranges of a `mapping_len`-byte region whose backing blocks the -/// creator should preallocate: the header page (counters plus the first -/// slots) and the start of the payload region. Every trace writes both -/// areas first; on filesystems where materializing a new area of the sparse -/// backing file costs milliseconds, reserving one block per area up front -/// keeps that cost out of a writer's first record and out of [`close`] — -/// growing an already-materialized area is cheap. +/// Materializes the page backing the protocol header without changing +/// protocol state, so that neither a writer's first claim nor [`close`]'s +/// snapshot pays for the backing file's first block allocation — a +/// millisecond-scale cost on some journalling filesystems, for reads of +/// holes as well as writes. Run it off any latency-sensitive path. +/// +/// # Safety +/// +/// Same contract as [`ShmWriter::new`]. #[cfg(target_os = "linux")] -pub fn preallocation_spans(mapping_len: usize) -> impl Iterator { - // One filesystem block suffices to start an area; clamp inside the - // mapping so tiny (test) regions never reserve past their end. - const SPAN_LEN: usize = 4096; - [0, layout::payload_base(mapping_len)] - .into_iter() - .filter(move |&offset| offset < mapping_len) - .map(move |offset| (offset as u64, SPAN_LEN.min(mapping_len - offset) as u64)) +pub unsafe fn pre_fault(mem: &impl AsRawSlice) { + // SAFETY: forwarded from this function's contract. + unsafe { state::SharedState::borrow(mem.as_raw_slice()) }.pre_fault(); } /// Sets the CLOSED gate so writers stop claiming once they observe it. @@ -382,21 +379,26 @@ mod tests { #[cfg(target_os = "linux")] #[test] - fn preallocation_spans_cover_both_first_touch_areas() { - // The production mapping: one block at the header, one at the start - // of the payload region, both inside the mapping. - let spans: Vec<_> = preallocation_spans(layout::MAX_MAPPING_LEN).collect(); - assert!( - spans == vec![(0, 4096), (layout::payload_base(layout::MAX_MAPPING_LEN) as u64, 4096)] - ); + fn pre_fault_does_not_disturb_protocol_state() { + let shm = MockedShm::alloc(1024); + // SAFETY: see `single_thread_basic`. + let writer = unsafe { ShmWriter::new(shm.clone()) }; - // A tiny region: spans clamp to the mapping instead of reserving - // past its end (which would desynchronize mapping sizes across - // processes if the reservation grew the file). - for (offset, len) in preallocation_spans(1024) { - assert!(offset + len <= 1024); - assert!(len > 0); - } + // On the untouched region, before any claim. + // SAFETY: see `collect_frames`. + unsafe { pre_fault(&shm) }; + assert!(writer.try_write_frame(b"foo")); + // Racing an already claimed region must change nothing either. + // SAFETY: see `collect_frames`. + unsafe { pre_fault(&shm) }; + assert!(writer.try_write_frame(b"bar")); + + let frames = collect_frames(&shm); + let mut iter = frames.iter(); + assert!(iter.next().unwrap() == b"foo"); + assert!(iter.next().unwrap() == b"bar"); + assert!(iter.next() == None); + assert!(frames.is_complete()); } #[test] diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/state.rs b/crates/fspy_shared/src/ipc/channel/shm_io/state.rs index 610186154..8a64672d3 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/state.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/state.rs @@ -224,6 +224,30 @@ impl<'m> SharedState<'m> { usize::try_from(claims & !CLOSED).unwrap_or(usize::MAX).min(self.max_slots()) } + /// Forces the page holding the header counters to be materialized by + /// the operating system before anyone touches it on a latency-sensitive + /// path. + /// + /// A compare-exchange of zero with zero on the claim counter: on an + /// untouched region it performs a real write — allocating the first + /// block of a sparse backing file, which can cost milliseconds on + /// journalling filesystems — without changing protocol state. If a + /// claim got there first, the page is already backed and the failed + /// exchange changes nothing. (An `or` of zero would not do: the + /// compiler may lower it to a plain load, which materializes only a + /// hole page without allocating the block.) + /// + /// Only Linux channels use this: elsewhere the first touch is cheap. + #[cfg(target_os = "linux")] + pub(super) fn pre_fault(self) { + let _ = self.header_word(layout::SLOT_COUNTER_OFFSET).compare_exchange( + 0, + 0, + Ordering::Relaxed, + Ordering::Relaxed, + ); + } + /// Sets the CLOSED gate so stragglers stop claiming. /// /// Not part of the close boundary (rule 1): run it after collection, diff --git a/crates/fspy_shm/src/unix.rs b/crates/fspy_shm/src/unix.rs index f6a8d67f2..f2a1075ed 100644 --- a/crates/fspy_shm/src/unix.rs +++ b/crates/fspy_shm/src/unix.rs @@ -111,31 +111,6 @@ pub fn remove(path: OsCStr<'_, Thin>) -> Result<()> { } impl ShmHandle { - /// Reserves the disk blocks backing `[offset, offset + len)` without - /// writing data or changing the file size. - /// - /// On journalling filesystems, materializing a new area of a sparse file - /// on first touch can cost milliseconds; reserving the blocks up front - /// moves that work to creation time, where it is a cheap metadata-only - /// operation. - /// - /// # Errors - /// - /// Returns the error reported by the filesystem — some (e.g. network - /// filesystems) do not support preallocation. - #[cfg(target_os = "linux")] - pub fn preallocate(&self, offset: u64, len: u64) -> Result<()> { - // KEEP_SIZE: allocation must never grow the file — other processes - // size their mappings from the file size, and the protocol requires - // every process to see the same region layout. - fspy_nostd::fs::fallocate( - &self.file, - fspy_nostd::fs::FallocateFlags::KEEP_SIZE, - offset, - len, - ) - } - /// Maps the shared bytes. /// /// # Errors From c4ba8dd9779da6738a81e6a1ecd9456ca9b91be5 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sat, 15 Aug 2026 07:50:58 +0800 Subject: [PATCH 10/92] perf(fspy): warm the first payload page alongside the header The fallocate experiment showed the first-touch cost is in the fault path, not block allocation, so only a real touch helps. Give the pre-fault thread a second target: a protocol-owned warm word between the table and the payload data, so the page where the first payloads land is materialized without racing any writer's payload bytes. Co-Authored-By: Claude Fable 5 --- .../src/ipc/channel/shm_io/layout.rs | 24 +++++++++++++++---- .../fspy_shared/src/ipc/channel/shm_io/mod.rs | 12 ++++++---- .../src/ipc/channel/shm_io/state.rs | 11 +++++++++ 3 files changed, 38 insertions(+), 9 deletions(-) diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs index 6717b09e7..0714309b2 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs @@ -78,15 +78,30 @@ pub(super) const fn max_slots(mapping_len: usize) -> usize { table_len(mapping_len) / SLOT_LEN } +/// Byte offset of the payload warm word: one protocol-owned word between the +/// table and the payload data, written only by the creator's pre-fault (and +/// only ever with zero). Touching it materializes the page where the first +/// payloads land without racing any writer's payload bytes. Word-aligned. +pub(super) const fn payload_warm_offset(mapping_len: usize) -> usize { + HEADER_LEN + table_len(mapping_len) +} + /// Byte offset where the payload region starts. Word-aligned. +/// +/// May exceed a tiny mapping; [`payload_region_len`] is zero then and no +/// payload is ever placed. pub(super) const fn payload_base(mapping_len: usize) -> usize { - HEADER_LEN + table_len(mapping_len) + payload_warm_offset(mapping_len) + SLOT_LEN } /// Byte size of the payload region. A multiple of the word size, so a /// word-aligned reservation inside it never reaches past `mapping_len`. pub(super) const fn payload_region_len(mapping_len: usize) -> usize { - let len = mapping_len - payload_base(mapping_len); + let base = payload_base(mapping_len); + if base >= mapping_len { + return 0; + } + let len = mapping_len - base; len - len % SLOT_LEN } @@ -160,8 +175,9 @@ mod tests { assert!(table % SLOT_LEN == 0); assert!(base % SLOT_LEN == 0); assert!(region % SLOT_LEN == 0); - assert!(base == HEADER_LEN + table); - assert!(base + region <= mapping_len); + assert!(payload_warm_offset(mapping_len) == HEADER_LEN + table); + assert!(base == HEADER_LEN + table + SLOT_LEN); + assert!(region == 0 || base + region <= mapping_len); } } diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs index 90f5365bb..e15e1d061 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs @@ -105,11 +105,13 @@ pub unsafe fn close(mem: &impl AsRawSlice) -> Result { unsafe { reader::close(mem.as_raw_slice()) } } -/// Materializes the page backing the protocol header without changing -/// protocol state, so that neither a writer's first claim nor [`close`]'s -/// snapshot pays for the backing file's first block allocation — a -/// millisecond-scale cost on some journalling filesystems, for reads of -/// holes as well as writes. Run it off any latency-sensitive path. +/// Materializes the two pages every trace touches first — the header page +/// and the start of the payload region — without changing protocol state, +/// so that neither a writer's first record nor [`close`]'s snapshot pays +/// the first-touch cost of each area: a millisecond-scale fault on some +/// filesystems, for reads of holes as well as writes (and, empirically, not +/// avoidable by preallocating blocks — only a real touch helps). Run it off +/// any latency-sensitive path. /// /// # Safety /// diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/state.rs b/crates/fspy_shared/src/ipc/channel/shm_io/state.rs index 8a64672d3..148c85cd8 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/state.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/state.rs @@ -246,6 +246,17 @@ impl<'m> SharedState<'m> { Ordering::Relaxed, Ordering::Relaxed, ); + + // Also materialize the page where the first payloads land, through + // the protocol-owned warm word so no writer's payload bytes are ever + // raced. Skipped when a tiny region has no room for it. + let warm_offset = layout::payload_warm_offset(self.len); + if warm_offset + layout::SLOT_LEN <= self.len { + // SAFETY: in bounds (just checked) and word-aligned (the table + // length is word-rounded after the aligned header). + let warm_word = unsafe { AtomicU64::from_ptr(self.base.add(warm_offset).cast()) }; + let _ = warm_word.compare_exchange(0, 0, Ordering::Relaxed, Ordering::Relaxed); + } } /// Sets the CLOSED gate so stragglers stop claiming. From 916e2929ef278d704e2d928ba4938b000d9eff11 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sat, 15 Aug 2026 07:54:47 +0800 Subject: [PATCH 11/92] Revert "perf(fspy): warm the first payload page alongside the header" This reverts commit c4ba8dd9779da6738a81e6a1ecd9456ca9b91be5. --- .../src/ipc/channel/shm_io/layout.rs | 24 ++++--------------- .../fspy_shared/src/ipc/channel/shm_io/mod.rs | 12 ++++------ .../src/ipc/channel/shm_io/state.rs | 11 --------- 3 files changed, 9 insertions(+), 38 deletions(-) diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs index 0714309b2..6717b09e7 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs @@ -78,30 +78,15 @@ pub(super) const fn max_slots(mapping_len: usize) -> usize { table_len(mapping_len) / SLOT_LEN } -/// Byte offset of the payload warm word: one protocol-owned word between the -/// table and the payload data, written only by the creator's pre-fault (and -/// only ever with zero). Touching it materializes the page where the first -/// payloads land without racing any writer's payload bytes. Word-aligned. -pub(super) const fn payload_warm_offset(mapping_len: usize) -> usize { - HEADER_LEN + table_len(mapping_len) -} - /// Byte offset where the payload region starts. Word-aligned. -/// -/// May exceed a tiny mapping; [`payload_region_len`] is zero then and no -/// payload is ever placed. pub(super) const fn payload_base(mapping_len: usize) -> usize { - payload_warm_offset(mapping_len) + SLOT_LEN + HEADER_LEN + table_len(mapping_len) } /// Byte size of the payload region. A multiple of the word size, so a /// word-aligned reservation inside it never reaches past `mapping_len`. pub(super) const fn payload_region_len(mapping_len: usize) -> usize { - let base = payload_base(mapping_len); - if base >= mapping_len { - return 0; - } - let len = mapping_len - base; + let len = mapping_len - payload_base(mapping_len); len - len % SLOT_LEN } @@ -175,9 +160,8 @@ mod tests { assert!(table % SLOT_LEN == 0); assert!(base % SLOT_LEN == 0); assert!(region % SLOT_LEN == 0); - assert!(payload_warm_offset(mapping_len) == HEADER_LEN + table); - assert!(base == HEADER_LEN + table + SLOT_LEN); - assert!(region == 0 || base + region <= mapping_len); + assert!(base == HEADER_LEN + table); + assert!(base + region <= mapping_len); } } diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs index e15e1d061..90f5365bb 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs @@ -105,13 +105,11 @@ pub unsafe fn close(mem: &impl AsRawSlice) -> Result { unsafe { reader::close(mem.as_raw_slice()) } } -/// Materializes the two pages every trace touches first — the header page -/// and the start of the payload region — without changing protocol state, -/// so that neither a writer's first record nor [`close`]'s snapshot pays -/// the first-touch cost of each area: a millisecond-scale fault on some -/// filesystems, for reads of holes as well as writes (and, empirically, not -/// avoidable by preallocating blocks — only a real touch helps). Run it off -/// any latency-sensitive path. +/// Materializes the page backing the protocol header without changing +/// protocol state, so that neither a writer's first claim nor [`close`]'s +/// snapshot pays for the backing file's first block allocation — a +/// millisecond-scale cost on some journalling filesystems, for reads of +/// holes as well as writes. Run it off any latency-sensitive path. /// /// # Safety /// diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/state.rs b/crates/fspy_shared/src/ipc/channel/shm_io/state.rs index 148c85cd8..8a64672d3 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/state.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/state.rs @@ -246,17 +246,6 @@ impl<'m> SharedState<'m> { Ordering::Relaxed, Ordering::Relaxed, ); - - // Also materialize the page where the first payloads land, through - // the protocol-owned warm word so no writer's payload bytes are ever - // raced. Skipped when a tiny region has no room for it. - let warm_offset = layout::payload_warm_offset(self.len); - if warm_offset + layout::SLOT_LEN <= self.len { - // SAFETY: in bounds (just checked) and word-aligned (the table - // length is word-rounded after the aligned header). - let warm_word = unsafe { AtomicU64::from_ptr(self.base.add(warm_offset).cast()) }; - let _ = warm_word.compare_exchange(0, 0, Ordering::Relaxed, Ordering::Relaxed); - } } /// Sets the CLOSED gate so stragglers stop claiming. From a5c8ec3fbc57b250f804a7eda8917e45a0261263 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sat, 15 Aug 2026 08:40:31 +0800 Subject: [PATCH 12/92] refactor(fspy): borrow committed frames from the mapping instead of copying MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frames now owns the mapping and lazily hands out per-span borrows of the validated committed payloads. A committed span is immutable under the protocol and disjoint from everything a live writer may still touch, so the borrows are sound without a copy; the trust argument lives in the reader module docs. This also collapses the close-time machinery: the CLOSED gate returns inline into close (its page is pre-warmed on Linux where first touches are expensive), the deferred-teardown thread is gone, and the mapping is released when Frames drops — naturally off the collection path. The receiver-side frame validation pass in the supervisor is dropped with it; committed frames are complete by protocol. Co-Authored-By: Claude Fable 5 --- crates/fspy/src/ipc.rs | 22 +-- crates/fspy_shared/src/ipc/channel/mod.rs | 75 +++------ .../src/ipc/channel/shm_io/layout.rs | 5 - .../fspy_shared/src/ipc/channel/shm_io/mod.rs | 80 +++------ .../src/ipc/channel/shm_io/reader.rs | 155 ++++++++++-------- .../src/ipc/channel/shm_io/state.rs | 35 +--- 6 files changed, 144 insertions(+), 228 deletions(-) diff --git a/crates/fspy/src/ipc.rs b/crates/fspy/src/ipc.rs index 2a649155c..169d8bb06 100644 --- a/crates/fspy/src/ipc.rs +++ b/crates/fspy/src/ipc.rs @@ -24,9 +24,9 @@ impl CollectedAccesses { /// [`fspy_shared::ipc::channel::Receiver::close`]). /// /// Fails when the trace cannot back the run's file accesses: a record - /// was lost before close, or the shared memory was corrupted. Failing - /// here — instead of returning a silently short trace — keeps the - /// tracking result trustworthy for caching. + /// was lost before close, or the shared-memory metadata was corrupted. + /// Failing here — instead of returning a silently short trace — keeps + /// the tracking result trustworthy for caching. pub fn collect(receiver: Receiver) -> io::Result { let frames = receiver.close()?; if !frames.is_complete() { @@ -35,15 +35,6 @@ impl CollectedAccesses { "file-access trace is incomplete: a tracked process lost a record", )); } - // Validate every frame once so iteration is infallible. - for frame in frames.iter() { - let _: PathAccess<'_> = wincode::deserialize_exact(frame).map_err(|err| { - io::Error::new( - io::ErrorKind::InvalidData, - format!("corrupt file-access record: {err}"), - ) - })?; - } Ok(Self { frames }) } @@ -52,8 +43,9 @@ impl CollectedAccesses { } pub fn iter_path_accesses(&self) -> impl Iterator> { - self.frames - .iter() - .map(|frame| wincode::deserialize_exact(frame).expect("frames validated in collect")) + self.frames.iter().map(|frame| { + wincode::deserialize_exact(frame) + .expect("committed frames are complete under the channel protocol") + }) } } diff --git a/crates/fspy_shared/src/ipc/channel/mod.rs b/crates/fspy_shared/src/ipc/channel/mod.rs index 0463ee6c8..8754515a8 100644 --- a/crates/fspy_shared/src/ipc/channel/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/mod.rs @@ -14,7 +14,11 @@ use fspy_nostd::Fat; use fspy_nostd_alloc::OsCString; use fspy_shm::Mapping; use shm_io::ShmWriter; -pub use shm_io::{ClaimError, FrameMut, Frames, WriteEncodedError}; +pub use shm_io::{ClaimError, FrameMut, WriteEncodedError}; + +/// The committed frames of a closed channel; borrows the shared mapping, +/// which stays alive (and mapped) until this value drops. +pub type Frames = shm_io::Frames; use uuid::Uuid; use wincode::{SchemaRead, SchemaWrite}; @@ -228,13 +232,15 @@ unsafe impl Send for Receiver {} unsafe impl Sync for Receiver {} impl Receiver { - /// Closes the channel and collects every committed frame. + /// Closes the channel and returns every committed frame, borrowed from + /// the shared mapping that moves into the returned [`Frames`]. /// /// Never blocks on senders: new claims are rejected from this point on, - /// unfinished frames are atomically aborted, and committed frames are - /// copied out and returned. A sender process that is still alive keeps + /// unfinished frames are atomically aborted, and committed frames become + /// readable in place. A sender process that is still alive keeps /// running; anything it reports after this point is outside the - /// channel's boundary by design. + /// channel's boundary by design. The mapping is released when the + /// returned [`Frames`] drops. /// /// # Errors /// @@ -248,34 +254,8 @@ impl Receiver { // SAFETY: `mapping` was created zero-initialized by `channel`, its // address is stable, and all attached processes access it only // through the `shm_io` protocol. - let frames = unsafe { shm_io::close(&mapping) } - .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err)); - // Two millisecond-scale teardown steps run off the caller's path: - // setting the claim gate (which write-faults the counter page — a - // first-block allocation on an otherwise-untouched region) and - // unmapping the multi-gigabyte view. Claims racing the gate land - // beyond the close snapshot and are never observed. Dropping the - // teardown performs both steps, so a failed thread spawn falls back - // to paying the latency here rather than leaving the gate unset. - let teardown = ChannelTeardown { mapping }; - let _ = - std::thread::Builder::new().name("fspy-shm-close".into()).spawn(move || drop(teardown)); - frames - } -} - -/// Deferred channel teardown: sets the claim gate, then unmaps. -struct ChannelTeardown { - mapping: Mapping, -} - -impl Drop for ChannelTeardown { - fn drop(&mut self) { - // SAFETY: `mapping` is the receiver's view of the region created - // zero-initialized by `channel` and accessed only through the - // `shm_io` protocol; it stays mapped until this struct's fields - // drop below. - unsafe { shm_io::close_claims(&self.mapping) }; + unsafe { shm_io::close(mapping) } + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err)) } } @@ -368,12 +348,11 @@ mod tests { assert!(B(&output.stdout) == B("false")); } - /// A sender that attached before close keeps its mapping; its claims - /// after close are either gated (once the deferred CLOSED gate lands) - /// or dropped without ever appearing in the collected frames. + /// A sender that attached before close keeps its mapping but cannot + /// claim any new frame afterwards. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn attached_sender_is_gated_after_close() { - let (conf, receiver) = channel(1024 * 1024).unwrap(); + async fn attached_sender_cannot_claim_after_close() { + let (conf, receiver) = channel(4096).unwrap(); let sender = conf.sender().unwrap(); let mut frame = sender.claim_frame(NonZeroUsize::new(2).unwrap()).unwrap(); @@ -384,23 +363,9 @@ mod tests { assert!(frames.iter().next().unwrap() == &[4, 2]); assert!(frames.is_complete()); - // The gate is set on the deferred teardown thread; a 1 MiB channel - // holds thousands of slots, far more claims than the gate needs - // scheduling opportunities to land, so exhausting capacity first - // would mean the gate never fired. - loop { - match sender.claim_frame(NonZeroUsize::new(2).unwrap()) { - Err(ClaimError::Closed) => break, - Err(ClaimError::Capacity) => panic!("claim gate never landed"), - Ok(frame) => { - // Dropped claims land beyond the close snapshot; the - // collected frames stay as they were. - frame.finish(); - assert!(frames.iter().count() == 1); - std::thread::yield_now(); - } - } - } + assert!( + sender.claim_frame(NonZeroUsize::new(2).unwrap()).unwrap_err() == ClaimError::Closed + ); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs index 6717b09e7..fdbd1d25e 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs @@ -129,11 +129,6 @@ impl PayloadSpan { } Some(Self { offset, len }) } - - /// The word-aligned length of the reservation containing this payload. - pub(super) const fn reserved_len(self) -> usize { - reserved_payload_len(self.len) - } } #[cfg(test)] diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs index 90f5365bb..dac3ae899 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs @@ -38,8 +38,10 @@ //! A payload becomes reachable only through its committed descriptor, and a //! descriptor is committed only after the payload is fully written //! ([`state`]'s ordering contract). The receiver never derives frame -//! locations from payload bytes and never references memory a live writer -//! may still mutate — committed payloads are copied out with atomic loads. +//! locations from payload bytes, and the borrows [`Frames`] hands out cover +//! exactly the validated committed spans — immutable under the protocol, +//! and disjoint from everything a live writer may still touch (see +//! [`reader`]'s trust argument). //! //! # Close boundary //! @@ -47,17 +49,14 @@ //! admitted before the snapshot races the freeze pass per slot and its //! frame is either included (commit won) or ignored (abort won) — never //! torn; a claim after the snapshot lands in a slot the receiver never -//! visits and is dropped. Both drops are sound because writers publish a -//! record *before* performing the recorded operation: a process that died -//! mid-frame never performed the operation, and one that claimed or -//! committed after the snapshot performs it outside the run's tracking -//! boundary. A live writer that loses a record *before* close (capacity, -//! abandonment) flags the trace incomplete ([`Frames::is_complete`]). -//! -//! [`close_claims`] sets the CLOSED gate afterwards, off the -//! latency-sensitive path, so straggler processes eventually stop claiming -//! (and stop materializing pages of the region) rather than writing into -//! the void forever. +//! visits and is dropped, and the CLOSED gate set before [`close`] returns +//! stops stragglers from claiming (and materializing pages) forever. Both +//! drops are sound because writers publish a record *before* performing the +//! recorded operation: a process that died mid-frame never performed the +//! operation, and one that claimed or committed after the snapshot performs +//! it outside the run's tracking boundary. A live writer that loses a +//! record *before* close (capacity, abandonment) flags the trace incomplete +//! ([`Frames::is_complete`]). //! //! Correctness never depends on writer-side cleanup: no exit hooks, PID //! checks, heartbeats, or timeouts. @@ -93,16 +92,17 @@ impl AsRawSlice for Mapping { } } -/// Closes the channel and collects the committed frames without waiting for -/// writers. See [`reader::close`]. +/// Closes the channel without waiting for writers and returns the committed +/// frames as borrows of the region, which moves into the returned +/// [`Frames`]. See [`reader::close`]. /// /// # Safety /// /// Same contract as [`ShmWriter::new`]: the region must be stable and valid, /// zero-initialized at creation, and accessed only through this protocol. -pub unsafe fn close(mem: &impl AsRawSlice) -> Result { +pub unsafe fn close(mem: M) -> Result, ProtocolError> { // SAFETY: forwarded from this function's contract. - unsafe { reader::close(mem.as_raw_slice()) } + unsafe { reader::close(mem) } } /// Materializes the page backing the protocol header without changing @@ -120,23 +120,6 @@ pub unsafe fn pre_fault(mem: &impl AsRawSlice) { unsafe { state::SharedState::borrow(mem.as_raw_slice()) }.pre_fault(); } -/// Sets the CLOSED gate so writers stop claiming once they observe it. -/// -/// Deliberately separate from [`close`]: the boundary is [`close`]'s -/// snapshot, and this write — which materializes the counter page, a -/// millisecond-scale first-block allocation on some journalling -/// filesystems when the trace is empty — belongs off the latency-sensitive -/// path. Claims landing between the snapshot and the gate are dropped -/// soundly (see the module docs). -/// -/// # Safety -/// -/// Same contract as [`ShmWriter::new`]. -pub unsafe fn close_claims(mem: &impl AsRawSlice) { - // SAFETY: forwarded from this function's contract. - unsafe { reader::close_claims(mem.as_raw_slice()) } -} - #[cfg(test)] mod tests { use std::{ @@ -208,10 +191,10 @@ mod tests { } } - fn collect_frames(shm: &MockedShm) -> Frames { + fn collect_frames(shm: &MockedShm) -> Frames { // SAFETY: `MockedShm` provides a stable, zero-initialized allocation // accessed only through the protocol. - unsafe { close(shm) }.unwrap() + unsafe { close(shm.clone()) }.unwrap() } #[test] @@ -419,7 +402,7 @@ mod tests { } #[test] - fn claims_between_snapshot_and_gate_are_dropped() { + fn claims_after_close_are_gated_without_poisoning() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. let writer = unsafe { ShmWriter::new(shm.clone()) }; @@ -432,18 +415,9 @@ mod tests { assert!(iter.next() == None); assert!(frames.is_complete()); - // The gate is not set yet: a straggler's claim is admitted, lands - // beyond the snapshot, and commits into a slot the receiver never - // read — the record is dropped, not torn, and not incomplete. - let mut late = writer.claim_frame(5.try_into().unwrap()).unwrap(); - late.copy_from_slice(b"late!"); - late.finish(); - assert!(frames.iter().count() == 1); - - // Once the gate lands, claims fail cleanly and still do not mark - // the trace incomplete: the access is outside the closed boundary. - // SAFETY: see `collect_frames`. - unsafe { close_claims(&shm) }; + // Close set the gate: a straggler's claim fails cleanly and does + // not mark the trace incomplete — the access is outside the closed + // boundary. assert!(writer.is_closed()); assert!(writer.claim_frame(5.try_into().unwrap()).unwrap_err() == ClaimError::Closed); assert!(frames.is_complete()); @@ -599,7 +573,7 @@ mod tests { shm.poke_word(64, (bogus_len << 32) | bogus_offset); // SAFETY: see `collect_frames`. - let result = unsafe { close(&shm) }; + let result = unsafe { close(shm) }; assert!(result.unwrap_err() == ProtocolError::CorruptDescriptor { slot_index: 0 }); } @@ -615,7 +589,7 @@ mod tests { shm.poke_word(64, (1 << 63) | (8u64 << 32) | 8); // SAFETY: see `collect_frames`. - let result = unsafe { close(&shm) }; + let result = unsafe { close(shm) }; assert!(result.unwrap_err() == ProtocolError::CorruptDescriptor { slot_index: 0 }); } @@ -719,7 +693,7 @@ mod tests { // SAFETY: the mapping is a valid shared-memory region created zeroed // and accessed only through the protocol. - let frames = unsafe { close(&mapping) }.unwrap(); + let frames = unsafe { close(mapping) }.unwrap(); assert!(frames.is_complete()); let collected = frames.iter().map(BStr::new).collect::>(); assert!(collected.len() == CHILD_COUNT * FRAME_COUNT_EACH_CHILD); @@ -787,7 +761,7 @@ mod tests { assert!(writer.try_write_frame(b"alive")); // SAFETY: see `real_shm_across_processes`. - let frames = unsafe { close(&writer.into_memory()) }.unwrap(); + let frames = unsafe { close(writer.into_memory()) }.unwrap(); let mut iter = frames.iter(); assert!(iter.next().unwrap() == b"alive"); assert!(iter.next() == None); diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs index 06da49fae..9c128886c 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs @@ -1,40 +1,50 @@ -//! The receiver side: closing the channel and collecting committed frames. +//! The receiver side: closing the channel and borrowing committed frames. //! -//! Closing never waits for writers, and never writes shared memory beyond -//! freezing claimed slots: the close boundary is a snapshot load of the -//! claim counter, unfinished slots inside the snapshot are atomically -//! frozen, committed descriptors are validated, and their payloads are -//! copied out of the shared mapping. Claims that arrive after the snapshot -//! receive slot indices this pass never visits — the CLOSED gate that -//! eventually stops them is set separately, off the latency-sensitive path -//! (see [`super::state`]'s rule 1). +//! Closing never waits for writers. The close boundary is a snapshot of the +//! claim counter; unfinished slots inside the snapshot are atomically +//! frozen, committed descriptors are validated, and the CLOSED gate is set +//! so stragglers stop claiming. No payload byte is read or copied here: +//! [`Frames`] keeps the mapping alive and hands out borrows of the +//! validated committed spans on demand. //! -//! The copy is deliberate: the mapping stays writable in every traced -//! process, so this module never creates a reference into shared memory -//! whose validity would depend on another process's compliance. Committed -//! spans are read with atomic loads (see [`super::state`]) into an owned -//! buffer, and everything downstream parses the private copy. +//! Those borrows are sound because of the protocol, not despite it: +//! a committed span is never written again (committing consumes the +//! writer's frame), every borrow covers exactly one validated committed +//! span, and everything a live writer may still touch — counters, slots, +//! its own claimed or aborted spans — is disjoint from every committed +//! span. This rests on the constructor contract that the region is accessed +//! only through this protocol; a process scribbling outside the protocol is +//! outside the trust model. -use std::ops::Range; +use std::{fmt, slice}; use super::{ + AsRawSlice, layout::PayloadSpan, slot::{self, SlotState}, state::SharedState, }; -/// The committed frames of a closed channel, copied out of shared memory. -#[derive(Debug)] -pub struct Frames { - bytes: Vec, - bounds: Vec>, +/// The committed frames of a closed channel: validated spans borrowed from +/// the mapping, which stays alive inside this value. Dropping it releases +/// the mapping. +pub struct Frames { + mem: M, + spans: Vec, complete: bool, } -impl Frames { +impl Frames { /// Iterates over the committed frames in claim order. pub fn iter(&self) -> impl Iterator { - self.bounds.iter().map(|bounds| &self.bytes[bounds.clone()]) + let base = self.mem.as_raw_slice().cast::().cast_const(); + self.spans.iter().map(move |span| { + // SAFETY: `close` validated the span against this mapping's + // layout, and a committed span is immutable for the mapping's + // lifetime (see the module docs), so the shared borrow is valid + // for as long as `self` lives. + unsafe { slice::from_raw_parts(base.add(span.offset), span.len) } + }) } /// Whether the trace contains every reported access. @@ -48,6 +58,15 @@ impl Frames { } } +impl fmt::Debug for Frames { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Frames") + .field("frames", &self.spans.len()) + .field("complete", &self.complete) + .finish_non_exhaustive() + } +} + /// A trace whose shared-memory metadata could not have been produced by this /// protocol. The mapping was corrupted; the trace is unusable. #[derive(thiserror::Error, Clone, Copy, PartialEq, Eq, Debug)] @@ -56,11 +75,14 @@ pub enum ProtocolError { CorruptDescriptor { slot_index: usize }, } -/// Closes the channel and collects the committed frames. +/// Closes the channel and returns the committed frames as borrows of the +/// mapping, which moves into the returned [`Frames`]. /// /// Never blocks on writers: writers admitted before the snapshot race per /// slot, and each raced slot independently ends up committed (included) or -/// aborted (excluded). See the crate-level protocol docs in [`super`]. +/// aborted (excluded). Claims after the snapshot land in slots this pass +/// never visits until the CLOSED gate — set before returning — stops them. +/// See the crate-level protocol docs in [`super`]. /// /// # Safety /// @@ -73,22 +95,49 @@ pub enum ProtocolError { /// Panics when the region is not word-aligned or its size is outside the /// supported range (see [`SharedState::borrow`]) — a broken caller, not /// corrupt shared data, which is reported as [`ProtocolError`] instead. -pub(super) unsafe fn close(mem: *mut [u8]) -> Result { - // SAFETY: forwarded from this function's contract. - let state = unsafe { SharedState::borrow(mem) }; +pub(super) unsafe fn close(mem: M) -> Result, ProtocolError> { + let spans; + let complete; + { + // SAFETY: forwarded from this function's contract; the raw slice + // stays valid while `mem` is borrowed here and beyond, since `mem` + // moves into the returned `Frames`. + let state = unsafe { SharedState::borrow(mem.as_raw_slice()) }; + + // The close boundary: claims at or before this snapshot are inside + // it, later ones land in slots this pass never visits. The count is + // clamped to the table capacity, so a counter inflated by failed + // claims (or by a foreign scribble) degrades to a full-table sweep, + // not an error. + let slot_count = state.snapshot_claims(); + + // Gate further claims. Cheap: the creator pre-faulted this page + // where first touches are expensive. Claims racing between the + // snapshot and this gate are dropped soundly (see the module docs + // in `super`). + state.close_claims(); - // The close boundary: claims at or before this snapshot are inside it, - // later ones land in slots this pass never visits. The count is clamped - // to the table capacity, so a counter inflated by failed claims (or by a - // foreign scribble) degrades to a full-table sweep, not an error. - let slot_count = state.snapshot_claims(); + // Freeze pass: drive every admitted slot to a terminal state and + // collect the committed spans. After this loop the snapshot's slice + // of the descriptor table can no longer change — late writers lose + // their commit race against `ABORTED`. + spans = freeze_committed_spans(state, slot_count)?; + + // Read the incomplete flag only after freezing: a writer sets it + // before performing an operation whose record was lost, so any flag + // this load misses belongs to an operation performed after the + // boundary (rule 1 in `state`'s ordering contract). + complete = !state.is_incomplete(); + } + + Ok(Frames { mem, spans, complete }) +} - // Freeze pass: drive every admitted slot to a terminal state and collect - // the committed spans. After this loop the snapshot's slice of the - // descriptor table can no longer change — late writers lose their commit - // race against `ABORTED`. +fn freeze_committed_spans( + state: SharedState<'_>, + slot_count: usize, +) -> Result, ProtocolError> { let mut spans = Vec::new(); - let mut payload_total = 0usize; for slot_index in 0..slot_count { match slot::decode(state.freeze(slot_index)) { SlotState::Aborted => {} @@ -96,7 +145,6 @@ pub(super) unsafe fn close(mem: *mut [u8]) -> Result { let span = PayloadSpan::validate(state.mapping_len(), payload_offset, payload_len) .ok_or(ProtocolError::CorruptDescriptor { slot_index })?; spans.push(span); - payload_total += span.len; } // `freeze` only returns terminal values, so `Unfinished` is // unreachable and grouped with the corrupt case. @@ -105,34 +153,5 @@ pub(super) unsafe fn close(mem: *mut [u8]) -> Result { } } } - - // Copy pass: move the committed payloads into an owned buffer. - let mut bytes = Vec::with_capacity(payload_total); - let mut bounds = Vec::with_capacity(spans.len()); - for span in spans { - let start = bytes.len(); - state.read_payload(span, &mut bytes); - bounds.push(start..bytes.len()); - } - - // Read the incomplete flag only after freezing: a writer sets it before - // performing an operation whose record was lost, so any flag this load - // misses belongs to an operation performed after the boundary (rule 1 in - // `state`'s ordering contract). - let complete = !state.is_incomplete(); - - Ok(Frames { bytes, bounds, complete }) -} - -/// Sets the CLOSED gate so writers stop claiming (and stop allocating pages -/// of the region) once they observe it. See [`SharedState::close_claims`]: -/// deliberately separate from [`close`] so the gate's page-materializing -/// write can run off the latency-sensitive path. -/// -/// # Safety -/// -/// Same contract as [`close`]. -pub(super) unsafe fn close_claims(mem: *mut [u8]) { - // SAFETY: forwarded from this function's contract. - unsafe { SharedState::borrow(mem) }.close_claims(); + Ok(spans) } diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/state.rs b/crates/fspy_shared/src/ipc/channel/shm_io/state.rs index 8a64672d3..1fdb0801e 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/state.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/state.rs @@ -43,25 +43,15 @@ //! committed descriptor becomes visible. //! 3. **Receiver observation** — the freeze compare-and-swap uses `Acquire` //! on failure ([`SharedState::freeze`]): observing a committed descriptor -//! also makes the payload writes it published visible, so the copy in -//! [`SharedState::read_payload`] reads settled bytes. -//! -//! Payload reads use `Relaxed` atomic loads rather than plain loads: committed -//! payloads are immutable under the protocol, but the mapping is writable in -//! every traced process, so a buggy foreign process can scribble concurrently. -//! Atomic loads keep such races from being undefined behavior in this -//! process — each load returns *some* value, and torn garbage surfaces as a -//! frame-decoding error instead of a crash. +//! also makes the payload writes it published visible, so the borrows the +//! receiver later hands out (see `reader`) read settled bytes. use std::{ marker::PhantomData, sync::atomic::{AtomicU64, Ordering}, }; -use super::{ - layout::{self, PayloadSpan}, - slot, -}; +use super::{layout, slot}; /// The CLOSED gate bit of the claim counter. The low 63 bits count claims, /// so no realistic claim volume can carry into the gate. @@ -293,23 +283,4 @@ impl<'m> SharedState<'m> { // `layout` keeps inside the mapping. unsafe { self.base.add(offset) } } - - /// Appends the payload bytes of a validated span to `out`. - /// - /// Reads the span's word-aligned reservation with `Relaxed` atomic loads - /// (see the module docs) and appends exactly `span.len` bytes. - pub(super) fn read_payload(self, span: PayloadSpan, out: &mut Vec) { - let keep = out.len() + span.len; - for word_index in 0..span.reserved_len() / layout::SLOT_LEN { - let offset = span.offset + word_index * layout::SLOT_LEN; - // SAFETY: `PayloadSpan::validate` checked that the word-aligned - // reservation `[span.offset, span.offset + span.reserved_len())` - // lies inside the payload region, and `span.offset` is - // word-aligned. - let word = unsafe { AtomicU64::from_ptr(self.base.add(offset).cast()) }; - out.extend_from_slice(&word.load(Ordering::Relaxed).to_ne_bytes()); - } - // Drop the sub-word padding bytes of the final word. - out.truncate(keep); - } } From d91b2fb8249099a0ed8682bc1dc296aa8a262c15 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sat, 15 Aug 2026 08:44:23 +0800 Subject: [PATCH 13/92] refactor(fspy): rename CollectedAccesses and drop its async wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing is collected anymore — frames are borrowed in place — and the async wrapper descended from the file-lock era, when acquiring the trace could block until every sender exited. Closing is now bounded by the number of reported records and runs inline, so the type becomes ChannelAccesses with a TryFrom conversion. Co-Authored-By: Claude Fable 5 --- crates/fspy/src/ipc.rs | 33 +++++++++++++++++---------------- crates/fspy/src/unix/mod.rs | 6 +++--- crates/fspy/src/windows/mod.rs | 6 +++--- 3 files changed, 23 insertions(+), 22 deletions(-) diff --git a/crates/fspy/src/ipc.rs b/crates/fspy/src/ipc.rs index 169d8bb06..f2443954a 100644 --- a/crates/fspy/src/ipc.rs +++ b/crates/fspy/src/ipc.rs @@ -4,30 +4,33 @@ use fspy_shared::ipc::{ PathAccess, channel::{Frames, Receiver}, }; -use tokio::task::spawn_blocking; // Shared memory size for storing path accesses. // 4 GiB is large enough to store path accesses in almost any realistic scenario. // This doesn't allocate physical memory until it's actually used. pub const SHM_CAPACITY: usize = 4 * 1024 * 1024 * 1024; -/// The validated path accesses collected from a closed IPC channel. -pub struct CollectedAccesses { +/// The path accesses a run reported through the IPC channel. +pub struct ChannelAccesses { frames: Frames, } -impl CollectedAccesses { - /// Closes the channel and validates the collected trace. +impl TryFrom for ChannelAccesses { + type Error = io::Error; + + /// Closes the channel and rejects traces that cannot back the run's + /// file accesses. /// - /// Never waits for tracked processes: closing rejects new records and + /// Never waits for tracked processes — closing rejects new records and /// atomically ignores unfinished ones (see - /// [`fspy_shared::ipc::channel::Receiver::close`]). + /// [`fspy_shared::ipc::channel::Receiver::close`]) — and its work is + /// bounded by the number of reported records, so it runs inline. /// - /// Fails when the trace cannot back the run's file accesses: a record - /// was lost before close, or the shared-memory metadata was corrupted. - /// Failing here — instead of returning a silently short trace — keeps - /// the tracking result trustworthy for caching. - pub fn collect(receiver: Receiver) -> io::Result { + /// Fails when a record was lost before close or the shared-memory + /// metadata was corrupted. Failing here — instead of returning a + /// silently short trace — keeps the tracking result trustworthy for + /// caching. + fn try_from(receiver: Receiver) -> io::Result { let frames = receiver.close()?; if !frames.is_complete() { return Err(io::Error::new( @@ -37,11 +40,9 @@ impl CollectedAccesses { } Ok(Self { frames }) } +} - pub async fn collect_async(receiver: Receiver) -> io::Result { - spawn_blocking(move || Self::collect(receiver)).await.expect("collect task panicked") - } - +impl ChannelAccesses { pub fn iter_path_accesses(&self) -> impl Iterator> { self.frames.iter().map(|frame| { wincode::deserialize_exact(frame) diff --git a/crates/fspy/src/unix/mod.rs b/crates/fspy/src/unix/mod.rs index 944cec7c4..fe517b99c 100644 --- a/crates/fspy/src/unix/mod.rs +++ b/crates/fspy/src/unix/mod.rs @@ -25,7 +25,7 @@ use tokio::task::spawn_blocking; use tokio_util::sync::CancellationToken; #[cfg(not(target_env = "musl"))] -use crate::ipc::{CollectedAccesses, SHM_CAPACITY}; +use crate::ipc::{ChannelAccesses, SHM_CAPACITY}; use crate::{ChildTermination, Command, TrackedChild, arena::PathAccessArena, error::SpawnError}; #[derive(Debug)] @@ -162,7 +162,7 @@ impl SpyImpl { // Close the ipc channel after the child has exited. // We are not interested in path accesses from descendants after the main child has exited. #[cfg(not(target_env = "musl"))] - let ipc_accesses = CollectedAccesses::collect_async(ipc_receiver).await?; + let ipc_accesses = ChannelAccesses::try_from(ipc_receiver)?; let path_accesses = PathAccessIterable { arenas, #[cfg(not(target_env = "musl"))] @@ -180,7 +180,7 @@ impl SpyImpl { pub struct PathAccessIterable { arenas: Vec, #[cfg(not(target_env = "musl"))] - ipc_accesses: CollectedAccesses, + ipc_accesses: ChannelAccesses, } impl PathAccessIterable { diff --git a/crates/fspy/src/windows/mod.rs b/crates/fspy/src/windows/mod.rs index ef720e988..a79606e73 100644 --- a/crates/fspy/src/windows/mod.rs +++ b/crates/fspy/src/windows/mod.rs @@ -24,14 +24,14 @@ use crate::{ ChildTermination, TrackedChild, command::Command, error::SpawnError, - ipc::{CollectedAccesses, SHM_CAPACITY}, + ipc::{ChannelAccesses, SHM_CAPACITY}, }; const INTERPOSE_CDYLIB: Artifact = artifact!("fspy_preload", "CARGO_CDYLIB_FILE_FSPY_PRELOAD_WINDOWS"); pub struct PathAccessIterable { - ipc_accesses: CollectedAccesses, + ipc_accesses: ChannelAccesses, } impl PathAccessIterable { @@ -169,7 +169,7 @@ impl SpyImpl { }; // Close the ipc channel after the child has exited. // We are not interested in path accesses from descendants after the main child has exited. - let ipc_accesses = CollectedAccesses::collect_async(receiver).await?; + let ipc_accesses = ChannelAccesses::try_from(receiver)?; let path_accesses = PathAccessIterable { ipc_accesses }; io::Result::Ok(ChildTermination { status, path_accesses }) From 0a80d2b13de8950d34221daf231ccf11b03303ad Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sat, 15 Aug 2026 09:05:13 +0800 Subject: [PATCH 14/92] docs(fspy-shm): add a protocol README to shm_io Co-Authored-By: Claude Fable 5 --- .../src/ipc/channel/shm_io/README.md | 149 ++++++++++++++++++ .../fspy_shared/src/ipc/channel/shm_io/mod.rs | 3 + 2 files changed, 152 insertions(+) create mode 100644 crates/fspy_shared/src/ipc/channel/shm_io/README.md diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/README.md b/crates/fspy_shared/src/ipc/channel/shm_io/README.md new file mode 100644 index 000000000..ae5c4ea35 --- /dev/null +++ b/crates/fspy_shared/src/ipc/channel/shm_io/README.md @@ -0,0 +1,149 @@ +# shm_io: the fspy frame channel + +One shared-memory region. Many writer processes append records; one +receiver reads them once, at the end of a run. The writers are traced build +processes reporting file accesses; the receiver is the runner deciding +whether the run can be cached. + +Three requirements shaped everything here: + +1. **A writer may die at any instruction** — SIGKILL included. This must + never corrupt the channel or lose another writer's records. +2. **A writer may outlive the run** (a daemon). The receiver must never + wait for writers; closing is immediate. +3. **The result must be safe to cache from.** Either the trace holds every + record the run reported, or the run is declared uncacheable. Never a + silently short trace. + +Earlier designs failed these. A file lock proved "no writers left", but a +process that closes file descriptors it does not recognize releases the +lock while its memory mapping keeps writing — the reader then parsed +half-written bytes (issue #544). Waiting for writers hangs forever on +daemons. Counting active writers breaks because a killed process never +decrements the count. + +## The region + +The channel is a sparse file in the temp directory, mapped into every +participating process. It is address space, not memory: only pages that +are actually written get backed. + +```text +| header (64 B) | descriptor table (1/8 of the region) | payloads (the rest, grow up) | +``` + +The header holds three words, and every one of them only ever counts up: + +- the **claim counter** — how many frames were ever claimed. Bit 63 is the + CLOSED gate. +- the **incomplete flag** — nonzero once any live writer lost a record. +- the **payload counter** — how many payload bytes were ever reserved, + including by failed claims. Only writers read it. + +The table has one 8-byte slot per frame. For the production 4 GiB region +that is ~67 million slots; the ~3.5 GiB payload region fits ~15–20 million +typical records, so payload space runs out first. The split is fixed, not a +movable frontier, because fixed bounds are what make claiming wait-free: +each counter is checked against its own constant limit using the value +`fetch_add` returned, and overshooting a limit is harmless because nothing +ever locates data through a counter — descriptors are self-describing. + +## Writing a frame + +Three steps: + +1. **Claim.** Two `fetch_add`s — one reserves payload bytes, one reserves a + slot. No retry loop, no lock. A claim that does not fit fails after the + fact; the wasted counter space does not matter (see above). +2. **Fill.** The writer serializes into its payload span. The span is + exclusively its own; nobody else knows it exists yet. +3. **Commit.** One compare-and-swap flips the frame's slot from zero to a + descriptor holding the payload's offset and length. The CAS is the + publication point: before it, the frame does not exist; after it, the + payload is immutable. + +Committing is explicit (`FrameMut::finish`). What happens when it never +runs is the heart of the design: + +- **The process died** — mid-claim, mid-fill, anywhere. The slot stays + zero. The receiver ignores it. Nothing else is affected, and no cleanup + code ever runs or is needed. +- **The process is alive but abandoned the frame** (dropped it without + finishing). The drop sets the incomplete flag, because the process will + go on to perform the file operation it just failed to record — the trace + now under-reports, so the run must not be cached. + +The rule that makes ignoring dead writers safe: **a record is committed +before the recorded operation is performed.** A dead writer's missing +record is an operation that never happened. A record refused after close +belongs to an operation performed after the run's boundary. + +## Closing and reading + +The receiver closes once, at the end of the run: + +1. **Snapshot** the claim counter with a plain load. This is the boundary: + claims at or before it are in the run, later ones are not. +2. **Gate** further claims by setting the CLOSED bit, so stragglers stop. +3. **Freeze** every slot in the snapshot: a compare-and-swap flips zero to + ABORTED. If the slot was already committed, the swap fails and the frame + is kept. Exactly one side wins each slot; both outcomes are terminal. +4. **Validate** each committed descriptor's bounds. A descriptor no correct + writer could produce fails the whole trace — never a panic, never an + out-of-bounds read. + +The result, `Frames`, owns the mapping and lends out one `&[u8]` per +committed span, straight from shared memory — no copy. The borrows are +sound because a committed span is never written again and is disjoint from +everything a live straggler may still touch. The mapping is released when +`Frames` is dropped. + +```text + writer's commit CAS wins + +------------------------------> COMMITTED (readable) +CLAIMED (slot 0) ---+ + +------------------------------> ABORTED (ignored) + receiver's freeze CAS wins +``` + +## Why this is sound, in one list + +- Frame traversal never reads payload bytes; every slot has a fixed place. + The #544 failure class (payload parsed as metadata) is structurally gone. +- A payload is reachable only through its committed descriptor. The commit + is a `Release` write and the receiver's failed freeze is an `Acquire` + read, so an observed descriptor implies fully visible payload bytes. +- Committed and aborted are terminal. No code path changes a terminal slot. +- Counters only grow. The receiver clamps them to the fixed capacities, so + an inflated counter degrades into extra aborted slots, not corruption. +- The bounds checks on descriptors are what justify the `unsafe` borrow + construction: the receiver's memory safety never depends on another + process being correct. +- Byte _integrity_ does trust protocol compliance — a process scribbling + random memory is outside the model. That trust buys the borrow-in-place + reader and the absence of checksums. + +## Performance notes + +- Claiming is two atomic adds; committing is one CAS. Nothing retries. +- Closing costs one pass over the claimed slots. No payload is copied. +- On Linux, the first touch of the sparse file can cost milliseconds on + journalling filesystems (it is the fault path, not block allocation — + `fallocate` does not help). Channel creation therefore pre-touches the + header page on a background thread, concurrently with process startup. + Windows and macOS fault cheaply and skip this. + +## Files + +| File | Role | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `mod.rs` | Public surface (`ShmWriter`, `FrameMut`, `close`, `Frames`), the protocol overview docs, and the integration tests — they run against a mocked region and are miri-clean (`cargo miri test -p fspy_shared shm_io`). | +| `layout.rs` | Pure geometry: header offsets, the table/payload split, span validation. Plain integer math, no pointers, no atomics. | +| `slot.rs` | The descriptor codec: pack and unpack one slot value, classify it as unfinished, aborted, committed, or corrupt. Pure. | +| `state.rs` | The only module that touches shared memory. All atomics, every unsafe pointer derivation, and the three-rule memory-ordering contract live here. | +| `writer.rs` | Claim, fill, finish. Owns the argument for why a claimed payload span is exclusively the writer's. | +| `reader.rs` | Close (snapshot, gate, freeze, validate) and `Frames`. Owns the argument for why borrowing committed spans is sound. | + +Each file carries one self-contained argument, so the protocol can be +reviewed module by module: the pure math first, then the atomics, then the +two aliasing arguments built on top of them. diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs index dac3ae899..fcb8e1f50 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs @@ -5,6 +5,9 @@ //! waiting for any writer. A process may die at any instruction — mid-claim, //! mid-write, pre-commit — and only its own unfinished frame is lost. //! +//! `README.md` in this directory tells the whole story in plain words and +//! indexes the modules. +//! //! # Region layout //! //! ```text From 3336edf493d02a2bfd97449ec23bbf180161edd7 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sat, 15 Aug 2026 09:10:52 +0800 Subject: [PATCH 15/92] docs(fspy-shm): keep shm_io usage-agnostic The protocol layer should not know its consumer: describe the publish- before-perform rule as the intended usage contract and the incomplete flag as a property of the channel, with no mention of what sits on top. Co-Authored-By: Claude Fable 5 --- .../src/ipc/channel/shm_io/README.md | 92 ++++++++++--------- .../fspy_shared/src/ipc/channel/shm_io/mod.rs | 18 ++-- .../src/ipc/channel/shm_io/reader.rs | 11 ++- .../src/ipc/channel/shm_io/writer.rs | 13 +-- 4 files changed, 69 insertions(+), 65 deletions(-) diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/README.md b/crates/fspy_shared/src/ipc/channel/shm_io/README.md index ae5c4ea35..702fec851 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/README.md +++ b/crates/fspy_shared/src/ipc/channel/shm_io/README.md @@ -1,32 +1,30 @@ -# shm_io: the fspy frame channel +# shm_io: a crash-tolerant frame channel over shared memory -One shared-memory region. Many writer processes append records; one -receiver reads them once, at the end of a run. The writers are traced build -processes reporting file accesses; the receiver is the runner deciding -whether the run can be cached. +One shared-memory region. Many writer processes append variable-length +records; one receiver collects them once, when the channel's lifetime ends. Three requirements shaped everything here: -1. **A writer may die at any instruction** — SIGKILL included. This must - never corrupt the channel or lose another writer's records. -2. **A writer may outlive the run** (a daemon). The receiver must never - wait for writers; closing is immediate. -3. **The result must be safe to cache from.** Either the trace holds every - record the run reported, or the run is declared uncacheable. Never a - silently short trace. - -Earlier designs failed these. A file lock proved "no writers left", but a -process that closes file descriptors it does not recognize releases the -lock while its memory mapping keeps writing — the reader then parsed -half-written bytes (issue #544). Waiting for writers hangs forever on -daemons. Counting active writers breaks because a killed process never -decrements the count. +1. **A writer may die at any instruction** — killed, crashed, anywhere. + This must never corrupt the channel or lose another writer's records. +2. **A writer may outlive the channel.** The receiver must never wait for + writers; closing is immediate. +3. **The receiver must know whether it got everything.** Either the frames + hold every record writers published, or they are flagged incomplete. + Never a silently short result. + +Simpler designs fail these. A lock that writers hold while active proves +"no writers left" only as long as every writer manages the lock correctly — +one process dropping it early lets the reader race live writes and parse +half-written bytes. Waiting for writers to finish hangs forever on a +writer that never exits. Counting active writers breaks because a killed +process never decrements the count. ## The region -The channel is a sparse file in the temp directory, mapped into every -participating process. It is address space, not memory: only pages that -are actually written get backed. +The region is any zero-initialized shared memory — in practice a sparse +file mapped into every participating process. A sparse mapping is address +space, not memory: only pages that are actually written get backed. ```text | header (64 B) | descriptor table (1/8 of the region) | payloads (the rest, grow up) | @@ -40,13 +38,14 @@ The header holds three words, and every one of them only ever counts up: - the **payload counter** — how many payload bytes were ever reserved, including by failed claims. Only writers read it. -The table has one 8-byte slot per frame. For the production 4 GiB region -that is ~67 million slots; the ~3.5 GiB payload region fits ~15–20 million -typical records, so payload space runs out first. The split is fixed, not a -movable frontier, because fixed bounds are what make claiming wait-free: -each counter is checked against its own constant limit using the value -`fetch_add` returned, and overshooting a limit is harmless because nothing -ever locates data through a counter — descriptors are self-describing. +The table has one 8-byte slot per frame. For a 4 GiB region that is ~67 +million slots; the ~3.5 GiB payload region fits ~15–20 million records of +a few hundred bytes, so payload space runs out first. The split is fixed, +not a movable frontier, because fixed bounds are what make claiming +wait-free: each counter is checked against its own constant limit using +the value `fetch_add` returned, and overshooting a limit is harmless +because nothing ever locates data through a counter — descriptors are +self-describing. ## Writing a frame @@ -69,27 +68,29 @@ runs is the heart of the design: zero. The receiver ignores it. Nothing else is affected, and no cleanup code ever runs or is needed. - **The process is alive but abandoned the frame** (dropped it without - finishing). The drop sets the incomplete flag, because the process will - go on to perform the file operation it just failed to record — the trace - now under-reports, so the run must not be cached. + finishing). The drop sets the incomplete flag: a live writer that failed + to publish a record may still go on to act as if it had, so the channel + stops claiming completeness. -The rule that makes ignoring dead writers safe: **a record is committed -before the recorded operation is performed.** A dead writer's missing -record is an operation that never happened. A record refused after close -belongs to an operation performed after the run's boundary. +This split assumes the intended usage contract: **a writer publishes a +record before performing the action the record describes.** Under that +contract, a dead writer's missing record describes an action that never +happened, and a record refused after close describes an action performed +after the channel's boundary — both safe to ignore. A writer that records +_after_ acting must not rely on these semantics. ## Closing and reading -The receiver closes once, at the end of the run: +The receiver closes once: 1. **Snapshot** the claim counter with a plain load. This is the boundary: - claims at or before it are in the run, later ones are not. + claims at or before it are in, later ones are not. 2. **Gate** further claims by setting the CLOSED bit, so stragglers stop. 3. **Freeze** every slot in the snapshot: a compare-and-swap flips zero to ABORTED. If the slot was already committed, the swap fails and the frame is kept. Exactly one side wins each slot; both outcomes are terminal. 4. **Validate** each committed descriptor's bounds. A descriptor no correct - writer could produce fails the whole trace — never a panic, never an + writer could produce fails the whole channel — never a panic, never an out-of-bounds read. The result, `Frames`, owns the mapping and lends out one `&[u8]` per @@ -109,7 +110,7 @@ CLAIMED (slot 0) ---+ ## Why this is sound, in one list - Frame traversal never reads payload bytes; every slot has a fixed place. - The #544 failure class (payload parsed as metadata) is structurally gone. + A half-written payload can never be parsed as metadata. - A payload is reachable only through its committed descriptor. The commit is a `Release` write and the receiver's failed freeze is an `Acquire` read, so an observed descriptor implies fully visible payload bytes. @@ -127,11 +128,12 @@ CLAIMED (slot 0) ---+ - Claiming is two atomic adds; committing is one CAS. Nothing retries. - Closing costs one pass over the claimed slots. No payload is copied. -- On Linux, the first touch of the sparse file can cost milliseconds on - journalling filesystems (it is the fault path, not block allocation — - `fallocate` does not help). Channel creation therefore pre-touches the - header page on a background thread, concurrently with process startup. - Windows and macOS fault cheaply and skip this. +- On Linux, the first touch of the sparse backing file can cost + milliseconds on journalling filesystems (it is the fault path, not block + allocation — `fallocate` does not help). Creators should run + [`pre_fault`] off any latency-sensitive path — for example on a + background thread, concurrently with spawning the first writer. Windows + and macOS fault cheaply and skip this. ## Files diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs index fcb8e1f50..13bc0ca0d 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs @@ -57,8 +57,8 @@ //! drops are sound because writers publish a record *before* performing the //! recorded operation: a process that died mid-frame never performed the //! operation, and one that claimed or committed after the snapshot performs -//! it outside the run's tracking boundary. A live writer that loses a -//! record *before* close (capacity, abandonment) flags the trace incomplete +//! it outside the channel's boundary. A live writer that loses a +//! record *before* close (capacity, abandonment) flags the channel incomplete //! ([`Frames::is_complete`]). //! //! Correctness never depends on writer-side cleanup: no exit hooks, PID @@ -257,8 +257,8 @@ mod tests { // Larger than the payload region, and larger than the absolute frame // limit: both fail the claim. The failed reservation stays counted — - // harmless, because the failure already made the trace incomplete - // and therefore uncacheable. + // harmless, because the failure already marked the channel + // incomplete. assert!(!writer.try_write_frame(&vec![0u8; 2048])); assert!( writer.claim_frame(((i32::MAX as usize) + 1).try_into().unwrap()).unwrap_err() @@ -291,7 +291,7 @@ mod tests { assert!(iter.next().unwrap() == b"foo"); assert!(iter.next().unwrap() == b"bar"); assert!(iter.next() == None); - // Death loses no performed operation, so the trace stays complete. + // Death loses no performed operation, so the channel stays complete. assert!(frames.is_complete()); } @@ -346,7 +346,7 @@ mod tests { } #[test] - fn abandoned_frame_marks_the_trace_incomplete() { + fn abandoned_frame_marks_the_channel_incomplete() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. let writer = unsafe { ShmWriter::new(shm.clone()) }; @@ -419,8 +419,8 @@ mod tests { assert!(frames.is_complete()); // Close set the gate: a straggler's claim fails cleanly and does - // not mark the trace incomplete — the access is outside the closed - // boundary. + // not mark the channel incomplete — the operation is outside the + // closed boundary. assert!(writer.is_closed()); assert!(writer.claim_frame(5.try_into().unwrap()).unwrap_err() == ClaimError::Closed); assert!(frames.is_complete()); @@ -769,7 +769,7 @@ mod tests { assert!(iter.next().unwrap() == b"alive"); assert!(iter.next() == None); // Death runs no drop code, so the killed writer's lost frame does not - // mark the trace incomplete. + // mark the channel incomplete. assert!(frames.is_complete()); } } diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs index 9c128886c..51409d3c1 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs @@ -47,11 +47,12 @@ impl Frames { }) } - /// Whether the trace contains every reported access. + /// Whether every record a writer published made it in. /// /// False when a live writer lost a record before the channel closed - /// (capacity exhaustion or an abandoned frame): the trace then - /// under-reports the run's accesses and must not back a cache entry. + /// (capacity exhaustion or an abandoned frame): the frames then + /// under-report what writers went on to do, and consumers that need + /// completeness must reject them. #[must_use] pub const fn is_complete(&self) -> bool { self.complete @@ -67,8 +68,8 @@ impl fmt::Debug for Frames { } } -/// A trace whose shared-memory metadata could not have been produced by this -/// protocol. The mapping was corrupted; the trace is unusable. +/// Shared-memory metadata that could not have been produced by this +/// protocol. The region was corrupted; its frames are unusable. #[derive(thiserror::Error, Clone, Copy, PartialEq, Eq, Debug)] pub enum ProtocolError { #[error("corrupt shared-memory frame descriptor at slot {slot_index}")] diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs index 860a3cacf..33c06cbba 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs @@ -26,12 +26,12 @@ pub struct ShmWriter { /// Why a frame could not be claimed. #[derive(thiserror::Error, Clone, Copy, PartialEq, Eq, Debug)] pub enum ClaimError { - /// The receiver closed the channel; the run is over and the access is - /// outside its tracking boundary. + /// The receiver closed the channel; anything after this point is + /// outside the channel's boundary. #[error("the channel has been closed by the receiver")] Closed, /// The frame is oversized or the region is full. The claim has already - /// recorded the loss, so the trace will be reported as incomplete. + /// recorded the loss, so the channel will report itself incomplete. #[error("no space left in the shared-memory region")] Capacity, } @@ -86,14 +86,15 @@ impl ShmWriter { /// /// The frame is invisible to the receiver until [`FrameMut::finish`] /// commits it. Dropping the frame without finishing abandons the claim - /// and marks the trace incomplete. + /// and marks the channel incomplete. pub fn claim_frame(&self, frame_size: NonZeroUsize) -> Result, ClaimError> { let state = self.state(); let reservation = state.try_claim(frame_size.get()).map_err(|err| match err { ReserveError::Closed => ClaimError::Closed, ReserveError::Capacity => { // The record is lost but this process lives on to perform the - // operation, so poison the trace before the caller proceeds. + // operation, so flag the channel incomplete before the caller + // proceeds. state.flag_incomplete(); ClaimError::Capacity } @@ -179,7 +180,7 @@ impl ShmWriter { /// [`FrameMut::finish`] commits the frame; it is the only way to make the /// payload visible to the receiver. Dropping the frame instead abandons the /// claim: the slot stays unfinished (the receiver will abort and ignore it) -/// and the trace is marked incomplete, because the dropping process is alive +/// and the channel is marked incomplete, because the dropping process is alive /// to perform the operation this record was meant to describe. A process /// that dies mid-frame runs no drop code and marks nothing — correctly so, /// since records are published before the recorded operation is performed. From 2d6aafa706bca8c1dfd6f54bc8b41f8865ab9c7e Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sat, 15 Aug 2026 09:12:43 +0800 Subject: [PATCH 16/92] docs(fspy-shm): say u64 and AtomicU64 instead of word Co-Authored-By: Claude Fable 5 --- .../src/ipc/channel/shm_io/README.md | 3 +- .../src/ipc/channel/shm_io/layout.rs | 33 ++++++++--------- .../fspy_shared/src/ipc/channel/shm_io/mod.rs | 18 +++++----- .../src/ipc/channel/shm_io/reader.rs | 2 +- .../src/ipc/channel/shm_io/state.rs | 35 ++++++++++--------- .../src/ipc/channel/shm_io/writer.rs | 2 +- 6 files changed, 48 insertions(+), 45 deletions(-) diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/README.md b/crates/fspy_shared/src/ipc/channel/shm_io/README.md index 702fec851..4496cc48e 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/README.md +++ b/crates/fspy_shared/src/ipc/channel/shm_io/README.md @@ -30,7 +30,8 @@ space, not memory: only pages that are actually written get backed. | header (64 B) | descriptor table (1/8 of the region) | payloads (the rest, grow up) | ``` -The header holds three words, and every one of them only ever counts up: +The header holds three `AtomicU64`s, and every one of them only ever +counts up: - the **claim counter** — how many frames were ever claimed. Bit 63 is the CLOSED gate. diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs index fdbd1d25e..8b7f3228d 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs @@ -28,8 +28,8 @@ pub(super) const HEADER_LEN: usize = 64; /// number of claims attempted). pub(super) const SLOT_COUNTER_OFFSET: usize = 0; -/// Byte offset of the incomplete flag word (nonzero once a live writer lost -/// a record). +/// Byte offset of the incomplete flag (one `u64`: nonzero once a live +/// writer lost a record). pub(super) const INCOMPLETE_OFFSET: usize = 8; /// Byte offset of the payload counter (one `u64`: payload bytes reserved, @@ -52,12 +52,13 @@ pub(super) const MAX_PAYLOAD_LEN: usize = i32::MAX as usize; /// mapping must fit `u32` arithmetic. pub(super) const MAX_MAPPING_LEN: usize = 1 << 32; -/// Rounds a payload length up to a multiple of the word size. +/// Rounds a payload length up to a multiple of `size_of::()`. /// -/// Payload reservations are word-aligned — combined with the word-aligned -/// region base they grow from, this keeps every payload offset word-aligned -/// so the receiver can copy payloads with aligned 64-bit atomic loads, and -/// the sub-word padding stays inside the frame's own reservation. +/// Payload reservations are whole `u64`s — combined with the `u64`-aligned +/// region base they grow from, every payload offset stays `u64`-aligned, an +/// invariant [`PayloadSpan::validate`] uses to reject descriptors no correct +/// writer produces. The sub-`u64` padding stays inside the frame's own +/// reservation. pub(super) const fn reserved_payload_len(payload_len: usize) -> usize { payload_len.next_multiple_of(SLOT_LEN) } @@ -78,13 +79,13 @@ pub(super) const fn max_slots(mapping_len: usize) -> usize { table_len(mapping_len) / SLOT_LEN } -/// Byte offset where the payload region starts. Word-aligned. +/// Byte offset where the payload region starts. `u64`-aligned. pub(super) const fn payload_base(mapping_len: usize) -> usize { HEADER_LEN + table_len(mapping_len) } -/// Byte size of the payload region. A multiple of the word size, so a -/// word-aligned reservation inside it never reaches past `mapping_len`. +/// Byte size of the payload region. A multiple of `size_of::()`, so a +/// reservation of whole `u64`s inside it never reaches past `mapping_len`. pub(super) const fn payload_region_len(mapping_len: usize) -> usize { let len = mapping_len - payload_base(mapping_len); len - len % SLOT_LEN @@ -99,7 +100,7 @@ pub(super) const fn payload_region_len(mapping_len: usize) -> usize { #[derive(Clone, Copy, Debug)] pub(super) struct PayloadSpan { /// Byte offset of the payload from the start of the mapping. - /// Always word-aligned. + /// Always `u64`-aligned. pub(super) offset: usize, /// Exact (unpadded) byte length of the payload. pub(super) len: usize, @@ -113,9 +114,9 @@ impl PayloadSpan { if len == 0 || len > MAX_PAYLOAD_LEN { return None; } - // Writers reserve word-aligned spans from the word-aligned region - // base, so a valid offset is word-aligned and its padded length stays - // inside the region. + // Writers reserve whole-`u64` spans from the `u64`-aligned region + // base, so a valid offset is `u64`-aligned and its padded length + // stays inside the region. if !offset.is_multiple_of(SLOT_LEN) { return None; } @@ -138,7 +139,7 @@ mod tests { use super::*; #[test] - fn reserved_payload_len_rounds_up_to_words() { + fn reserved_payload_len_rounds_up_to_u64s() { assert!(reserved_payload_len(1) == 8); assert!(reserved_payload_len(7) == 8); assert!(reserved_payload_len(8) == 8); @@ -186,7 +187,7 @@ mod tests { let mapping_len = 1024; let base = payload_base(mapping_len); let region = payload_region_len(mapping_len); - // A word-aligned span at the region start. + // A `u64`-aligned span at the region start. assert!(PayloadSpan::validate(mapping_len, base, 8).is_some()); // Exact end of the region, with padding inside it. assert!(PayloadSpan::validate(mapping_len, base + region - 8, 5).is_some()); diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs index 13bc0ca0d..a8ecf0133 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs @@ -18,7 +18,7 @@ //! fixed descriptor table payloads grow up -> //! ``` //! -//! The header holds three monotonic words ([`state`]): a claim counter +//! The header holds three monotonic `AtomicU64`s ([`state`]): a claim counter //! carrying the CLOSED gate bit, a payload counter, and an incomplete flag. //! A claim is two wait-free `fetch_add`s — one reserves payload bytes, one //! reserves a descriptor slot — validated against the fixed region bounds @@ -175,12 +175,12 @@ mod tests { Self { mem: Arc::new(mem), len } } - /// Overwrites a raw word of the region, simulating foreign-process + /// Overwrites one raw `u64` of the region, simulating foreign-process /// corruption of protocol metadata. - fn poke_word(&self, byte_offset: usize, value: u64) { + fn poke_u64(&self, byte_offset: usize, value: u64) { // SAFETY: the offsets used by tests lie within the allocation and - // are word-aligned; the atomic store synchronizes with the - // protocol's atomic accesses of the same word. + // are `u64`-aligned; the atomic store synchronizes with the + // protocol's atomic accesses of the same `u64`. let atomic = unsafe { AtomicU64::from_ptr(self.as_raw_slice().cast::().add(byte_offset).cast()) }; @@ -234,7 +234,7 @@ mod tests { } #[test] - fn multi_word_frame_roundtrips_exactly() { + fn frame_spanning_many_u64s_roundtrips_exactly() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. let writer = unsafe { ShmWriter::new(shm.clone()) }; @@ -573,7 +573,7 @@ mod tests { // Point slot 0 at a span escaping the mapping. let bogus_len = 8u64; let bogus_offset = 1020u64; - shm.poke_word(64, (bogus_len << 32) | bogus_offset); + shm.poke_u64(64, (bogus_len << 32) | bogus_offset); // SAFETY: see `collect_frames`. let result = unsafe { close(shm) }; @@ -589,7 +589,7 @@ mod tests { // The aborted bit combined with payload bits is a value no protocol // operation produces. - shm.poke_word(64, (1 << 63) | (8u64 << 32) | 8); + shm.poke_u64(64, (1 << 63) | (8u64 << 32) | 8); // SAFETY: see `collect_frames`. let result = unsafe { close(shm) }; @@ -607,7 +607,7 @@ mod tests { // scribble — degrades to a full-table sweep, never out-of-bounds // slot access: the committed frame survives, the untouched slots // freeze as aborted. - shm.poke_word(0, (1 << 40) | 1); + shm.poke_u64(0, (1 << 40) | 1); let frames = collect_frames(&shm); let mut iter = frames.iter(); diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs index 51409d3c1..5429dbbb6 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs @@ -93,7 +93,7 @@ pub enum ProtocolError { /// /// # Panics /// -/// Panics when the region is not word-aligned or its size is outside the +/// Panics when the region is not `u64`-aligned or its size is outside the /// supported range (see [`SharedState::borrow`]) — a broken caller, not /// corrupt shared data, which is reported as [`ProtocolError`] instead. pub(super) unsafe fn close(mem: M) -> Result, ProtocolError> { diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/state.rs b/crates/fspy_shared/src/ipc/channel/shm_io/state.rs index 1fdb0801e..f1481c3e3 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/state.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/state.rs @@ -5,9 +5,9 @@ //! construction contract of [`SharedState::borrow`]; no other module reads or //! writes the mapping directly. //! -//! # Shared words +//! # Shared atomics //! -//! The header holds three independent monotonic words (offsets in +//! The header holds three independent monotonic `AtomicU64`s (offsets in //! [`layout`]): //! //! - the **claim counter**: bit 63 is the CLOSED gate, the low bits count @@ -71,7 +71,7 @@ pub(super) enum ReserveError { pub(super) struct Reservation { /// Index of the reserved descriptor slot. pub(super) slot_index: usize, - /// Byte offset of the reserved payload span. Word-aligned. + /// Byte offset of the reserved payload span. `u64`-aligned. pub(super) payload_offset: usize, } @@ -98,7 +98,7 @@ impl<'m> SharedState<'m> { /// # Panics /// /// Panics when the mapping cannot host the protocol at all: base not - /// word-aligned, or length outside + /// `u64`-aligned, or length outside /// `[layout::HEADER_LEN, layout::MAX_MAPPING_LEN]`. These indicate a /// broken caller, not runtime data. pub(super) unsafe fn borrow(mem: *mut [u8]) -> Self { @@ -118,11 +118,11 @@ impl<'m> SharedState<'m> { layout::max_slots(self.len) } - /// A header word. `offset` must be one of the `layout` header offsets. - fn header_word(self, offset: usize) -> &'m AtomicU64 { + /// A header atomic. `offset` must be one of the `layout` header offsets. + fn header_atomic(self, offset: usize) -> &'m AtomicU64 { debug_assert!(offset < layout::HEADER_LEN); - // SAFETY: `borrow` checked that the mapping is word-aligned and at - // least `HEADER_LEN` bytes, so every word-aligned header offset is a + // SAFETY: `borrow` checked that the mapping is `u64`-aligned and at + // least `HEADER_LEN` bytes, so every `u64`-aligned header offset is a // valid, aligned `AtomicU64` for `'m`. unsafe { AtomicU64::from_ptr(self.base.add(offset).cast()) } } @@ -138,7 +138,7 @@ impl<'m> SharedState<'m> { fn slot_atomic(self, index: usize) -> &'m AtomicU64 { assert!(index < self.max_slots()); // SAFETY: the assertion keeps the slot inside the fixed table, which - // consists of word-aligned 8-byte slots after the aligned header. + // consists of `u64`-aligned slots after the aligned header. unsafe { AtomicU64::from_ptr(self.base.add(layout::HEADER_LEN + index * layout::SLOT_LEN).cast()) } @@ -146,7 +146,7 @@ impl<'m> SharedState<'m> { /// Whether the CLOSED gate has been set. pub(super) fn is_closed(self) -> bool { - self.header_word(layout::SLOT_COUNTER_OFFSET).load(Ordering::Relaxed) & CLOSED != 0 + self.header_atomic(layout::SLOT_COUNTER_OFFSET).load(Ordering::Relaxed) & CLOSED != 0 } /// Atomically reserves one descriptor slot and one payload span. @@ -163,7 +163,7 @@ impl<'m> SharedState<'m> { // because the counter is not what locates payloads (descriptors are) // and a `u64` cannot realistically wrap. let payload_start = self - .header_word(layout::PAYLOAD_COUNTER_OFFSET) + .header_atomic(layout::PAYLOAD_COUNTER_OFFSET) .fetch_add(reserved_len as u64, Ordering::Relaxed); // Checked: a foreign scribble of the counter must fail the claim, // not wrap the bound into an out-of-bounds reservation. @@ -172,7 +172,8 @@ impl<'m> SharedState<'m> { return Err(ReserveError::Capacity); } - let claims = self.header_word(layout::SLOT_COUNTER_OFFSET).fetch_add(1, Ordering::Relaxed); + let claims = + self.header_atomic(layout::SLOT_COUNTER_OFFSET).fetch_add(1, Ordering::Relaxed); if claims & CLOSED != 0 { return Err(ReserveError::Closed); } @@ -197,20 +198,20 @@ impl<'m> SharedState<'m> { /// Must be called before the operation whose record was lost is /// performed (rule 1). pub(super) fn flag_incomplete(self) { - self.header_word(layout::INCOMPLETE_OFFSET).fetch_or(1, Ordering::Relaxed); + self.header_atomic(layout::INCOMPLETE_OFFSET).fetch_or(1, Ordering::Relaxed); } /// Whether any live writer lost a record. Read after the freeze pass /// (rule 1). pub(super) fn is_incomplete(self) -> bool { - self.header_word(layout::INCOMPLETE_OFFSET).load(Ordering::Relaxed) != 0 + self.header_atomic(layout::INCOMPLETE_OFFSET).load(Ordering::Relaxed) != 0 } /// Snapshots the number of admitted claims: the receiver's close /// boundary (rule 1). Clamped to the table capacity because failed /// claims overshoot the counter. pub(super) fn snapshot_claims(self) -> usize { - let claims = self.header_word(layout::SLOT_COUNTER_OFFSET).load(Ordering::Relaxed); + let claims = self.header_atomic(layout::SLOT_COUNTER_OFFSET).load(Ordering::Relaxed); usize::try_from(claims & !CLOSED).unwrap_or(usize::MAX).min(self.max_slots()) } @@ -230,7 +231,7 @@ impl<'m> SharedState<'m> { /// Only Linux channels use this: elsewhere the first touch is cheap. #[cfg(target_os = "linux")] pub(super) fn pre_fault(self) { - let _ = self.header_word(layout::SLOT_COUNTER_OFFSET).compare_exchange( + let _ = self.header_atomic(layout::SLOT_COUNTER_OFFSET).compare_exchange( 0, 0, Ordering::Relaxed, @@ -245,7 +246,7 @@ impl<'m> SharedState<'m> { /// this write materializes the counter page, which can cost /// milliseconds of first-block allocation on journalling filesystems. pub(super) fn close_claims(self) { - self.header_word(layout::SLOT_COUNTER_OFFSET).fetch_or(CLOSED, Ordering::Relaxed); + self.header_atomic(layout::SLOT_COUNTER_OFFSET).fetch_or(CLOSED, Ordering::Relaxed); } /// Publishes a committed descriptor into an unfinished slot. diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs index 33c06cbba..650ac4d00 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs @@ -60,7 +60,7 @@ impl ShmWriter { /// /// # Panics /// - /// Panics when the region is not word-aligned or its size is outside the + /// Panics when the region is not `u64`-aligned or its size is outside the /// supported range (see [`SharedState::borrow`]). pub unsafe fn new(mem: M) -> Self { // Validate the region geometry eagerly so misuse fails at From f2ac0987aa920ece62d315736af61068ce6bd3f4 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sat, 15 Aug 2026 10:07:13 +0800 Subject: [PATCH 17/92] refactor(fspy-shm): fix the channel layout at compile time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The descriptor table's length becomes the protocol's const-generic parameter, so the header and table are one repr(C) struct — offsets become field accesses, the table a real array, and the per-accessor unsafe pointer derivations collapse into one borrow. The payload area stays outside the struct deliberately: writers hold exclusive borrows into it that must not alias the shared region borrow. The channel names its layout the same way: channel::() sizes the backing file to capacity_for_slots(SLOTS) (the exact inverse of the slots_for_capacity sizing rule), every process names one shared SHM_SLOTS constant, and a sender now rejects a region whose size disagrees with the layout instead of panicking inside geometry assertions. Co-Authored-By: Claude Fable 5 --- crates/fspy/src/ipc.rs | 13 +- crates/fspy/src/unix/mod.rs | 6 +- crates/fspy/src/windows/mod.rs | 9 +- crates/fspy_client_unix/src/lib.rs | 6 +- .../src/windows/client.rs | 6 +- crates/fspy_shared/src/ipc/channel/mod.rs | 84 +++++--- .../src/ipc/channel/shm_io/README.md | 21 +- .../src/ipc/channel/shm_io/layout.rs | 201 +++++++++--------- .../fspy_shared/src/ipc/channel/shm_io/mod.rs | 117 +++++----- .../src/ipc/channel/shm_io/reader.rs | 25 ++- .../src/ipc/channel/shm_io/state.rs | 201 ++++++++++-------- .../src/ipc/channel/shm_io/writer.rs | 24 +-- crates/fspy_shared/src/ipc/mod.rs | 10 + 13 files changed, 393 insertions(+), 330 deletions(-) diff --git a/crates/fspy/src/ipc.rs b/crates/fspy/src/ipc.rs index f2443954a..9708612fd 100644 --- a/crates/fspy/src/ipc.rs +++ b/crates/fspy/src/ipc.rs @@ -1,21 +1,16 @@ use std::io; use fspy_shared::ipc::{ - PathAccess, + PathAccess, SHM_SLOTS, channel::{Frames, Receiver}, }; -// Shared memory size for storing path accesses. -// 4 GiB is large enough to store path accesses in almost any realistic scenario. -// This doesn't allocate physical memory until it's actually used. -pub const SHM_CAPACITY: usize = 4 * 1024 * 1024 * 1024; - /// The path accesses a run reported through the IPC channel. pub struct ChannelAccesses { - frames: Frames, + frames: Frames, } -impl TryFrom for ChannelAccesses { +impl TryFrom> for ChannelAccesses { type Error = io::Error; /// Closes the channel and rejects traces that cannot back the run's @@ -30,7 +25,7 @@ impl TryFrom for ChannelAccesses { /// metadata was corrupted. Failing here — instead of returning a /// silently short trace — keeps the tracking result trustworthy for /// caching. - fn try_from(receiver: Receiver) -> io::Result { + fn try_from(receiver: Receiver) -> io::Result { let frames = receiver.close()?; if !frames.is_complete() { return Err(io::Error::new( diff --git a/crates/fspy/src/unix/mod.rs b/crates/fspy/src/unix/mod.rs index fe517b99c..b05b1ae05 100644 --- a/crates/fspy/src/unix/mod.rs +++ b/crates/fspy/src/unix/mod.rs @@ -10,7 +10,7 @@ use std::{io, path::Path}; use fspy_seccomp_unotify::supervisor::supervise; use fspy_shared::ipc::PathAccess; #[cfg(not(target_env = "musl"))] -use fspy_shared::ipc::{IpcStr, channel::channel}; +use fspy_shared::ipc::{IpcStr, SHM_SLOTS, channel::channel}; #[cfg(target_os = "macos")] use fspy_shared_unix::payload::Artifacts; use fspy_shared_unix::{ @@ -25,7 +25,7 @@ use tokio::task::spawn_blocking; use tokio_util::sync::CancellationToken; #[cfg(not(target_env = "musl"))] -use crate::ipc::{ChannelAccesses, SHM_CAPACITY}; +use crate::ipc::ChannelAccesses; use crate::{ChildTermination, Command, TrackedChild, arena::PathAccessArena, error::SpawnError}; #[derive(Debug)] @@ -80,7 +80,7 @@ impl SpyImpl { #[cfg(not(target_env = "musl"))] let (ipc_channel_conf, ipc_receiver) = - channel(SHM_CAPACITY).map_err(SpawnError::ChannelCreation)?; + channel::().map_err(SpawnError::ChannelCreation)?; let payload = Payload { #[cfg(not(target_env = "musl"))] diff --git a/crates/fspy/src/windows/mod.rs b/crates/fspy/src/windows/mod.rs index a79606e73..888be8c50 100644 --- a/crates/fspy/src/windows/mod.rs +++ b/crates/fspy/src/windows/mod.rs @@ -8,7 +8,7 @@ use std::{ use fspy_detours_sys::{DetourCopyPayloadToProcess, DetourUpdateProcessWithDll}; use fspy_shared::{ - ipc::{PathAccess, channel::channel}, + ipc::{PathAccess, SHM_SLOTS, channel::channel}, windows::{PAYLOAD_ID, Payload}, }; use futures_util::FutureExt; @@ -21,10 +21,7 @@ use winapi::{ use winsafe::co::{CP, WC}; use crate::{ - ChildTermination, TrackedChild, - command::Command, - error::SpawnError, - ipc::{ChannelAccesses, SHM_CAPACITY}, + ChildTermination, TrackedChild, command::Command, error::SpawnError, ipc::ChannelAccesses, }; const INTERPOSE_CDYLIB: Artifact = @@ -87,7 +84,7 @@ impl SpyImpl { command.creation_flags(CREATE_SUSPENDED); let (channel_conf, receiver) = - channel(SHM_CAPACITY).map_err(SpawnError::ChannelCreation)?; + channel::().map_err(SpawnError::ChannelCreation)?; let mut spawn_success = false; let spawn_success = &mut spawn_success; diff --git a/crates/fspy_client_unix/src/lib.rs b/crates/fspy_client_unix/src/lib.rs index f6814cf66..ff72c6e8a 100644 --- a/crates/fspy_client_unix/src/lib.rs +++ b/crates/fspy_client_unix/src/lib.rs @@ -11,7 +11,7 @@ pub mod raw_exec; use std::{ffi::OsStr, fmt::Debug, num::NonZeroUsize, os::unix::ffi::OsStrExt as _, path::Path}; use convert::{ToAbsolutePath, ToAccessMode}; -use fspy_shared::ipc::{PathAccess, channel::Sender}; +use fspy_shared::ipc::{PathAccess, SHM_SLOTS, channel::Sender}; use fspy_shared_unix::{ exec::ExecResolveConfig, payload::{EncodedPayload, decode_payload_from_env}, @@ -22,7 +22,7 @@ use wincode::Serialize as _; pub struct Client { encoded_payload: EncodedPayload, - ipc_sender: Option, + ipc_sender: Option>, } // SAFETY: construction owns every field, later methods borrow them immutably, @@ -53,7 +53,7 @@ impl Client { pub fn from_env(envs: impl Iterator) -> Self { let encoded_payload = decode_payload_from_env(envs).unwrap(); - let ipc_sender = match encoded_payload.payload.ipc_channel_conf.sender() { + let ipc_sender = match encoded_payload.payload.ipc_channel_conf.sender::() { Ok(sender) => Some(sender), Err(err) => { // This can happen if the process starts after the root target diff --git a/crates/fspy_preload_windows/src/windows/client.rs b/crates/fspy_preload_windows/src/windows/client.rs index cf8c076ed..1be48f300 100644 --- a/crates/fspy_preload_windows/src/windows/client.rs +++ b/crates/fspy_preload_windows/src/windows/client.rs @@ -2,21 +2,21 @@ use std::{cell::SyncUnsafeCell, ffi::CStr, mem::MaybeUninit}; use fspy_detours_sys::DetourCopyPayloadToProcess; use fspy_shared::{ - ipc::{PathAccess, channel::Sender}, + ipc::{PathAccess, SHM_SLOTS, channel::Sender}, windows::{PAYLOAD_ID, Payload}, }; use winapi::{shared::minwindef::BOOL, um::winnt::HANDLE}; pub struct Client<'a> { payload: Payload<'a>, - ipc_sender: Option, + ipc_sender: Option>, } impl<'a> Client<'a> { pub fn from_payload_bytes(payload_bytes: &'a [u8]) -> Self { let payload: Payload<'a> = wincode::deserialize_exact(payload_bytes).unwrap(); - let ipc_sender = match payload.channel_conf.sender() { + let ipc_sender = match payload.channel_conf.sender::() { Ok(sender) => Some(sender), Err(err) => { // this can happen if the process is started after the root target process has exited. diff --git a/crates/fspy_shared/src/ipc/channel/mod.rs b/crates/fspy_shared/src/ipc/channel/mod.rs index 8754515a8..e30f8fe4c 100644 --- a/crates/fspy_shared/src/ipc/channel/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/mod.rs @@ -14,11 +14,11 @@ use fspy_nostd::Fat; use fspy_nostd_alloc::OsCString; use fspy_shm::Mapping; use shm_io::ShmWriter; -pub use shm_io::{ClaimError, FrameMut, WriteEncodedError}; +pub use shm_io::{ClaimError, FrameMut, WriteEncodedError, capacity_for_slots, slots_for_capacity}; /// The committed frames of a closed channel; borrows the shared mapping, /// which stays alive (and mapped) until this value drops. -pub type Frames = shm_io::Frames; +pub type Frames = shm_io::Frames; use uuid::Uuid; use wincode::{SchemaRead, SchemaWrite}; @@ -38,12 +38,17 @@ pub struct ChannelConf { shm_id: Box, } -/// Creates a mpsc IPC channel with one receiver and a `ChannelConf` that can be passed around processes and used to create multiple senders +/// Creates a mpsc IPC channel with one receiver and a `ChannelConf` that can be passed around processes and used to create multiple senders. +/// +/// The channel's layout is fixed at compile time by `SLOTS`, the descriptor +/// table size (see [`slots_for_capacity`] to derive it from a byte budget); +/// the backing region is sized to [`capacity_for_slots`] of `SLOTS`. Every +/// process must name the same `SLOTS`. #[expect(clippy::missing_errors_doc, reason = "non-vt crate: cannot use vt_str/vt_path types")] -pub fn channel(capacity: usize) -> io::Result<(ChannelConf, Receiver)> { +pub fn channel() -> io::Result<(ChannelConf, Receiver)> { let shm_c_path = os_c_string(shm_backing_path()?.as_os_str())?; - let handle = - fspy_shm::create(shm_c_path.as_c_str().as_thin(), capacity).map_err(shm_error_to_io)?; + let handle = fspy_shm::create(shm_c_path.as_c_str().as_thin(), capacity_for_slots(SLOTS)) + .map_err(shm_error_to_io)?; // The keeper exists from here on, so every error path below cleans up. let keeper = ShmKeeper { path: shm_c_path }; let mapping = handle.map().map_err(shm_error_to_io)?; @@ -60,7 +65,7 @@ pub fn channel(capacity: usize) -> io::Result<(ChannelConf, Receiver)> { let _ = std::thread::Builder::new().name("fspy-shm-prefault".into()).spawn(move || { // SAFETY: the mapping views the region created zero-initialized // above, which is only accessed through the `shm_io` protocol. - unsafe { shm_io::pre_fault(&prefault_mapping) }; + unsafe { shm_io::pre_fault::(&prefault_mapping) }; }); } @@ -164,7 +169,7 @@ impl ChannelConf { clippy::missing_errors_doc, reason = "error conditions are self-evident from return type" )] - pub fn sender(&self) -> io::Result { + pub fn sender(&self) -> io::Result> { // The arena never touches the process heap, so this stays safe in // the preload contexts that create senders (pre-`main` constructors, // the Windows loader lock). @@ -176,9 +181,18 @@ impl ChannelConf { .map_err(shm_error_to_io)? .map() .map_err(shm_error_to_io)?; + // A truncated or foreign file must fail here, not panic the host + // process inside the protocol's geometry assertions. + if mapping.len() != capacity_for_slots(SLOTS) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "shared-memory region size does not match the channel layout", + )); + } // SAFETY: `mapping` is a freshly mapped shared memory region created // zero-initialized by `channel` and accessed only through the - // `shm_io` protocol by every attached process. + // `shm_io` protocol by every attached process, which names the same + // `SLOTS` via this method. let writer = unsafe { ShmWriter::new(mapping) }; if writer.is_closed() { return Err(io::Error::new( @@ -190,12 +204,12 @@ impl ChannelConf { } } -pub struct Sender { - writer: ShmWriter, +pub struct Sender { + writer: ShmWriter, } -impl Deref for Sender { - type Target = ShmWriter; +impl Deref for Sender { + type Target = ShmWriter; fn deref(&self) -> &Self::Target { &self.writer @@ -205,17 +219,17 @@ impl Deref for Sender { // SAFETY: `Sender` only accesses the shared mapping through the `shm_io` // protocol, which synchronizes concurrent writers and the receiver with // atomic operations; the mapping's address is stable and independently owned. -unsafe impl Send for Sender {} +unsafe impl Send for Sender {} // SAFETY: see the `Send` impl; `ShmWriter`'s shared-reference API is // internally synchronized by the protocol. -unsafe impl Sync for Sender {} +unsafe impl Sync for Sender {} /// The unique receiver side of an IPC channel. /// /// Holds the shared memory and its backing file alive for as long as senders /// may attach; [`Receiver::close`] (or dropping) removes the backing file. -pub struct Receiver { +pub struct Receiver { /// Keeps the shared memory's backing file alive for as long as senders /// may attach. _keeper: ShmKeeper, @@ -226,12 +240,12 @@ pub struct Receiver { // through the `shm_io` protocol in `close`, which synchronizes with senders // via atomic operations. The mapping's address is stable and independently // owned. -unsafe impl Send for Receiver {} +unsafe impl Send for Receiver {} // SAFETY: see the `Send` impl. -unsafe impl Sync for Receiver {} +unsafe impl Sync for Receiver {} -impl Receiver { +impl Receiver { /// Closes the channel and returns every committed frame, borrowed from /// the shared mapping that moves into the returned [`Frames`]. /// @@ -246,7 +260,7 @@ impl Receiver { /// /// Fails only when the shared-memory metadata was corrupted (a protocol /// impossibility for correct senders); the trace is then unusable. - pub fn close(self) -> io::Result { + pub fn close(self) -> io::Result> { let Self { _keeper: keeper, mapping } = self; // Remove the backing file first so no new process attaches while the // channel closes. @@ -269,17 +283,21 @@ mod tests { use super::*; + // Table sizes for the test channels: ~4 KiB and ~64 KiB regions. + const S_4K: usize = slots_for_capacity(4096); + const S_64K: usize = slots_for_capacity(64 * 1024); + /// The shared-memory path is generated absolute, so a sender in a process /// with a different working directory and a relative temporary directory /// must still attach. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn sender_ignores_changed_temp_and_working_directory() { - let (conf, receiver) = channel(4096).unwrap(); + let (conf, receiver) = channel::().unwrap(); let changed_cwd = temp_dir().join(format!("fspy-ipc-changed-cwd-{}", Uuid::new_v4())); fs::create_dir(&changed_cwd).unwrap(); let mut command = command_for_fn!(conf, |conf: ChannelConf| { - let sender = conf.sender().unwrap(); + let sender = conf.sender::().unwrap(); let frame_size = NonZeroUsize::new(2).unwrap(); let mut frame = sender.claim_frame(frame_size).unwrap(); frame.copy_from_slice(&[4, 2]); @@ -300,11 +318,9 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn smoke() { - // A deliberately odd, small capacity: the fixed partition must - // still yield a usable channel. - let (conf, receiver) = channel(1000).unwrap(); + let (conf, receiver) = channel::().unwrap(); let cmd = command_for_fn!(conf, |conf: ChannelConf| { - let sender = conf.sender().unwrap(); + let sender = conf.sender::().unwrap(); let frame_size = NonZeroUsize::new(2).unwrap(); let mut frame = sender.claim_frame(frame_size).unwrap(); frame.copy_from_slice(&[4, 2]); @@ -325,11 +341,11 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[expect(clippy::print_stdout, reason = "test diagnostics")] async fn forbid_new_senders_after_close() { - let (conf, receiver) = channel(4096).unwrap(); + let (conf, receiver) = channel::().unwrap(); let _frames = receiver.close().unwrap(); let cmd = command_for_fn!(conf, |conf: ChannelConf| { - print!("{}", conf.sender().is_ok()); + print!("{}", conf.sender::().is_ok()); }); let output = std::process::Command::from(cmd).output().unwrap(); assert!(B(&output.stdout) == B("false")); @@ -338,11 +354,11 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[expect(clippy::print_stdout, reason = "test diagnostics")] async fn forbid_new_senders_after_receiver_dropped() { - let (conf, receiver) = channel(4096).unwrap(); + let (conf, receiver) = channel::().unwrap(); drop(receiver); let cmd = command_for_fn!(conf, |conf: ChannelConf| { - print!("{}", conf.sender().is_ok()); + print!("{}", conf.sender::().is_ok()); }); let output = std::process::Command::from(cmd).output().unwrap(); assert!(B(&output.stdout) == B("false")); @@ -352,8 +368,8 @@ mod tests { /// claim any new frame afterwards. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn attached_sender_cannot_claim_after_close() { - let (conf, receiver) = channel(4096).unwrap(); - let sender = conf.sender().unwrap(); + let (conf, receiver) = channel::().unwrap(); + let sender = conf.sender::().unwrap(); let mut frame = sender.claim_frame(NonZeroUsize::new(2).unwrap()).unwrap(); frame.copy_from_slice(&[4, 2]); @@ -371,10 +387,10 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn concurrent_senders() { // 64 KiB: a 1023-slot table for the 200 frames sent below. - let (conf, receiver) = channel(64 * 1024).unwrap(); + let (conf, receiver) = channel::().unwrap(); for i in 0u16..200 { let cmd = command_for_fn!((conf.clone(), i), |(conf, i): (ChannelConf, u16)| { - let sender = conf.sender().unwrap(); + let sender = conf.sender::().unwrap(); let data_to_send = i.to_string(); let mut frame = sender.claim_frame(NonZeroUsize::new(data_to_send.len()).unwrap()).unwrap(); diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/README.md b/crates/fspy_shared/src/ipc/channel/shm_io/README.md index 4496cc48e..c18346a2b 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/README.md +++ b/crates/fspy_shared/src/ipc/channel/shm_io/README.md @@ -39,14 +39,17 @@ counts up: - the **payload counter** — how many payload bytes were ever reserved, including by failed claims. Only writers read it. -The table has one 8-byte slot per frame. For a 4 GiB region that is ~67 -million slots; the ~3.5 GiB payload region fits ~15–20 million records of -a few hundred bytes, so payload space runs out first. The split is fixed, -not a movable frontier, because fixed bounds are what make claiming -wait-free: each counter is checked against its own constant limit using -the value `fetch_add` returned, and overshooting a limit is harmless -because nothing ever locates data through a counter — descriptors are -self-describing. +The table has one 8-byte slot per frame, and its length is the protocol's +const-generic parameter (`SLOTS`): the header and table are one `repr(C)` +struct, and a creator sizes the backing region to `capacity_for_slots(SLOTS)` +(`slots_for_capacity` derives the parameter from a byte budget — an eighth +of the space). For a 4 GiB region that is ~67 million slots; the ~3.5 GiB +payload region fits ~15–20 million records of a few hundred bytes, so +payload space runs out first. The split is fixed, not a movable frontier, +because fixed bounds are what make claiming wait-free: each counter is +checked against its own constant limit using the value `fetch_add` +returned, and overshooting a limit is harmless because nothing ever +locates data through a counter — descriptors are self-describing. ## Writing a frame @@ -143,7 +146,7 @@ CLAIMED (slot 0) ---+ | `mod.rs` | Public surface (`ShmWriter`, `FrameMut`, `close`, `Frames`), the protocol overview docs, and the integration tests — they run against a mocked region and are miri-clean (`cargo miri test -p fspy_shared shm_io`). | | `layout.rs` | Pure geometry: header offsets, the table/payload split, span validation. Plain integer math, no pointers, no atomics. | | `slot.rs` | The descriptor codec: pack and unpack one slot value, classify it as unfinished, aborted, committed, or corrupt. Pure. | -| `state.rs` | The only module that touches shared memory. All atomics, every unsafe pointer derivation, and the three-rule memory-ordering contract live here. | +| `state.rs` | The only module that touches shared memory: the `repr(C)` region struct (header plus slot array), the single unsafe borrow of it, the raw payload pointer, and the three-rule memory-ordering contract. | | `writer.rs` | Claim, fill, finish. Owns the argument for why a claimed payload span is exclusively the writer's. | | `reader.rs` | Close (snapshot, gate, freeze, validate) and `Frames`. Owns the argument for why borrowing committed spans is sound. | diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs index 8b7f3228d..1a8185999 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs @@ -1,40 +1,24 @@ //! Pure geometry of the shared-memory region. //! -//! The mapping is divided into three fixed areas: +//! The region is divided into three fixed areas: //! //! ```text -//! | header | descriptor table (fixed capacity) | payloads (grow up) | +//! | header | descriptor table (SLOTS slots) | payloads (grow up) | //! ``` //! -//! The table gets an eighth of the space after the header (with a small -//! floor so tiny regions stay usable). An eighth is generous slack for -//! typical record shapes — one 8-byte descriptor per payload of a few -//! hundred bytes — and the region is sparse, so an oversized table costs -//! address space, not memory. +//! The header and table have compile-time layout and live in `state`'s +//! `repr(C)` region struct; this module holds the plain-integer arithmetic +//! around them: the table-sizing rule for a given capacity, payload +//! rounding, and payload-span validation. No pointers, no atomics. //! -//! Everything in this module is arithmetic on plain integers — no atomics, -//! no pointers, no shared state. Overflow safety follows from one bound -//! enforced at construction time: the mapping length never exceeds -//! [`MAX_MAPPING_LEN`], so all offsets fit the 32-bit descriptor fields and -//! all sums fit `usize` on the 64-bit targets the parent module asserts. - -/// Byte size of the region header: the slot counter, the incomplete flag, -/// and the payload counter, padded so the descriptor table starts off the -/// counters' cache line and there is room for future header fields, which -/// must start zeroed. -pub(super) const HEADER_LEN: usize = 64; - -/// Byte offset of the claim counter (one `u64`: the CLOSED gate bit and the -/// number of claims attempted). -pub(super) const SLOT_COUNTER_OFFSET: usize = 0; - -/// Byte offset of the incomplete flag (one `u64`: nonzero once a live -/// writer lost a record). -pub(super) const INCOMPLETE_OFFSET: usize = 8; +//! Overflow safety follows from one bound enforced at construction time: +//! the mapping length never exceeds [`MAX_MAPPING_LEN`], so all offsets fit +//! the 32-bit descriptor fields and all sums fit `usize` on the 64-bit +//! targets the parent module asserts. -/// Byte offset of the payload counter (one `u64`: payload bytes reserved, -/// including by failed claims — monotonic, never read by the receiver). -pub(super) const PAYLOAD_COUNTER_OFFSET: usize = 16; +/// Byte size of the region header. Kept in this type-free module for the +/// sizing arithmetic; `state` asserts it equals `size_of::
()`. +pub(super) const HEADER_LEN: usize = 64; /// Byte size of one descriptor slot. pub(super) const SLOT_LEN: usize = size_of::(); @@ -42,8 +26,7 @@ pub(super) const SLOT_LEN: usize = size_of::(); /// Maximum payload size of a single frame. /// /// Committed lengths are stored in the 31-bit length field of a descriptor, -/// which caps them at `i32::MAX` — the same frame-size limit as the previous -/// inline-header format. +/// which caps them at `i32::MAX`. pub(super) const MAX_PAYLOAD_LEN: usize = i32::MAX as usize; /// Maximum supported mapping size. @@ -52,6 +35,39 @@ pub(super) const MAX_PAYLOAD_LEN: usize = i32::MAX as usize; /// mapping must fit `u32` arithmetic. pub(super) const MAX_MAPPING_LEN: usize = 1 << 32; +/// The descriptor-table length for a region of `capacity` bytes: the +/// `SLOTS` parameter a creator should instantiate the protocol with. +/// +/// An eighth of the space for descriptors (floored at eight slots so tiny +/// regions stay usable) is generous slack for typical record shapes — one +/// 8-byte descriptor per payload of a few hundred bytes — and the region is +/// sparse, so an oversized table costs address space, not memory. +#[must_use] +pub const fn slots_for_capacity(capacity: usize) -> usize { + let available = capacity - HEADER_LEN; + let len = available / 8; + let len = if len < 8 * SLOT_LEN { 8 * SLOT_LEN } else { len }; + let len = if len > available { available } else { len }; + len / SLOT_LEN +} + +/// The byte capacity a creator should size the region to for a table of +/// `slots` descriptors. +/// +/// Covers the header, the table, and the payload region the sizing rule +/// implies (seven bytes of payload space per table byte — the exact +/// inverse of [`slots_for_capacity`] for eight or more slots). +#[must_use] +pub const fn capacity_for_slots(slots: usize) -> usize { + HEADER_LEN + slots * SLOT_LEN * 8 +} + +/// Byte offset where the payload region starts: right after the table. +/// `state` asserts it equals `size_of::>()`. +pub(super) const fn payload_base_for_slots(slots: usize) -> usize { + HEADER_LEN + slots * SLOT_LEN +} + /// Rounds a payload length up to a multiple of `size_of::()`. /// /// Payload reservations are whole `u64`s — combined with the `u64`-aligned @@ -63,31 +79,14 @@ pub(super) const fn reserved_payload_len(payload_len: usize) -> usize { payload_len.next_multiple_of(SLOT_LEN) } -/// Byte size of the descriptor table in a mapping of `mapping_len` bytes. -pub(super) const fn table_len(mapping_len: usize) -> usize { - let available = mapping_len - HEADER_LEN; - // An eighth for descriptors, floored at eight slots so small (test) - // regions hold a few frames, and never more than the available space. - let len = available / 8; - let len = if len < 8 * SLOT_LEN { 8 * SLOT_LEN } else { len }; - let len = if len > available { available } else { len }; - len - len % SLOT_LEN -} - -/// Number of descriptor slots in a mapping of `mapping_len` bytes. -pub(super) const fn max_slots(mapping_len: usize) -> usize { - table_len(mapping_len) / SLOT_LEN -} - -/// Byte offset where the payload region starts. `u64`-aligned. -pub(super) const fn payload_base(mapping_len: usize) -> usize { - HEADER_LEN + table_len(mapping_len) -} - -/// Byte size of the payload region. A multiple of `size_of::()`, so a +/// Byte size of the payload region of a `mapping_len`-byte mapping whose +/// payloads start at `payload_base`. A multiple of `size_of::()`, so a /// reservation of whole `u64`s inside it never reaches past `mapping_len`. -pub(super) const fn payload_region_len(mapping_len: usize) -> usize { - let len = mapping_len - payload_base(mapping_len); +pub(super) const fn payload_region_len(mapping_len: usize, payload_base: usize) -> usize { + if payload_base >= mapping_len { + return 0; + } + let len = mapping_len - payload_base; len - len % SLOT_LEN } @@ -108,9 +107,15 @@ pub(super) struct PayloadSpan { impl PayloadSpan { /// Validates a committed descriptor's payload range against the payload - /// region of a `mapping_len`-byte mapping. Returns `None` if the range - /// could not have been produced by a correct writer. - pub(super) const fn validate(mapping_len: usize, offset: usize, len: usize) -> Option { + /// region `[payload_base, payload_base + payload_region_len)` of a + /// `mapping_len`-byte mapping. Returns `None` if the range could not + /// have been produced by a correct writer. + pub(super) const fn validate( + mapping_len: usize, + payload_base: usize, + offset: usize, + len: usize, + ) -> Option { if len == 0 || len > MAX_PAYLOAD_LEN { return None; } @@ -120,11 +125,11 @@ impl PayloadSpan { if !offset.is_multiple_of(SLOT_LEN) { return None; } - let base = payload_base(mapping_len); // `offset` and `len` come from 32-bit descriptor fields, so these // sums cannot overflow `usize`. - if offset < base - || offset + reserved_payload_len(len) > base + payload_region_len(mapping_len) + if offset < payload_base + || offset + reserved_payload_len(len) + > payload_base + payload_region_len(mapping_len, payload_base) { return None; } @@ -148,59 +153,59 @@ mod tests { } #[test] - fn partition_is_aligned_and_disjoint() { - for mapping_len in [64, 100, 128, 1024, 4096, 1 << 20, MAX_MAPPING_LEN] { - let table = table_len(mapping_len); - let base = payload_base(mapping_len); - let region = payload_region_len(mapping_len); - assert!(table % SLOT_LEN == 0); - assert!(base % SLOT_LEN == 0); - assert!(region % SLOT_LEN == 0); - assert!(base == HEADER_LEN + table); - assert!(base + region <= mapping_len); - } + fn slots_for_capacity_gives_an_eighth_to_the_table() { + assert!(slots_for_capacity(1 << 20) == 16383); + // The 4 GiB production mapping: ~67M slots. + assert!(slots_for_capacity(MAX_MAPPING_LEN) == ((MAX_MAPPING_LEN - HEADER_LEN) / 8) / 8); } #[test] - fn partition_gives_an_eighth_to_the_table() { - assert!(table_len(1 << 20) == (1 << 20) / 8 - 8); - assert!(max_slots(1 << 20) == 16383); - // The 4 GiB production mapping: ~67M slots, ~3.4 GiB of payloads. - assert!(max_slots(MAX_MAPPING_LEN) == ((MAX_MAPPING_LEN - HEADER_LEN) / 8) / 8); + fn slots_for_capacity_floors_tiny_regions_at_eight_slots() { + assert!(slots_for_capacity(1024) == 15); + assert!(slots_for_capacity(256) == 8); + // Not enough space for the floor: the table takes what exists and + // payload capacity degrades to zero; claims fail gracefully. + assert!(slots_for_capacity(100) == 4); + assert!(slots_for_capacity(64) == 0); } #[test] - fn tiny_regions_floor_the_table_at_eight_slots() { - // Enough space: eight slots, remainder to payloads. - assert!(max_slots(1024) == 15); - assert!(max_slots(256) == 8); - // Not enough space for the floor: the table takes what exists and - // payload capacity degrades to zero; claims fail gracefully. - assert!(table_len(100) == 32); - assert!(payload_region_len(100) == 0); - assert!(table_len(64) == 0); - assert!(max_slots(64) == 0); + fn capacity_and_slots_round_trip() { + for slots in [8, 15, 1023, slots_for_capacity(MAX_MAPPING_LEN)] { + assert!(slots_for_capacity(capacity_for_slots(slots)) == slots); + } + // The production 4 GiB capacity round-trips exactly. + assert!(capacity_for_slots(slots_for_capacity(MAX_MAPPING_LEN)) == MAX_MAPPING_LEN); + } + + #[test] + fn payload_region_rounds_down_and_degrades_to_zero() { + assert!(payload_region_len(1024, 184) == 840); + assert!(payload_region_len(1000, 184) == 816); + // The base at or past the mapping: no payload space at all. + assert!(payload_region_len(100, 104) == 0); + assert!(payload_region_len(100, 100) == 0); } #[test] fn payload_span_validates_bounds() { let mapping_len = 1024; - let base = payload_base(mapping_len); - let region = payload_region_len(mapping_len); + let base = 184; + let region = payload_region_len(mapping_len, base); // A `u64`-aligned span at the region start. - assert!(PayloadSpan::validate(mapping_len, base, 8).is_some()); + assert!(PayloadSpan::validate(mapping_len, base, base, 8).is_some()); // Exact end of the region, with padding inside it. - assert!(PayloadSpan::validate(mapping_len, base + region - 8, 5).is_some()); + assert!(PayloadSpan::validate(mapping_len, base, base + region - 8, 5).is_some()); // Zero length is never committed. - assert!(PayloadSpan::validate(mapping_len, base, 0).is_none()); + assert!(PayloadSpan::validate(mapping_len, base, base, 0).is_none()); // Padded length may not cross the end of the region. - assert!(PayloadSpan::validate(mapping_len, base + region - 8, 9).is_none()); - // Payloads may not reach into the descriptor table. - assert!(PayloadSpan::validate(mapping_len, base - 8, 8).is_none()); - assert!(PayloadSpan::validate(mapping_len, HEADER_LEN, 8).is_none()); + assert!(PayloadSpan::validate(mapping_len, base, base + region - 8, 9).is_none()); + // Payloads may not reach into the descriptor table or header. + assert!(PayloadSpan::validate(mapping_len, base, base - 8, 8).is_none()); + assert!(PayloadSpan::validate(mapping_len, base, 0, 8).is_none()); // Unaligned offsets cannot come from a correct writer. - assert!(PayloadSpan::validate(mapping_len, base + 4, 4).is_none()); + assert!(PayloadSpan::validate(mapping_len, base, base + 4, 4).is_none()); // Oversized lengths are rejected before any arithmetic. - assert!(PayloadSpan::validate(mapping_len, base, MAX_PAYLOAD_LEN + 1).is_none()); + assert!(PayloadSpan::validate(mapping_len, base, base, MAX_PAYLOAD_LEN + 1).is_none()); } } diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs index a8ecf0133..17e1d192e 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs @@ -18,8 +18,11 @@ //! fixed descriptor table payloads grow up -> //! ``` //! -//! The header holds three monotonic `AtomicU64`s ([`state`]): a claim counter -//! carrying the CLOSED gate bit, a payload counter, and an incomplete flag. +//! The table length is the protocol's const-generic parameter (`SLOTS`), +//! so the header and table are one `repr(C)` struct ([`state`]); the +//! payload area stays untyped bytes. The header holds three monotonic +//! `AtomicU64`s: a claim counter carrying the CLOSED gate bit, a payload +//! counter, and an incomplete flag. //! A claim is two wait-free `fetch_add`s — one reserves payload bytes, one //! reserves a descriptor slot — validated against the fixed region bounds //! from the returned old values. Failed claims overshoot the counters @@ -73,6 +76,7 @@ mod writer; use std::ptr::slice_from_raw_parts_mut; use fspy_shm::Mapping; +pub use layout::{capacity_for_slots, slots_for_capacity}; pub use reader::{Frames, ProtocolError}; pub use writer::{ClaimError, FrameMut, ShmWriter, WriteEncodedError}; @@ -103,7 +107,9 @@ impl AsRawSlice for Mapping { /// /// Same contract as [`ShmWriter::new`]: the region must be stable and valid, /// zero-initialized at creation, and accessed only through this protocol. -pub unsafe fn close(mem: M) -> Result, ProtocolError> { +pub unsafe fn close( + mem: M, +) -> Result, ProtocolError> { // SAFETY: forwarded from this function's contract. unsafe { reader::close(mem) } } @@ -118,9 +124,9 @@ pub unsafe fn close(mem: M) -> Result, ProtocolError> { /// /// Same contract as [`ShmWriter::new`]. #[cfg(target_os = "linux")] -pub unsafe fn pre_fault(mem: &impl AsRawSlice) { +pub unsafe fn pre_fault(mem: &impl AsRawSlice) { // SAFETY: forwarded from this function's contract. - unsafe { state::SharedState::borrow(mem.as_raw_slice()) }.pre_fault(); + unsafe { state::SharedState::::borrow(mem.as_raw_slice()) }.pre_fault(); } #[cfg(test)] @@ -138,6 +144,13 @@ mod tests { use super::*; + // Table sizes matching each test's region size, via the sizing rule. + const S_1K: usize = slots_for_capacity(1024); + const S_16K: usize = slots_for_capacity(16 * 1024); + const S_64K: usize = slots_for_capacity(64 * 1024); + #[cfg(not(miri))] + const S_1M: usize = slots_for_capacity(1024 * 1024); + /// A mocked shared memory region for testing. /// /// To be testable for miri, the shared memory is allocated using `Arc` @@ -194,10 +207,10 @@ mod tests { } } - fn collect_frames(shm: &MockedShm) -> Frames { + fn collect_frames(shm: &MockedShm) -> Frames { // SAFETY: `MockedShm` provides a stable, zero-initialized allocation // accessed only through the protocol. - unsafe { close(shm.clone()) }.unwrap() + unsafe { close::<_, SLOTS>(shm.clone()) }.unwrap() } #[test] @@ -205,12 +218,12 @@ mod tests { let shm = MockedShm::alloc(1024); // SAFETY: `MockedShm::alloc` provides a valid, properly-sized, // zero-initialized allocation. - let writer = unsafe { ShmWriter::new(shm.clone()) }; + let writer = unsafe { ShmWriter::<_, S_1K>::new(shm.clone()) }; assert!(writer.try_write_frame(b"hello")); assert!(writer.try_write_frame(b"world")); assert!(writer.try_write_frame(b"this is a test")); - let frames = collect_frames(&shm); + let frames = collect_frames::(&shm); let mut iter = frames.iter(); assert!(iter.next().unwrap() == b"hello"); assert!(iter.next().unwrap() == b"world"); @@ -223,11 +236,11 @@ mod tests { fn zero_sized_frames_are_rejected() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer = unsafe { ShmWriter::new(shm.clone()) }; + let writer = unsafe { ShmWriter::<_, S_1K>::new(shm.clone()) }; assert!(writer.try_write_frame(b"hello")); assert!(!writer.try_write_frame(b"")); - let frames = collect_frames(&shm); + let frames = collect_frames::(&shm); let mut iter = frames.iter(); assert!(iter.next().unwrap() == b"hello"); assert!(iter.next() == None); @@ -237,11 +250,11 @@ mod tests { fn frame_spanning_many_u64s_roundtrips_exactly() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer = unsafe { ShmWriter::new(shm.clone()) }; + let writer = unsafe { ShmWriter::<_, S_1K>::new(shm.clone()) }; let pattern: Vec = (0..=99).collect(); assert!(writer.try_write_frame(&pattern)); - let frames = collect_frames(&shm); + let frames = collect_frames::(&shm); let mut iter = frames.iter(); assert!(iter.next().unwrap() == pattern.as_slice()); assert!(iter.next() == None); @@ -251,7 +264,7 @@ mod tests { fn oversized_frame_fails_and_marks_incomplete() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer = unsafe { ShmWriter::new(shm.clone()) }; + let writer = unsafe { ShmWriter::<_, S_1K>::new(shm.clone()) }; assert!(writer.try_write_frame(b"test")); @@ -265,7 +278,7 @@ mod tests { == ClaimError::Capacity ); - let frames = collect_frames(&shm); + let frames = collect_frames::(&shm); let mut iter = frames.iter(); assert!(iter.next().unwrap() == b"test"); assert!(iter.next() == None); @@ -277,7 +290,7 @@ mod tests { fn crash_after_claim_is_skipped() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer = unsafe { ShmWriter::new(shm.clone()) }; + let writer = unsafe { ShmWriter::<_, S_1K>::new(shm.clone()) }; assert!(writer.try_write_frame(b"foo")); // Simulate a crash right after claiming: no drop code runs. @@ -286,7 +299,7 @@ mod tests { assert!(writer.try_write_frame(b"bar")); - let frames = collect_frames(&shm); + let frames = collect_frames::(&shm); let mut iter = frames.iter(); assert!(iter.next().unwrap() == b"foo"); assert!(iter.next().unwrap() == b"bar"); @@ -299,7 +312,7 @@ mod tests { fn crash_during_partial_write_is_skipped() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer = unsafe { ShmWriter::new(shm.clone()) }; + let writer = unsafe { ShmWriter::<_, S_1K>::new(shm.clone()) }; assert!(writer.try_write_frame(b"foo")); // Simulate a crash during writing. @@ -309,7 +322,7 @@ mod tests { assert!(writer.try_write_frame(b"bar")); - let frames = collect_frames(&shm); + let frames = collect_frames::(&shm); let mut iter = frames.iter(); assert!(iter.next().unwrap() == b"foo"); assert!(iter.next().unwrap() == b"bar"); @@ -323,7 +336,7 @@ mod tests { // receiver from finding the valid frames around them. let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer = unsafe { ShmWriter::new(shm.clone()) }; + let writer = unsafe { ShmWriter::<_, S_1K>::new(shm.clone()) }; assert!(writer.try_write_frame(b"foo")); @@ -337,7 +350,7 @@ mod tests { assert!(writer.try_write_frame(b"bar")); - let frames = collect_frames(&shm); + let frames = collect_frames::(&shm); let mut iter = frames.iter(); assert!(iter.next().unwrap() == b"foo"); assert!(iter.next().unwrap() == b"bar"); @@ -349,14 +362,14 @@ mod tests { fn abandoned_frame_marks_the_channel_incomplete() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer = unsafe { ShmWriter::new(shm.clone()) }; + let writer = unsafe { ShmWriter::<_, S_1K>::new(shm.clone()) }; assert!(writer.try_write_frame(b"foo")); // A live writer dropping an unfinished frame abandons a record it // may still act on. drop(writer.claim_frame(5.try_into().unwrap()).unwrap()); - let frames = collect_frames(&shm); + let frames = collect_frames::(&shm); let mut iter = frames.iter(); assert!(iter.next().unwrap() == b"foo"); assert!(iter.next() == None); @@ -368,18 +381,18 @@ mod tests { fn pre_fault_does_not_disturb_protocol_state() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer = unsafe { ShmWriter::new(shm.clone()) }; + let writer = unsafe { ShmWriter::<_, S_1K>::new(shm.clone()) }; // On the untouched region, before any claim. // SAFETY: see `collect_frames`. - unsafe { pre_fault(&shm) }; + unsafe { pre_fault::(&shm) }; assert!(writer.try_write_frame(b"foo")); // Racing an already claimed region must change nothing either. // SAFETY: see `collect_frames`. - unsafe { pre_fault(&shm) }; + unsafe { pre_fault::(&shm) }; assert!(writer.try_write_frame(b"bar")); - let frames = collect_frames(&shm); + let frames = collect_frames::(&shm); let mut iter = frames.iter(); assert!(iter.next().unwrap() == b"foo"); assert!(iter.next().unwrap() == b"bar"); @@ -393,13 +406,13 @@ mod tests { // on the slot side while payload space remains. let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer = unsafe { ShmWriter::new(shm.clone()) }; + let writer = unsafe { ShmWriter::<_, S_1K>::new(shm.clone()) }; for _ in 0..15 { assert!(writer.try_write_frame(b"x")); } assert!(writer.claim_frame(1.try_into().unwrap()).unwrap_err() == ClaimError::Capacity); - let frames = collect_frames(&shm); + let frames = collect_frames::(&shm); assert!(frames.iter().count() == 15); assert!(!frames.is_complete()); } @@ -408,11 +421,11 @@ mod tests { fn claims_after_close_are_gated_without_poisoning() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer = unsafe { ShmWriter::new(shm.clone()) }; + let writer = unsafe { ShmWriter::<_, S_1K>::new(shm.clone()) }; assert!(writer.try_write_frame(b"foo")); assert!(!writer.is_closed()); - let frames = collect_frames(&shm); + let frames = collect_frames::(&shm); let mut iter = frames.iter(); assert!(iter.next().unwrap() == b"foo"); assert!(iter.next() == None); @@ -430,13 +443,13 @@ mod tests { fn commit_after_abort_publishes_nothing() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer = unsafe { ShmWriter::new(shm.clone()) }; + let writer = unsafe { ShmWriter::<_, S_1K>::new(shm.clone()) }; let mut frame = writer.claim_frame(5.try_into().unwrap()).unwrap(); frame.copy_from_slice(b"late!"); // The receiver closes while the frame is unfinished and aborts it. - let frames = collect_frames(&shm); + let frames = collect_frames::(&shm); assert!(frames.iter().count() == 0); assert!(frames.is_complete()); @@ -444,7 +457,7 @@ mod tests { // must not fire either, because the frame *was* explicitly finished. frame.finish(); - let frames = collect_frames(&shm); + let frames = collect_frames::(&shm); assert!(frames.iter().count() == 0); assert!(frames.is_complete()); } @@ -460,7 +473,7 @@ mod tests { // SAFETY: see `single_thread_basic`. The clone shares the // same backing memory, which is safe because the protocol // synchronizes concurrent access with atomics. - let writer = unsafe { ShmWriter::new(shm.clone()) }; + let writer = unsafe { ShmWriter::<_, S_16K>::new(shm.clone()) }; for _ in 0..10 { assert!(writer.try_write_frame(b"hello")); assert!(writer.try_write_frame(b"foo")); @@ -470,7 +483,7 @@ mod tests { } }); - let frames = collect_frames(&shm); + let frames = collect_frames::(&shm); let mut count = 0; for frame in frames.iter() { count += 1; @@ -485,7 +498,7 @@ mod tests { fn concurrent_exceeded_size() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer = unsafe { ShmWriter::new(shm.clone()) }; + let writer = unsafe { ShmWriter::<_, S_1K>::new(shm.clone()) }; thread::scope(|s| { for _ in 0..4 { s.spawn(|| { @@ -498,7 +511,7 @@ mod tests { } }); - let frames = collect_frames(&shm); + let frames = collect_frames::(&shm); let mut count = 0; for frame in frames.iter() { count += 1; @@ -521,7 +534,7 @@ mod tests { let writers = [(); 2].map(|()| { s.spawn(|| { // SAFETY: see `concurrent`. - let writer = unsafe { ShmWriter::new(shm.clone()) }; + let writer = unsafe { ShmWriter::<_, S_64K>::new(shm.clone()) }; barrier.wait(); let mut written = 0usize; // Bounded so the test terminates even if close is slow; @@ -542,7 +555,7 @@ mod tests { }); barrier.wait(); - let frames = collect_frames(&shm); + let frames = collect_frames::(&shm); let results = writers.map(|writer| writer.join().unwrap()); (frames, results) }); @@ -567,7 +580,7 @@ mod tests { fn corrupt_committed_descriptor_is_a_protocol_error() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer = unsafe { ShmWriter::new(shm.clone()) }; + let writer = unsafe { ShmWriter::<_, S_1K>::new(shm.clone()) }; assert!(writer.try_write_frame(b"hello")); // Point slot 0 at a span escaping the mapping. @@ -576,7 +589,7 @@ mod tests { shm.poke_u64(64, (bogus_len << 32) | bogus_offset); // SAFETY: see `collect_frames`. - let result = unsafe { close(shm) }; + let result = unsafe { close::<_, S_1K>(shm) }; assert!(result.unwrap_err() == ProtocolError::CorruptDescriptor { slot_index: 0 }); } @@ -584,7 +597,7 @@ mod tests { fn corrupt_aborted_descriptor_is_a_protocol_error() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer = unsafe { ShmWriter::new(shm.clone()) }; + let writer = unsafe { ShmWriter::<_, S_1K>::new(shm.clone()) }; assert!(writer.try_write_frame(b"hello")); // The aborted bit combined with payload bits is a value no protocol @@ -592,7 +605,7 @@ mod tests { shm.poke_u64(64, (1 << 63) | (8u64 << 32) | 8); // SAFETY: see `collect_frames`. - let result = unsafe { close(shm) }; + let result = unsafe { close::<_, S_1K>(shm) }; assert!(result.unwrap_err() == ProtocolError::CorruptDescriptor { slot_index: 0 }); } @@ -600,7 +613,7 @@ mod tests { fn overshot_claim_counter_clamps_to_the_table() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer = unsafe { ShmWriter::new(shm.clone()) }; + let writer = unsafe { ShmWriter::<_, S_1K>::new(shm.clone()) }; assert!(writer.try_write_frame(b"hello")); // A wildly inflated claim counter — mass claim failures or a foreign @@ -609,7 +622,7 @@ mod tests { // freeze as aborted. shm.poke_u64(0, (1 << 40) | 1); - let frames = collect_frames(&shm); + let frames = collect_frames::(&shm); let mut iter = frames.iter(); assert!(iter.next().unwrap() == b"hello"); assert!(iter.next() == None); @@ -637,7 +650,7 @@ mod tests { let result = std::panic::catch_unwind(|| { // SAFETY: Intentionally passing a misaligned pointer to test that // the geometry assertion correctly panics. - unsafe { ShmWriter::new(misaligned_shm) }; + unsafe { ShmWriter::<_, S_1K>::new(misaligned_shm) }; }); assert!(result.is_err(), "should panic on a misaligned region"); } @@ -678,7 +691,7 @@ mod tests { // SAFETY: `mapping` is a freshly mapped shared memory // region with a valid pointer and size; the protocol // synchronizes concurrent access. - let writer = unsafe { ShmWriter::new(mapping) }; + let writer = unsafe { ShmWriter::<_, S_1M>::new(mapping) }; for i in 0..FRAME_COUNT_EACH_CHILD { let frame_data = std::format!("{child_index} {i}"); assert!(writer.try_write_frame(frame_data.as_bytes())); @@ -696,7 +709,7 @@ mod tests { // SAFETY: the mapping is a valid shared-memory region created zeroed // and accessed only through the protocol. - let frames = unsafe { close(mapping) }.unwrap(); + let frames = unsafe { close::<_, S_1M>(mapping) }.unwrap(); assert!(frames.is_complete()); let collected = frames.iter().map(BStr::new).collect::>(); assert!(collected.len() == CHILD_COUNT * FRAME_COUNT_EACH_CHILD); @@ -734,7 +747,7 @@ mod tests { let c_path = crate::ipc::channel::os_c_string(std::ffi::OsStr::new(&shm_name)).unwrap(); let child_mapping = fspy_shm::open(c_path.as_c_str().as_thin()).unwrap().map().unwrap(); // SAFETY: see `real_shm_across_processes`. - let writer = unsafe { ShmWriter::new(child_mapping) }; + let writer = unsafe { ShmWriter::<_, S_1M>::new(child_mapping) }; let mut frame = writer.claim_frame(5.try_into().unwrap()).unwrap(); frame[..3].copy_from_slice(b"wor"); // Signal the parent that the frame is claimed and partially @@ -760,11 +773,11 @@ mod tests { // A surviving writer keeps working after the kill. // SAFETY: see `real_shm_across_processes`. - let writer = unsafe { ShmWriter::new(mapping) }; + let writer = unsafe { ShmWriter::<_, S_1M>::new(mapping) }; assert!(writer.try_write_frame(b"alive")); // SAFETY: see `real_shm_across_processes`. - let frames = unsafe { close(writer.into_memory()) }.unwrap(); + let frames = unsafe { close::<_, S_1M>(writer.into_memory()) }.unwrap(); let mut iter = frames.iter(); assert!(iter.next().unwrap() == b"alive"); assert!(iter.next() == None); diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs index 5429dbbb6..43c13f4c3 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs @@ -28,13 +28,13 @@ use super::{ /// The committed frames of a closed channel: validated spans borrowed from /// the mapping, which stays alive inside this value. Dropping it releases /// the mapping. -pub struct Frames { +pub struct Frames { mem: M, spans: Vec, complete: bool, } -impl Frames { +impl Frames { /// Iterates over the committed frames in claim order. pub fn iter(&self) -> impl Iterator { let base = self.mem.as_raw_slice().cast::().cast_const(); @@ -59,7 +59,7 @@ impl Frames { } } -impl fmt::Debug for Frames { +impl fmt::Debug for Frames { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("Frames") .field("frames", &self.spans.len()) @@ -96,14 +96,16 @@ pub enum ProtocolError { /// Panics when the region is not `u64`-aligned or its size is outside the /// supported range (see [`SharedState::borrow`]) — a broken caller, not /// corrupt shared data, which is reported as [`ProtocolError`] instead. -pub(super) unsafe fn close(mem: M) -> Result, ProtocolError> { +pub(super) unsafe fn close( + mem: M, +) -> Result, ProtocolError> { let spans; let complete; { // SAFETY: forwarded from this function's contract; the raw slice // stays valid while `mem` is borrowed here and beyond, since `mem` // moves into the returned `Frames`. - let state = unsafe { SharedState::borrow(mem.as_raw_slice()) }; + let state = unsafe { SharedState::::borrow(mem.as_raw_slice()) }; // The close boundary: claims at or before this snapshot are inside // it, later ones land in slots this pass never visits. The count is @@ -134,8 +136,8 @@ pub(super) unsafe fn close(mem: M) -> Result, ProtocolE Ok(Frames { mem, spans, complete }) } -fn freeze_committed_spans( - state: SharedState<'_>, +fn freeze_committed_spans( + state: SharedState<'_, SLOTS>, slot_count: usize, ) -> Result, ProtocolError> { let mut spans = Vec::new(); @@ -143,8 +145,13 @@ fn freeze_committed_spans( match slot::decode(state.freeze(slot_index)) { SlotState::Aborted => {} SlotState::Committed { payload_offset, payload_len } => { - let span = PayloadSpan::validate(state.mapping_len(), payload_offset, payload_len) - .ok_or(ProtocolError::CorruptDescriptor { slot_index })?; + let span = PayloadSpan::validate( + state.mapping_len(), + state.payload_base(), + payload_offset, + payload_len, + ) + .ok_or(ProtocolError::CorruptDescriptor { slot_index })?; spans.push(span); } // `freeze` only returns terminal values, so `Unfinished` is diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/state.rs b/crates/fspy_shared/src/ipc/channel/shm_io/state.rs index f1481c3e3..ca26d69b3 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/state.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/state.rs @@ -1,14 +1,16 @@ //! The only module that touches shared-memory bytes. //! //! [`SharedState`] wraps the raw mapping and exposes the protocol's atomic -//! operations. Every unsafe pointer derivation lives here, justified by the -//! construction contract of [`SharedState::borrow`]; no other module reads or -//! writes the mapping directly. +//! operations. The header and descriptor table are one `repr(C)` struct, +//! [`Region`], borrowed from the mapping base with a single unsafe cast in +//! [`SharedState::borrow`]; every access after that is a plain field access +//! or an index into the slot array. Only the untyped payload area — which +//! must stay outside the [`Region`] referent so writers' exclusive `&mut` +//! payload spans never alias it — is still reached through raw pointers. //! //! # Shared atomics //! -//! The header holds three independent monotonic `AtomicU64`s (offsets in -//! [`layout`]): +//! The header holds three independent monotonic `AtomicU64`s: //! //! - the **claim counter**: bit 63 is the CLOSED gate, the low bits count //! claims ever attempted. Claiming is one wait-free `fetch_add`; the @@ -30,14 +32,13 @@ //! value it reads (in the counter's modification order) are in the //! snapshot; later ones receive slot indices the receiver never visits. //! Claims publish no payload data, so `Relaxed` suffices throughout. -//! The CLOSED gate is set *after* collection (off the latency-sensitive -//! path): it only stops stragglers from working and allocating pages -//! forever; any claim admitted between the snapshot and the gate lands -//! beyond the snapshot and is never observed. The incomplete flag rides -//! the same rule: a writer sets it (`Relaxed` RMW) before performing the -//! operation whose record was lost, and the receiver re-reads it after -//! freezing; a flag the receiver misses therefore belongs to an operation -//! performed after the boundary. +//! The CLOSED gate only stops stragglers from claiming (and allocating +//! pages) forever; any claim admitted between the snapshot and the gate +//! lands beyond the snapshot and is never observed. The incomplete flag +//! rides the same rule: a writer sets it (`Relaxed` RMW) before +//! performing the operation whose record was lost, and the receiver +//! re-reads it after freezing; a flag the receiver misses therefore +//! belongs to an operation performed after the boundary. //! 2. **Writer commit** — the slot compare-and-swap uses `Release` //! ([`SharedState::commit`]): every payload write happens-before the //! committed descriptor becomes visible. @@ -57,6 +58,33 @@ use super::{layout, slot}; /// so no realistic claim volume can carry into the gate. const CLOSED: u64 = 1 << 63; +/// The region header: three protocol atomics, padded so the descriptor +/// table starts off their cache line and there is room for future header +/// fields, which must start zeroed. +#[repr(C)] +struct Header { + /// Bit 63 is the CLOSED gate; the low bits count claims ever attempted. + claims: AtomicU64, + /// Nonzero once a live writer lost a record. + incomplete: AtomicU64, + /// Payload bytes ever reserved, including by failed claims. + payload_reserved: AtomicU64, + _reserved: [u64; 5], +} + +const _: () = assert!(size_of::
() == layout::HEADER_LEN); +const _: () = assert!(align_of::
() == align_of::()); + +/// The compile-time-laid-out prefix of the region: the header and the +/// descriptor table. The payload area follows it in the mapping but is +/// deliberately not a field — writers hold exclusive `&mut` borrows into +/// it, which must not alias the shared `&Region` borrow. +#[repr(C)] +struct Region { + header: Header, + slots: [AtomicU64; SLOTS], +} + /// Why a claim was not admitted. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub(super) enum ReserveError { @@ -77,13 +105,21 @@ pub(super) struct Reservation { /// A borrowed view of the shared mapping with protocol-level operations. #[derive(Clone, Copy)] -pub(super) struct SharedState<'m> { +pub(super) struct SharedState<'m, const SLOTS: usize> { + region: &'m Region, base: *mut u8, len: usize, _mapping: PhantomData<&'m ()>, } -impl<'m> SharedState<'m> { +impl SharedState<'_, SLOTS> { + /// Byte offset where the payload region starts: right after the + /// descriptor table. `u64`-aligned by construction. + const PAYLOAD_BASE: usize = { + assert!(size_of::>() == layout::payload_base_for_slots(SLOTS)); + size_of::>() + }; + /// Borrows a shared mapping. /// /// # Safety @@ -91,62 +127,45 @@ impl<'m> SharedState<'m> { /// - `mem` must be valid for reads and writes for the lifetime `'m` and /// its address must be stable. /// - The memory must have been zero-initialized when the region was - /// created, and accessed only through this protocol since. - /// - Other processes may access the region concurrently, but only through - /// this protocol. + /// created, and accessed only through this protocol since, + /// instantiated with the same `SLOTS` by every process. /// /// # Panics /// /// Panics when the mapping cannot host the protocol at all: base not - /// `u64`-aligned, or length outside - /// `[layout::HEADER_LEN, layout::MAX_MAPPING_LEN]`. These indicate a - /// broken caller, not runtime data. + /// `u64`-aligned, too small for the header and table, or larger than + /// [`layout::MAX_MAPPING_LEN`]. These indicate a broken caller, not + /// runtime data. pub(super) unsafe fn borrow(mem: *mut [u8]) -> Self { let base = mem.cast::(); let len = mem.len(); - assert!(base.addr().is_multiple_of(align_of::())); - assert!((layout::HEADER_LEN..=layout::MAX_MAPPING_LEN).contains(&len)); - Self { base, len, _mapping: PhantomData } + assert!(base.addr().is_multiple_of(align_of::>())); + assert!((Self::PAYLOAD_BASE..=layout::MAX_MAPPING_LEN).contains(&len)); + // SAFETY: the region prefix `[base, base + size_of::())` is + // in bounds and aligned (both asserted above) and consists entirely + // of atomics zero-initialized at creation, so a shared borrow for + // `'m` is valid even while other threads and processes access the + // same memory — they do so through these same atomics. + let region = unsafe { &*base.cast::>() }; + Self { region, base, len, _mapping: PhantomData } } pub(super) const fn mapping_len(self) -> usize { self.len } - /// Number of descriptor slots the fixed table holds. - pub(super) const fn max_slots(self) -> usize { - layout::max_slots(self.len) - } - - /// A header atomic. `offset` must be one of the `layout` header offsets. - fn header_atomic(self, offset: usize) -> &'m AtomicU64 { - debug_assert!(offset < layout::HEADER_LEN); - // SAFETY: `borrow` checked that the mapping is `u64`-aligned and at - // least `HEADER_LEN` bytes, so every `u64`-aligned header offset is a - // valid, aligned `AtomicU64` for `'m`. - unsafe { AtomicU64::from_ptr(self.base.add(offset).cast()) } - } - - /// The descriptor slot at `index`. - /// - /// # Panics - /// - /// Panics when the slot lies outside the fixed table. Callers only pass - /// indices below an admitted (writer) or clamped (receiver) slot count, - /// so the assertion documents an invariant rather than guarding runtime - /// data. - fn slot_atomic(self, index: usize) -> &'m AtomicU64 { - assert!(index < self.max_slots()); - // SAFETY: the assertion keeps the slot inside the fixed table, which - // consists of `u64`-aligned slots after the aligned header. - unsafe { - AtomicU64::from_ptr(self.base.add(layout::HEADER_LEN + index * layout::SLOT_LEN).cast()) - } + /// Byte offset where the payload region starts. + #[expect( + clippy::unused_self, + reason = "reads an associated const; instance syntax keeps call sites uniform" + )] + pub(super) const fn payload_base(self) -> usize { + Self::PAYLOAD_BASE } /// Whether the CLOSED gate has been set. pub(super) fn is_closed(self) -> bool { - self.header_atomic(layout::SLOT_COUNTER_OFFSET).load(Ordering::Relaxed) & CLOSED != 0 + self.region.header.claims.load(Ordering::Relaxed) & CLOSED != 0 } /// Atomically reserves one descriptor slot and one payload span. @@ -162,34 +181,31 @@ impl<'m> SharedState<'m> { // slot. A failed reservation stays counted — overshoot is harmless // because the counter is not what locates payloads (descriptors are) // and a `u64` cannot realistically wrap. - let payload_start = self - .header_atomic(layout::PAYLOAD_COUNTER_OFFSET) - .fetch_add(reserved_len as u64, Ordering::Relaxed); + let payload_start = + self.region.header.payload_reserved.fetch_add(reserved_len as u64, Ordering::Relaxed); // Checked: a foreign scribble of the counter must fail the claim, // not wrap the bound into an out-of-bounds reservation. let payload_end = payload_start.checked_add(reserved_len as u64); - if payload_end.is_none_or(|end| end > layout::payload_region_len(self.len) as u64) { + let payload_region_len = layout::payload_region_len(self.len, Self::PAYLOAD_BASE); + if payload_end.is_none_or(|end| end > payload_region_len as u64) { return Err(ReserveError::Capacity); } - let claims = - self.header_atomic(layout::SLOT_COUNTER_OFFSET).fetch_add(1, Ordering::Relaxed); + let claims = self.region.header.claims.fetch_add(1, Ordering::Relaxed); if claims & CLOSED != 0 { return Err(ReserveError::Closed); } let slot_index = usize::try_from(claims).expect("claim count exceeds usize"); - if slot_index >= self.max_slots() { + if slot_index >= SLOTS { return Err(ReserveError::Capacity); } - // The capacity check bounded `payload_start` by the region length, - // which fits `usize`. - let payload_start = usize::try_from(payload_start).expect("bounded by the payload region"); Ok(Reservation { slot_index, - // In bounds: `payload_start + reserved_len` fits the region, and - // the region ends within the mapping (`layout`). - payload_offset: layout::payload_base(self.len) + payload_start, + // In bounds: `payload_start + reserved_len` fits the payload + // region, which ends within the mapping (`layout`). + payload_offset: Self::PAYLOAD_BASE + + usize::try_from(payload_start).expect("bounded by the payload region"), }) } @@ -198,26 +214,31 @@ impl<'m> SharedState<'m> { /// Must be called before the operation whose record was lost is /// performed (rule 1). pub(super) fn flag_incomplete(self) { - self.header_atomic(layout::INCOMPLETE_OFFSET).fetch_or(1, Ordering::Relaxed); + self.region.header.incomplete.fetch_or(1, Ordering::Relaxed); } /// Whether any live writer lost a record. Read after the freeze pass /// (rule 1). pub(super) fn is_incomplete(self) -> bool { - self.header_atomic(layout::INCOMPLETE_OFFSET).load(Ordering::Relaxed) != 0 + self.region.header.incomplete.load(Ordering::Relaxed) != 0 } /// Snapshots the number of admitted claims: the receiver's close /// boundary (rule 1). Clamped to the table capacity because failed /// claims overshoot the counter. pub(super) fn snapshot_claims(self) -> usize { - let claims = self.header_atomic(layout::SLOT_COUNTER_OFFSET).load(Ordering::Relaxed); - usize::try_from(claims & !CLOSED).unwrap_or(usize::MAX).min(self.max_slots()) + let claims = self.region.header.claims.load(Ordering::Relaxed); + usize::try_from(claims & !CLOSED).unwrap_or(usize::MAX).min(SLOTS) } - /// Forces the page holding the header counters to be materialized by - /// the operating system before anyone touches it on a latency-sensitive - /// path. + /// Sets the CLOSED gate so stragglers stop claiming. + pub(super) fn close_claims(self) { + self.region.header.claims.fetch_or(CLOSED, Ordering::Relaxed); + } + + /// Forces the page backing the header (and the table's first slots) to + /// be materialized by the operating system before anyone touches it on + /// a latency-sensitive path. /// /// A compare-exchange of zero with zero on the claim counter: on an /// untouched region it performs a real write — allocating the first @@ -231,22 +252,8 @@ impl<'m> SharedState<'m> { /// Only Linux channels use this: elsewhere the first touch is cheap. #[cfg(target_os = "linux")] pub(super) fn pre_fault(self) { - let _ = self.header_atomic(layout::SLOT_COUNTER_OFFSET).compare_exchange( - 0, - 0, - Ordering::Relaxed, - Ordering::Relaxed, - ); - } - - /// Sets the CLOSED gate so stragglers stop claiming. - /// - /// Not part of the close boundary (rule 1): run it after collection, - /// off the latency-sensitive path — on an otherwise-untouched region - /// this write materializes the counter page, which can cost - /// milliseconds of first-block allocation on journalling filesystems. - pub(super) fn close_claims(self) { - self.header_atomic(layout::SLOT_COUNTER_OFFSET).fetch_or(CLOSED, Ordering::Relaxed); + let _ = + self.region.header.claims.compare_exchange(0, 0, Ordering::Relaxed, Ordering::Relaxed); } /// Publishes a committed descriptor into an unfinished slot. @@ -254,18 +261,28 @@ impl<'m> SharedState<'m> { /// Returns false when the receiver aborted the slot first; the payload is /// then permanently unreachable and the writer must not touch it again /// either way. + /// + /// # Panics + /// + /// Panics when `slot_index` lies outside the table; callers only pass + /// indices of admitted reservations. pub(super) fn commit(self, slot_index: usize, descriptor: u64) -> bool { // Rule 2: `Release` orders every payload write before the descriptor. - self.slot_atomic(slot_index) + self.region.slots[slot_index] .compare_exchange(slot::UNFINISHED, descriptor, Ordering::Release, Ordering::Relaxed) .is_ok() } /// Freezes one slot during close and returns its terminal value: `ABORTED` /// when the receiver won the race, the committed descriptor otherwise. + /// + /// # Panics + /// + /// Panics when `slot_index` lies outside the table; the receiver only + /// passes indices below its clamped snapshot. pub(super) fn freeze(self, slot_index: usize) -> u64 { // Rule 3: `Acquire` on failure makes a committed payload visible. - match self.slot_atomic(slot_index).compare_exchange( + match self.region.slots[slot_index].compare_exchange( slot::UNFINISHED, slot::ABORTED, Ordering::AcqRel, diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs index 650ac4d00..cf8b25ce4 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs @@ -19,7 +19,7 @@ use super::{ /// reserved with atomic operations, filled in uniquely owned payload spans, /// and published with an atomic commit (see the module docs of /// [`super::state`]). -pub struct ShmWriter { +pub struct ShmWriter { mem: M, } @@ -48,7 +48,7 @@ pub enum WriteEncodedError { Claim(#[from] ClaimError), } -impl ShmWriter { +impl ShmWriter { /// Creates a writer backed by a shared-memory region. /// /// # Safety @@ -66,11 +66,11 @@ impl ShmWriter { // Validate the region geometry eagerly so misuse fails at // construction, not at the first claim. // SAFETY: forwarded from this function's contract. - let _ = unsafe { SharedState::borrow(mem.as_raw_slice()) }; + let _ = unsafe { SharedState::::borrow(mem.as_raw_slice()) }; Self { mem } } - fn state(&self) -> SharedState<'_> { + fn state(&self) -> SharedState<'_, SLOTS> { // SAFETY: `new` requires the region to stay valid and // protocol-governed for the writer's lifetime, and it validated the // geometry. @@ -87,7 +87,7 @@ impl ShmWriter { /// The frame is invisible to the receiver until [`FrameMut::finish`] /// commits it. Dropping the frame without finishing abandons the claim /// and marks the channel incomplete. - pub fn claim_frame(&self, frame_size: NonZeroUsize) -> Result, ClaimError> { + pub fn claim_frame(&self, frame_size: NonZeroUsize) -> Result, ClaimError> { let state = self.state(); let reservation = state.try_claim(frame_size.get()).map_err(|err| match err { ReserveError::Closed => ClaimError::Closed, @@ -184,14 +184,14 @@ impl ShmWriter { /// to perform the operation this record was meant to describe. A process /// that dies mid-frame runs no drop code and marks nothing — correctly so, /// since records are published before the recorded operation is performed. -pub struct FrameMut<'a> { - state: SharedState<'a>, +pub struct FrameMut<'a, const SLOTS: usize> { + state: SharedState<'a, SLOTS>, slot_index: usize, descriptor: u64, content: &'a mut [u8], } -impl std::fmt::Debug for FrameMut<'_> { +impl std::fmt::Debug for FrameMut<'_, SLOTS> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("FrameMut") .field("slot_index", &self.slot_index) @@ -200,7 +200,7 @@ impl std::fmt::Debug for FrameMut<'_> { } } -impl Deref for FrameMut<'_> { +impl Deref for FrameMut<'_, SLOTS> { type Target = [u8]; fn deref(&self) -> &Self::Target { @@ -208,13 +208,13 @@ impl Deref for FrameMut<'_> { } } -impl DerefMut for FrameMut<'_> { +impl DerefMut for FrameMut<'_, SLOTS> { fn deref_mut(&mut self) -> &mut Self::Target { self.content } } -impl FrameMut<'_> { +impl FrameMut<'_, SLOTS> { /// Commits the frame, making it visible to the receiver. /// /// If the receiver closed the channel and aborted this frame's slot @@ -226,7 +226,7 @@ impl FrameMut<'_> { } } -impl Drop for FrameMut<'_> { +impl Drop for FrameMut<'_, SLOTS> { fn drop(&mut self) { self.state.flag_incomplete(); } diff --git a/crates/fspy_shared/src/ipc/mod.rs b/crates/fspy_shared/src/ipc/mod.rs index 9c49e7371..306f81d81 100644 --- a/crates/fspy_shared/src/ipc/mod.rs +++ b/crates/fspy_shared/src/ipc/mod.rs @@ -3,6 +3,16 @@ pub mod channel; mod ipc_path; use std::fmt::Debug; +/// Descriptor-table size of the path-access channel, sized from a 4 GiB +/// region. +/// +/// Large enough to store path accesses in almost any realistic scenario, +/// and sparse, so no physical memory is used until it is actually written. +/// Every process on the channel — supervisor and preload clients — must +/// name this same table size. +#[cfg(not(target_env = "musl"))] +pub const SHM_SLOTS: usize = channel::slots_for_capacity(4 * 1024 * 1024 * 1024); + use bitflags::bitflags; pub use fspy_ipc_str::IpcStr; pub use ipc_path::IpcPath; From 7a4c9505a337a68b15aab3673fb8270d2d064466 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sat, 15 Aug 2026 10:32:55 +0800 Subject: [PATCH 18/92] refactor(fspy-shm): dynamic sizing with typed region views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The const-generic table size taxed every signature and call site, and it bought a property the mapping already provides: with the layout derived from the mapping length alone, the region is self-describing — writers and the receiver compute identical bounds from the size of the file they mapped, with no shared constant to agree on and no size handshake to get wrong. What the struct experiment taught survives: one unsafe borrow now builds three typed views — the repr(C) header, the descriptor table as a slice of atomics, and the raw payload area — so counters are named fields, slots are bounds-checked indexes, and only payload spans remain pointer arithmetic. Co-Authored-By: Claude Fable 5 --- crates/fspy/src/ipc.rs | 13 +- crates/fspy/src/unix/mod.rs | 6 +- crates/fspy/src/windows/mod.rs | 4 +- crates/fspy_client_unix/src/lib.rs | 6 +- .../src/windows/client.rs | 6 +- crates/fspy_shared/src/ipc/channel/mod.rs | 78 +++++----- .../src/ipc/channel/shm_io/README.md | 29 ++-- .../src/ipc/channel/shm_io/layout.rs | 121 +++++++--------- .../fspy_shared/src/ipc/channel/shm_io/mod.rs | 130 ++++++++--------- .../src/ipc/channel/shm_io/reader.rs | 25 ++-- .../src/ipc/channel/shm_io/state.rs | 136 +++++++++--------- .../src/ipc/channel/shm_io/writer.rs | 24 ++-- crates/fspy_shared/src/ipc/mod.rs | 10 -- 13 files changed, 272 insertions(+), 316 deletions(-) diff --git a/crates/fspy/src/ipc.rs b/crates/fspy/src/ipc.rs index 9708612fd..f2443954a 100644 --- a/crates/fspy/src/ipc.rs +++ b/crates/fspy/src/ipc.rs @@ -1,16 +1,21 @@ use std::io; use fspy_shared::ipc::{ - PathAccess, SHM_SLOTS, + PathAccess, channel::{Frames, Receiver}, }; +// Shared memory size for storing path accesses. +// 4 GiB is large enough to store path accesses in almost any realistic scenario. +// This doesn't allocate physical memory until it's actually used. +pub const SHM_CAPACITY: usize = 4 * 1024 * 1024 * 1024; + /// The path accesses a run reported through the IPC channel. pub struct ChannelAccesses { - frames: Frames, + frames: Frames, } -impl TryFrom> for ChannelAccesses { +impl TryFrom for ChannelAccesses { type Error = io::Error; /// Closes the channel and rejects traces that cannot back the run's @@ -25,7 +30,7 @@ impl TryFrom> for ChannelAccesses { /// metadata was corrupted. Failing here — instead of returning a /// silently short trace — keeps the tracking result trustworthy for /// caching. - fn try_from(receiver: Receiver) -> io::Result { + fn try_from(receiver: Receiver) -> io::Result { let frames = receiver.close()?; if !frames.is_complete() { return Err(io::Error::new( diff --git a/crates/fspy/src/unix/mod.rs b/crates/fspy/src/unix/mod.rs index b05b1ae05..fe517b99c 100644 --- a/crates/fspy/src/unix/mod.rs +++ b/crates/fspy/src/unix/mod.rs @@ -10,7 +10,7 @@ use std::{io, path::Path}; use fspy_seccomp_unotify::supervisor::supervise; use fspy_shared::ipc::PathAccess; #[cfg(not(target_env = "musl"))] -use fspy_shared::ipc::{IpcStr, SHM_SLOTS, channel::channel}; +use fspy_shared::ipc::{IpcStr, channel::channel}; #[cfg(target_os = "macos")] use fspy_shared_unix::payload::Artifacts; use fspy_shared_unix::{ @@ -25,7 +25,7 @@ use tokio::task::spawn_blocking; use tokio_util::sync::CancellationToken; #[cfg(not(target_env = "musl"))] -use crate::ipc::ChannelAccesses; +use crate::ipc::{ChannelAccesses, SHM_CAPACITY}; use crate::{ChildTermination, Command, TrackedChild, arena::PathAccessArena, error::SpawnError}; #[derive(Debug)] @@ -80,7 +80,7 @@ impl SpyImpl { #[cfg(not(target_env = "musl"))] let (ipc_channel_conf, ipc_receiver) = - channel::().map_err(SpawnError::ChannelCreation)?; + channel(SHM_CAPACITY).map_err(SpawnError::ChannelCreation)?; let payload = Payload { #[cfg(not(target_env = "musl"))] diff --git a/crates/fspy/src/windows/mod.rs b/crates/fspy/src/windows/mod.rs index 888be8c50..3ac99361e 100644 --- a/crates/fspy/src/windows/mod.rs +++ b/crates/fspy/src/windows/mod.rs @@ -8,7 +8,7 @@ use std::{ use fspy_detours_sys::{DetourCopyPayloadToProcess, DetourUpdateProcessWithDll}; use fspy_shared::{ - ipc::{PathAccess, SHM_SLOTS, channel::channel}, + ipc::{PathAccess, channel::channel}, windows::{PAYLOAD_ID, Payload}, }; use futures_util::FutureExt; @@ -84,7 +84,7 @@ impl SpyImpl { command.creation_flags(CREATE_SUSPENDED); let (channel_conf, receiver) = - channel::().map_err(SpawnError::ChannelCreation)?; + channel(SHM_CAPACITY).map_err(SpawnError::ChannelCreation)?; let mut spawn_success = false; let spawn_success = &mut spawn_success; diff --git a/crates/fspy_client_unix/src/lib.rs b/crates/fspy_client_unix/src/lib.rs index ff72c6e8a..f6814cf66 100644 --- a/crates/fspy_client_unix/src/lib.rs +++ b/crates/fspy_client_unix/src/lib.rs @@ -11,7 +11,7 @@ pub mod raw_exec; use std::{ffi::OsStr, fmt::Debug, num::NonZeroUsize, os::unix::ffi::OsStrExt as _, path::Path}; use convert::{ToAbsolutePath, ToAccessMode}; -use fspy_shared::ipc::{PathAccess, SHM_SLOTS, channel::Sender}; +use fspy_shared::ipc::{PathAccess, channel::Sender}; use fspy_shared_unix::{ exec::ExecResolveConfig, payload::{EncodedPayload, decode_payload_from_env}, @@ -22,7 +22,7 @@ use wincode::Serialize as _; pub struct Client { encoded_payload: EncodedPayload, - ipc_sender: Option>, + ipc_sender: Option, } // SAFETY: construction owns every field, later methods borrow them immutably, @@ -53,7 +53,7 @@ impl Client { pub fn from_env(envs: impl Iterator) -> Self { let encoded_payload = decode_payload_from_env(envs).unwrap(); - let ipc_sender = match encoded_payload.payload.ipc_channel_conf.sender::() { + let ipc_sender = match encoded_payload.payload.ipc_channel_conf.sender() { Ok(sender) => Some(sender), Err(err) => { // This can happen if the process starts after the root target diff --git a/crates/fspy_preload_windows/src/windows/client.rs b/crates/fspy_preload_windows/src/windows/client.rs index 1be48f300..cf8c076ed 100644 --- a/crates/fspy_preload_windows/src/windows/client.rs +++ b/crates/fspy_preload_windows/src/windows/client.rs @@ -2,21 +2,21 @@ use std::{cell::SyncUnsafeCell, ffi::CStr, mem::MaybeUninit}; use fspy_detours_sys::DetourCopyPayloadToProcess; use fspy_shared::{ - ipc::{PathAccess, SHM_SLOTS, channel::Sender}, + ipc::{PathAccess, channel::Sender}, windows::{PAYLOAD_ID, Payload}, }; use winapi::{shared::minwindef::BOOL, um::winnt::HANDLE}; pub struct Client<'a> { payload: Payload<'a>, - ipc_sender: Option>, + ipc_sender: Option, } impl<'a> Client<'a> { pub fn from_payload_bytes(payload_bytes: &'a [u8]) -> Self { let payload: Payload<'a> = wincode::deserialize_exact(payload_bytes).unwrap(); - let ipc_sender = match payload.channel_conf.sender::() { + let ipc_sender = match payload.channel_conf.sender() { Ok(sender) => Some(sender), Err(err) => { // this can happen if the process is started after the root target process has exited. diff --git a/crates/fspy_shared/src/ipc/channel/mod.rs b/crates/fspy_shared/src/ipc/channel/mod.rs index e30f8fe4c..b36fd1c0b 100644 --- a/crates/fspy_shared/src/ipc/channel/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/mod.rs @@ -14,11 +14,11 @@ use fspy_nostd::Fat; use fspy_nostd_alloc::OsCString; use fspy_shm::Mapping; use shm_io::ShmWriter; -pub use shm_io::{ClaimError, FrameMut, WriteEncodedError, capacity_for_slots, slots_for_capacity}; +pub use shm_io::{ClaimError, FrameMut, WriteEncodedError}; /// The committed frames of a closed channel; borrows the shared mapping, /// which stays alive (and mapped) until this value drops. -pub type Frames = shm_io::Frames; +pub type Frames = shm_io::Frames; use uuid::Uuid; use wincode::{SchemaRead, SchemaWrite}; @@ -40,15 +40,13 @@ pub struct ChannelConf { /// Creates a mpsc IPC channel with one receiver and a `ChannelConf` that can be passed around processes and used to create multiple senders. /// -/// The channel's layout is fixed at compile time by `SLOTS`, the descriptor -/// table size (see [`slots_for_capacity`] to derive it from a byte budget); -/// the backing region is sized to [`capacity_for_slots`] of `SLOTS`. Every -/// process must name the same `SLOTS`. +/// The channel's layout is derived from `capacity` alone, on both ends, so +/// senders need no configuration beyond the `ChannelConf`. #[expect(clippy::missing_errors_doc, reason = "non-vt crate: cannot use vt_str/vt_path types")] -pub fn channel() -> io::Result<(ChannelConf, Receiver)> { +pub fn channel(capacity: usize) -> io::Result<(ChannelConf, Receiver)> { let shm_c_path = os_c_string(shm_backing_path()?.as_os_str())?; - let handle = fspy_shm::create(shm_c_path.as_c_str().as_thin(), capacity_for_slots(SLOTS)) - .map_err(shm_error_to_io)?; + let handle = + fspy_shm::create(shm_c_path.as_c_str().as_thin(), capacity).map_err(shm_error_to_io)?; // The keeper exists from here on, so every error path below cleans up. let keeper = ShmKeeper { path: shm_c_path }; let mapping = handle.map().map_err(shm_error_to_io)?; @@ -65,7 +63,7 @@ pub fn channel() -> io::Result<(ChannelConf, Receiver let _ = std::thread::Builder::new().name("fspy-shm-prefault".into()).spawn(move || { // SAFETY: the mapping views the region created zero-initialized // above, which is only accessed through the `shm_io` protocol. - unsafe { shm_io::pre_fault::(&prefault_mapping) }; + unsafe { shm_io::pre_fault(&prefault_mapping) }; }); } @@ -169,7 +167,7 @@ impl ChannelConf { clippy::missing_errors_doc, reason = "error conditions are self-evident from return type" )] - pub fn sender(&self) -> io::Result> { + pub fn sender(&self) -> io::Result { // The arena never touches the process heap, so this stays safe in // the preload contexts that create senders (pre-`main` constructors, // the Windows loader lock). @@ -183,16 +181,16 @@ impl ChannelConf { .map_err(shm_error_to_io)?; // A truncated or foreign file must fail here, not panic the host // process inside the protocol's geometry assertions. - if mapping.len() != capacity_for_slots(SLOTS) { + if !shm_io::is_supported_region_len(mapping.len()) { return Err(io::Error::new( io::ErrorKind::InvalidData, - "shared-memory region size does not match the channel layout", + "shared-memory region size cannot host the channel", )); } // SAFETY: `mapping` is a freshly mapped shared memory region created // zero-initialized by `channel` and accessed only through the - // `shm_io` protocol by every attached process, which names the same - // `SLOTS` via this method. + // `shm_io` protocol by every attached process; the protocol derives + // one layout from the mapped size on every side. let writer = unsafe { ShmWriter::new(mapping) }; if writer.is_closed() { return Err(io::Error::new( @@ -204,12 +202,12 @@ impl ChannelConf { } } -pub struct Sender { - writer: ShmWriter, +pub struct Sender { + writer: ShmWriter, } -impl Deref for Sender { - type Target = ShmWriter; +impl Deref for Sender { + type Target = ShmWriter; fn deref(&self) -> &Self::Target { &self.writer @@ -219,17 +217,17 @@ impl Deref for Sender { // SAFETY: `Sender` only accesses the shared mapping through the `shm_io` // protocol, which synchronizes concurrent writers and the receiver with // atomic operations; the mapping's address is stable and independently owned. -unsafe impl Send for Sender {} +unsafe impl Send for Sender {} // SAFETY: see the `Send` impl; `ShmWriter`'s shared-reference API is // internally synchronized by the protocol. -unsafe impl Sync for Sender {} +unsafe impl Sync for Sender {} /// The unique receiver side of an IPC channel. /// /// Holds the shared memory and its backing file alive for as long as senders /// may attach; [`Receiver::close`] (or dropping) removes the backing file. -pub struct Receiver { +pub struct Receiver { /// Keeps the shared memory's backing file alive for as long as senders /// may attach. _keeper: ShmKeeper, @@ -240,12 +238,12 @@ pub struct Receiver { // through the `shm_io` protocol in `close`, which synchronizes with senders // via atomic operations. The mapping's address is stable and independently // owned. -unsafe impl Send for Receiver {} +unsafe impl Send for Receiver {} // SAFETY: see the `Send` impl. -unsafe impl Sync for Receiver {} +unsafe impl Sync for Receiver {} -impl Receiver { +impl Receiver { /// Closes the channel and returns every committed frame, borrowed from /// the shared mapping that moves into the returned [`Frames`]. /// @@ -260,7 +258,7 @@ impl Receiver { /// /// Fails only when the shared-memory metadata was corrupted (a protocol /// impossibility for correct senders); the trace is then unusable. - pub fn close(self) -> io::Result> { + pub fn close(self) -> io::Result { let Self { _keeper: keeper, mapping } = self; // Remove the backing file first so no new process attaches while the // channel closes. @@ -283,21 +281,17 @@ mod tests { use super::*; - // Table sizes for the test channels: ~4 KiB and ~64 KiB regions. - const S_4K: usize = slots_for_capacity(4096); - const S_64K: usize = slots_for_capacity(64 * 1024); - /// The shared-memory path is generated absolute, so a sender in a process /// with a different working directory and a relative temporary directory /// must still attach. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn sender_ignores_changed_temp_and_working_directory() { - let (conf, receiver) = channel::().unwrap(); + let (conf, receiver) = channel(4096).unwrap(); let changed_cwd = temp_dir().join(format!("fspy-ipc-changed-cwd-{}", Uuid::new_v4())); fs::create_dir(&changed_cwd).unwrap(); let mut command = command_for_fn!(conf, |conf: ChannelConf| { - let sender = conf.sender::().unwrap(); + let sender = conf.sender().unwrap(); let frame_size = NonZeroUsize::new(2).unwrap(); let mut frame = sender.claim_frame(frame_size).unwrap(); frame.copy_from_slice(&[4, 2]); @@ -318,9 +312,9 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn smoke() { - let (conf, receiver) = channel::().unwrap(); + let (conf, receiver) = channel(4096).unwrap(); let cmd = command_for_fn!(conf, |conf: ChannelConf| { - let sender = conf.sender::().unwrap(); + let sender = conf.sender().unwrap(); let frame_size = NonZeroUsize::new(2).unwrap(); let mut frame = sender.claim_frame(frame_size).unwrap(); frame.copy_from_slice(&[4, 2]); @@ -341,11 +335,11 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[expect(clippy::print_stdout, reason = "test diagnostics")] async fn forbid_new_senders_after_close() { - let (conf, receiver) = channel::().unwrap(); + let (conf, receiver) = channel(4096).unwrap(); let _frames = receiver.close().unwrap(); let cmd = command_for_fn!(conf, |conf: ChannelConf| { - print!("{}", conf.sender::().is_ok()); + print!("{}", conf.sender().is_ok()); }); let output = std::process::Command::from(cmd).output().unwrap(); assert!(B(&output.stdout) == B("false")); @@ -354,11 +348,11 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[expect(clippy::print_stdout, reason = "test diagnostics")] async fn forbid_new_senders_after_receiver_dropped() { - let (conf, receiver) = channel::().unwrap(); + let (conf, receiver) = channel(4096).unwrap(); drop(receiver); let cmd = command_for_fn!(conf, |conf: ChannelConf| { - print!("{}", conf.sender::().is_ok()); + print!("{}", conf.sender().is_ok()); }); let output = std::process::Command::from(cmd).output().unwrap(); assert!(B(&output.stdout) == B("false")); @@ -368,8 +362,8 @@ mod tests { /// claim any new frame afterwards. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn attached_sender_cannot_claim_after_close() { - let (conf, receiver) = channel::().unwrap(); - let sender = conf.sender::().unwrap(); + let (conf, receiver) = channel(4096).unwrap(); + let sender = conf.sender().unwrap(); let mut frame = sender.claim_frame(NonZeroUsize::new(2).unwrap()).unwrap(); frame.copy_from_slice(&[4, 2]); @@ -387,10 +381,10 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn concurrent_senders() { // 64 KiB: a 1023-slot table for the 200 frames sent below. - let (conf, receiver) = channel::().unwrap(); + let (conf, receiver) = channel(64 * 1024).unwrap(); for i in 0u16..200 { let cmd = command_for_fn!((conf.clone(), i), |(conf, i): (ChannelConf, u16)| { - let sender = conf.sender::().unwrap(); + let sender = conf.sender().unwrap(); let data_to_send = i.to_string(); let mut frame = sender.claim_frame(NonZeroUsize::new(data_to_send.len()).unwrap()).unwrap(); diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/README.md b/crates/fspy_shared/src/ipc/channel/shm_io/README.md index c18346a2b..d981b983a 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/README.md +++ b/crates/fspy_shared/src/ipc/channel/shm_io/README.md @@ -39,13 +39,12 @@ counts up: - the **payload counter** — how many payload bytes were ever reserved, including by failed claims. Only writers read it. -The table has one 8-byte slot per frame, and its length is the protocol's -const-generic parameter (`SLOTS`): the header and table are one `repr(C)` -struct, and a creator sizes the backing region to `capacity_for_slots(SLOTS)` -(`slots_for_capacity` derives the parameter from a byte budget — an eighth -of the space). For a 4 GiB region that is ~67 million slots; the ~3.5 GiB -payload region fits ~15–20 million records of a few hundred bytes, so -payload space runs out first. The split is fixed, not a movable frontier, +The table has one 8-byte slot per frame — an eighth of the region. Every +bound is derived from the mapping length alone, so the region is +self-describing: writers and the receiver compute the same layout from the +size of the file they mapped, with nothing else to agree on. For a 4 GiB +region that is ~67 million slots; the ~3.5 GiB payload region fits ~15–20 +million records of a few hundred bytes, so payload space runs out first. The split is fixed, not a movable frontier, because fixed bounds are what make claiming wait-free: each counter is checked against its own constant limit using the value `fetch_add` returned, and overshooting a limit is harmless because nothing ever @@ -141,14 +140,14 @@ CLAIMED (slot 0) ---+ ## Files -| File | Role | -| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `mod.rs` | Public surface (`ShmWriter`, `FrameMut`, `close`, `Frames`), the protocol overview docs, and the integration tests — they run against a mocked region and are miri-clean (`cargo miri test -p fspy_shared shm_io`). | -| `layout.rs` | Pure geometry: header offsets, the table/payload split, span validation. Plain integer math, no pointers, no atomics. | -| `slot.rs` | The descriptor codec: pack and unpack one slot value, classify it as unfinished, aborted, committed, or corrupt. Pure. | -| `state.rs` | The only module that touches shared memory: the `repr(C)` region struct (header plus slot array), the single unsafe borrow of it, the raw payload pointer, and the three-rule memory-ordering contract. | -| `writer.rs` | Claim, fill, finish. Owns the argument for why a claimed payload span is exclusively the writer's. | -| `reader.rs` | Close (snapshot, gate, freeze, validate) and `Frames`. Owns the argument for why borrowing committed spans is sound. | +| File | Role | +| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `mod.rs` | Public surface (`ShmWriter`, `FrameMut`, `close`, `Frames`), the protocol overview docs, and the integration tests — they run against a mocked region and are miri-clean (`cargo miri test -p fspy_shared shm_io`). | +| `layout.rs` | Pure geometry: header offsets, the table/payload split, span validation. Plain integer math, no pointers, no atomics. | +| `slot.rs` | The descriptor codec: pack and unpack one slot value, classify it as unfinished, aborted, committed, or corrupt. Pure. | +| `state.rs` | The only module that touches shared memory: one unsafe borrow builds three typed views — the `repr(C)` header struct, the descriptor table as a slice of atomics, and the raw payload area — and the three-rule memory-ordering contract lives here. | +| `writer.rs` | Claim, fill, finish. Owns the argument for why a claimed payload span is exclusively the writer's. | +| `reader.rs` | Close (snapshot, gate, freeze, validate) and `Frames`. Owns the argument for why borrowing committed spans is sound. | Each file carries one self-contained argument, so the protocol can be reviewed module by module: the pure math first, then the atomics, then the diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs index 1a8185999..6ff3b86cb 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs @@ -35,37 +35,27 @@ pub(super) const MAX_PAYLOAD_LEN: usize = i32::MAX as usize; /// mapping must fit `u32` arithmetic. pub(super) const MAX_MAPPING_LEN: usize = 1 << 32; -/// The descriptor-table length for a region of `capacity` bytes: the -/// `SLOTS` parameter a creator should instantiate the protocol with. +/// The descriptor-table length of a `mapping_len`-byte region. /// -/// An eighth of the space for descriptors (floored at eight slots so tiny -/// regions stay usable) is generous slack for typical record shapes — one -/// 8-byte descriptor per payload of a few hundred bytes — and the region is -/// sparse, so an oversized table costs address space, not memory. -#[must_use] -pub const fn slots_for_capacity(capacity: usize) -> usize { - let available = capacity - HEADER_LEN; +/// Both endpoints derive the layout from the mapping length alone, so the +/// region is self-describing: no side channel has to agree on a table +/// size. An eighth of the space for descriptors (floored at eight slots so +/// tiny regions stay usable) is generous slack for typical record shapes — +/// one 8-byte descriptor per payload of a few hundred bytes — and the +/// region is sparse, so an oversized table costs address space, not +/// memory. +pub(super) const fn max_slots(mapping_len: usize) -> usize { + let available = mapping_len - HEADER_LEN; let len = available / 8; let len = if len < 8 * SLOT_LEN { 8 * SLOT_LEN } else { len }; let len = if len > available { available } else { len }; len / SLOT_LEN } -/// The byte capacity a creator should size the region to for a table of -/// `slots` descriptors. -/// -/// Covers the header, the table, and the payload region the sizing rule -/// implies (seven bytes of payload space per table byte — the exact -/// inverse of [`slots_for_capacity`] for eight or more slots). -#[must_use] -pub const fn capacity_for_slots(slots: usize) -> usize { - HEADER_LEN + slots * SLOT_LEN * 8 -} - /// Byte offset where the payload region starts: right after the table. -/// `state` asserts it equals `size_of::>()`. -pub(super) const fn payload_base_for_slots(slots: usize) -> usize { - HEADER_LEN + slots * SLOT_LEN +/// `u64`-aligned. +pub(super) const fn payload_base(mapping_len: usize) -> usize { + HEADER_LEN + max_slots(mapping_len) * SLOT_LEN } /// Rounds a payload length up to a multiple of `size_of::()`. @@ -79,14 +69,15 @@ pub(super) const fn reserved_payload_len(payload_len: usize) -> usize { payload_len.next_multiple_of(SLOT_LEN) } -/// Byte size of the payload region of a `mapping_len`-byte mapping whose -/// payloads start at `payload_base`. A multiple of `size_of::()`, so a -/// reservation of whole `u64`s inside it never reaches past `mapping_len`. -pub(super) const fn payload_region_len(mapping_len: usize, payload_base: usize) -> usize { - if payload_base >= mapping_len { +/// Byte size of the payload region of a `mapping_len`-byte mapping. A +/// multiple of `size_of::()`, so a reservation of whole `u64`s inside +/// it never reaches past `mapping_len`. +pub(super) const fn payload_region_len(mapping_len: usize) -> usize { + let base = payload_base(mapping_len); + if base >= mapping_len { return 0; } - let len = mapping_len - payload_base; + let len = mapping_len - base; len - len % SLOT_LEN } @@ -107,15 +98,9 @@ pub(super) struct PayloadSpan { impl PayloadSpan { /// Validates a committed descriptor's payload range against the payload - /// region `[payload_base, payload_base + payload_region_len)` of a - /// `mapping_len`-byte mapping. Returns `None` if the range could not - /// have been produced by a correct writer. - pub(super) const fn validate( - mapping_len: usize, - payload_base: usize, - offset: usize, - len: usize, - ) -> Option { + /// region of a `mapping_len`-byte mapping. Returns `None` if the range + /// could not have been produced by a correct writer. + pub(super) const fn validate(mapping_len: usize, offset: usize, len: usize) -> Option { if len == 0 || len > MAX_PAYLOAD_LEN { return None; } @@ -125,11 +110,11 @@ impl PayloadSpan { if !offset.is_multiple_of(SLOT_LEN) { return None; } + let base = payload_base(mapping_len); // `offset` and `len` come from 32-bit descriptor fields, so these // sums cannot overflow `usize`. - if offset < payload_base - || offset + reserved_payload_len(len) - > payload_base + payload_region_len(mapping_len, payload_base) + if offset < base + || offset + reserved_payload_len(len) > base + payload_region_len(mapping_len) { return None; } @@ -153,59 +138,51 @@ mod tests { } #[test] - fn slots_for_capacity_gives_an_eighth_to_the_table() { - assert!(slots_for_capacity(1 << 20) == 16383); + fn max_slots_gives_an_eighth_to_the_table() { + assert!(max_slots(1 << 20) == 16383); // The 4 GiB production mapping: ~67M slots. - assert!(slots_for_capacity(MAX_MAPPING_LEN) == ((MAX_MAPPING_LEN - HEADER_LEN) / 8) / 8); + assert!(max_slots(MAX_MAPPING_LEN) == ((MAX_MAPPING_LEN - HEADER_LEN) / 8) / 8); } #[test] - fn slots_for_capacity_floors_tiny_regions_at_eight_slots() { - assert!(slots_for_capacity(1024) == 15); - assert!(slots_for_capacity(256) == 8); + fn max_slots_floors_tiny_regions_at_eight_slots() { + assert!(max_slots(1024) == 15); + assert!(max_slots(256) == 8); // Not enough space for the floor: the table takes what exists and // payload capacity degrades to zero; claims fail gracefully. - assert!(slots_for_capacity(100) == 4); - assert!(slots_for_capacity(64) == 0); - } - - #[test] - fn capacity_and_slots_round_trip() { - for slots in [8, 15, 1023, slots_for_capacity(MAX_MAPPING_LEN)] { - assert!(slots_for_capacity(capacity_for_slots(slots)) == slots); - } - // The production 4 GiB capacity round-trips exactly. - assert!(capacity_for_slots(slots_for_capacity(MAX_MAPPING_LEN)) == MAX_MAPPING_LEN); + assert!(max_slots(100) == 4); + assert!(max_slots(64) == 0); } #[test] fn payload_region_rounds_down_and_degrades_to_zero() { - assert!(payload_region_len(1024, 184) == 840); - assert!(payload_region_len(1000, 184) == 816); + assert!(payload_base(1024) == 184); + assert!(payload_region_len(1024) == 840); + assert!(payload_region_len(1000) == 824); // The base at or past the mapping: no payload space at all. - assert!(payload_region_len(100, 104) == 0); - assert!(payload_region_len(100, 100) == 0); + assert!(payload_region_len(100) == 0); + assert!(payload_region_len(64) == 0); } #[test] fn payload_span_validates_bounds() { let mapping_len = 1024; - let base = 184; - let region = payload_region_len(mapping_len, base); + let base = payload_base(mapping_len); + let region = payload_region_len(mapping_len); // A `u64`-aligned span at the region start. - assert!(PayloadSpan::validate(mapping_len, base, base, 8).is_some()); + assert!(PayloadSpan::validate(mapping_len, base, 8).is_some()); // Exact end of the region, with padding inside it. - assert!(PayloadSpan::validate(mapping_len, base, base + region - 8, 5).is_some()); + assert!(PayloadSpan::validate(mapping_len, base + region - 8, 5).is_some()); // Zero length is never committed. - assert!(PayloadSpan::validate(mapping_len, base, base, 0).is_none()); + assert!(PayloadSpan::validate(mapping_len, base, 0).is_none()); // Padded length may not cross the end of the region. - assert!(PayloadSpan::validate(mapping_len, base, base + region - 8, 9).is_none()); + assert!(PayloadSpan::validate(mapping_len, base + region - 8, 9).is_none()); // Payloads may not reach into the descriptor table or header. - assert!(PayloadSpan::validate(mapping_len, base, base - 8, 8).is_none()); - assert!(PayloadSpan::validate(mapping_len, base, 0, 8).is_none()); + assert!(PayloadSpan::validate(mapping_len, base - 8, 8).is_none()); + assert!(PayloadSpan::validate(mapping_len, 0, 8).is_none()); // Unaligned offsets cannot come from a correct writer. - assert!(PayloadSpan::validate(mapping_len, base, base + 4, 4).is_none()); + assert!(PayloadSpan::validate(mapping_len, base + 4, 4).is_none()); // Oversized lengths are rejected before any arithmetic. - assert!(PayloadSpan::validate(mapping_len, base, base, MAX_PAYLOAD_LEN + 1).is_none()); + assert!(PayloadSpan::validate(mapping_len, base, MAX_PAYLOAD_LEN + 1).is_none()); } } diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs index 17e1d192e..0ddd69dab 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs @@ -18,11 +18,13 @@ //! fixed descriptor table payloads grow up -> //! ``` //! -//! The table length is the protocol's const-generic parameter (`SLOTS`), -//! so the header and table are one `repr(C)` struct ([`state`]); the -//! payload area stays untyped bytes. The header holds three monotonic +//! The layout is derived from the mapping length alone ([`layout`]), so +//! the region is self-describing: every process computes the same table +//! and payload bounds from the mapped size. One borrow constructs typed +//! views of the header (a `repr(C)` struct of three monotonic //! `AtomicU64`s: a claim counter carrying the CLOSED gate bit, a payload -//! counter, and an incomplete flag. +//! counter, and an incomplete flag) and of the descriptor table (a slice +//! of atomics); the payload area stays untyped bytes. //! A claim is two wait-free `fetch_add`s — one reserves payload bytes, one //! reserves a descriptor slot — validated against the fixed region bounds //! from the returned old values. Failed claims overshoot the counters @@ -76,7 +78,6 @@ mod writer; use std::ptr::slice_from_raw_parts_mut; use fspy_shm::Mapping; -pub use layout::{capacity_for_slots, slots_for_capacity}; pub use reader::{Frames, ProtocolError}; pub use writer::{ClaimError, FrameMut, ShmWriter, WriteEncodedError}; @@ -107,9 +108,7 @@ impl AsRawSlice for Mapping { /// /// Same contract as [`ShmWriter::new`]: the region must be stable and valid, /// zero-initialized at creation, and accessed only through this protocol. -pub unsafe fn close( - mem: M, -) -> Result, ProtocolError> { +pub unsafe fn close(mem: M) -> Result, ProtocolError> { // SAFETY: forwarded from this function's contract. unsafe { reader::close(mem) } } @@ -124,9 +123,19 @@ pub unsafe fn close( /// /// Same contract as [`ShmWriter::new`]. #[cfg(target_os = "linux")] -pub unsafe fn pre_fault(mem: &impl AsRawSlice) { +pub unsafe fn pre_fault(mem: &impl AsRawSlice) { // SAFETY: forwarded from this function's contract. - unsafe { state::SharedState::::borrow(mem.as_raw_slice()) }.pre_fault(); + unsafe { state::SharedState::borrow(mem.as_raw_slice()) }.pre_fault(); +} + +/// Whether a mapping of `len` bytes can host the protocol at all. +/// +/// Senders opening a file they do not control should refuse unsupported +/// lengths with an error; the protocol's own constructors treat them as a +/// broken caller and panic. +#[must_use] +pub fn is_supported_region_len(len: usize) -> bool { + (layout::HEADER_LEN..=layout::MAX_MAPPING_LEN).contains(&len) } #[cfg(test)] @@ -144,13 +153,6 @@ mod tests { use super::*; - // Table sizes matching each test's region size, via the sizing rule. - const S_1K: usize = slots_for_capacity(1024); - const S_16K: usize = slots_for_capacity(16 * 1024); - const S_64K: usize = slots_for_capacity(64 * 1024); - #[cfg(not(miri))] - const S_1M: usize = slots_for_capacity(1024 * 1024); - /// A mocked shared memory region for testing. /// /// To be testable for miri, the shared memory is allocated using `Arc` @@ -207,10 +209,10 @@ mod tests { } } - fn collect_frames(shm: &MockedShm) -> Frames { + fn collect_frames(shm: &MockedShm) -> Frames { // SAFETY: `MockedShm` provides a stable, zero-initialized allocation // accessed only through the protocol. - unsafe { close::<_, SLOTS>(shm.clone()) }.unwrap() + unsafe { close(shm.clone()) }.unwrap() } #[test] @@ -218,12 +220,12 @@ mod tests { let shm = MockedShm::alloc(1024); // SAFETY: `MockedShm::alloc` provides a valid, properly-sized, // zero-initialized allocation. - let writer = unsafe { ShmWriter::<_, S_1K>::new(shm.clone()) }; + let writer = unsafe { ShmWriter::new(shm.clone()) }; assert!(writer.try_write_frame(b"hello")); assert!(writer.try_write_frame(b"world")); assert!(writer.try_write_frame(b"this is a test")); - let frames = collect_frames::(&shm); + let frames = collect_frames(&shm); let mut iter = frames.iter(); assert!(iter.next().unwrap() == b"hello"); assert!(iter.next().unwrap() == b"world"); @@ -236,11 +238,11 @@ mod tests { fn zero_sized_frames_are_rejected() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer = unsafe { ShmWriter::<_, S_1K>::new(shm.clone()) }; + let writer = unsafe { ShmWriter::new(shm.clone()) }; assert!(writer.try_write_frame(b"hello")); assert!(!writer.try_write_frame(b"")); - let frames = collect_frames::(&shm); + let frames = collect_frames(&shm); let mut iter = frames.iter(); assert!(iter.next().unwrap() == b"hello"); assert!(iter.next() == None); @@ -250,11 +252,11 @@ mod tests { fn frame_spanning_many_u64s_roundtrips_exactly() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer = unsafe { ShmWriter::<_, S_1K>::new(shm.clone()) }; + let writer = unsafe { ShmWriter::new(shm.clone()) }; let pattern: Vec = (0..=99).collect(); assert!(writer.try_write_frame(&pattern)); - let frames = collect_frames::(&shm); + let frames = collect_frames(&shm); let mut iter = frames.iter(); assert!(iter.next().unwrap() == pattern.as_slice()); assert!(iter.next() == None); @@ -264,7 +266,7 @@ mod tests { fn oversized_frame_fails_and_marks_incomplete() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer = unsafe { ShmWriter::<_, S_1K>::new(shm.clone()) }; + let writer = unsafe { ShmWriter::new(shm.clone()) }; assert!(writer.try_write_frame(b"test")); @@ -278,7 +280,7 @@ mod tests { == ClaimError::Capacity ); - let frames = collect_frames::(&shm); + let frames = collect_frames(&shm); let mut iter = frames.iter(); assert!(iter.next().unwrap() == b"test"); assert!(iter.next() == None); @@ -290,7 +292,7 @@ mod tests { fn crash_after_claim_is_skipped() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer = unsafe { ShmWriter::<_, S_1K>::new(shm.clone()) }; + let writer = unsafe { ShmWriter::new(shm.clone()) }; assert!(writer.try_write_frame(b"foo")); // Simulate a crash right after claiming: no drop code runs. @@ -299,7 +301,7 @@ mod tests { assert!(writer.try_write_frame(b"bar")); - let frames = collect_frames::(&shm); + let frames = collect_frames(&shm); let mut iter = frames.iter(); assert!(iter.next().unwrap() == b"foo"); assert!(iter.next().unwrap() == b"bar"); @@ -312,7 +314,7 @@ mod tests { fn crash_during_partial_write_is_skipped() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer = unsafe { ShmWriter::<_, S_1K>::new(shm.clone()) }; + let writer = unsafe { ShmWriter::new(shm.clone()) }; assert!(writer.try_write_frame(b"foo")); // Simulate a crash during writing. @@ -322,7 +324,7 @@ mod tests { assert!(writer.try_write_frame(b"bar")); - let frames = collect_frames::(&shm); + let frames = collect_frames(&shm); let mut iter = frames.iter(); assert!(iter.next().unwrap() == b"foo"); assert!(iter.next().unwrap() == b"bar"); @@ -336,7 +338,7 @@ mod tests { // receiver from finding the valid frames around them. let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer = unsafe { ShmWriter::<_, S_1K>::new(shm.clone()) }; + let writer = unsafe { ShmWriter::new(shm.clone()) }; assert!(writer.try_write_frame(b"foo")); @@ -350,7 +352,7 @@ mod tests { assert!(writer.try_write_frame(b"bar")); - let frames = collect_frames::(&shm); + let frames = collect_frames(&shm); let mut iter = frames.iter(); assert!(iter.next().unwrap() == b"foo"); assert!(iter.next().unwrap() == b"bar"); @@ -362,14 +364,14 @@ mod tests { fn abandoned_frame_marks_the_channel_incomplete() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer = unsafe { ShmWriter::<_, S_1K>::new(shm.clone()) }; + let writer = unsafe { ShmWriter::new(shm.clone()) }; assert!(writer.try_write_frame(b"foo")); // A live writer dropping an unfinished frame abandons a record it // may still act on. drop(writer.claim_frame(5.try_into().unwrap()).unwrap()); - let frames = collect_frames::(&shm); + let frames = collect_frames(&shm); let mut iter = frames.iter(); assert!(iter.next().unwrap() == b"foo"); assert!(iter.next() == None); @@ -381,18 +383,18 @@ mod tests { fn pre_fault_does_not_disturb_protocol_state() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer = unsafe { ShmWriter::<_, S_1K>::new(shm.clone()) }; + let writer = unsafe { ShmWriter::new(shm.clone()) }; // On the untouched region, before any claim. // SAFETY: see `collect_frames`. - unsafe { pre_fault::(&shm) }; + unsafe { pre_fault(&shm) }; assert!(writer.try_write_frame(b"foo")); // Racing an already claimed region must change nothing either. // SAFETY: see `collect_frames`. - unsafe { pre_fault::(&shm) }; + unsafe { pre_fault(&shm) }; assert!(writer.try_write_frame(b"bar")); - let frames = collect_frames::(&shm); + let frames = collect_frames(&shm); let mut iter = frames.iter(); assert!(iter.next().unwrap() == b"foo"); assert!(iter.next().unwrap() == b"bar"); @@ -406,13 +408,13 @@ mod tests { // on the slot side while payload space remains. let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer = unsafe { ShmWriter::<_, S_1K>::new(shm.clone()) }; + let writer = unsafe { ShmWriter::new(shm.clone()) }; for _ in 0..15 { assert!(writer.try_write_frame(b"x")); } assert!(writer.claim_frame(1.try_into().unwrap()).unwrap_err() == ClaimError::Capacity); - let frames = collect_frames::(&shm); + let frames = collect_frames(&shm); assert!(frames.iter().count() == 15); assert!(!frames.is_complete()); } @@ -421,11 +423,11 @@ mod tests { fn claims_after_close_are_gated_without_poisoning() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer = unsafe { ShmWriter::<_, S_1K>::new(shm.clone()) }; + let writer = unsafe { ShmWriter::new(shm.clone()) }; assert!(writer.try_write_frame(b"foo")); assert!(!writer.is_closed()); - let frames = collect_frames::(&shm); + let frames = collect_frames(&shm); let mut iter = frames.iter(); assert!(iter.next().unwrap() == b"foo"); assert!(iter.next() == None); @@ -443,13 +445,13 @@ mod tests { fn commit_after_abort_publishes_nothing() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer = unsafe { ShmWriter::<_, S_1K>::new(shm.clone()) }; + let writer = unsafe { ShmWriter::new(shm.clone()) }; let mut frame = writer.claim_frame(5.try_into().unwrap()).unwrap(); frame.copy_from_slice(b"late!"); // The receiver closes while the frame is unfinished and aborts it. - let frames = collect_frames::(&shm); + let frames = collect_frames(&shm); assert!(frames.iter().count() == 0); assert!(frames.is_complete()); @@ -457,7 +459,7 @@ mod tests { // must not fire either, because the frame *was* explicitly finished. frame.finish(); - let frames = collect_frames::(&shm); + let frames = collect_frames(&shm); assert!(frames.iter().count() == 0); assert!(frames.is_complete()); } @@ -473,7 +475,7 @@ mod tests { // SAFETY: see `single_thread_basic`. The clone shares the // same backing memory, which is safe because the protocol // synchronizes concurrent access with atomics. - let writer = unsafe { ShmWriter::<_, S_16K>::new(shm.clone()) }; + let writer = unsafe { ShmWriter::new(shm.clone()) }; for _ in 0..10 { assert!(writer.try_write_frame(b"hello")); assert!(writer.try_write_frame(b"foo")); @@ -483,7 +485,7 @@ mod tests { } }); - let frames = collect_frames::(&shm); + let frames = collect_frames(&shm); let mut count = 0; for frame in frames.iter() { count += 1; @@ -498,7 +500,7 @@ mod tests { fn concurrent_exceeded_size() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer = unsafe { ShmWriter::<_, S_1K>::new(shm.clone()) }; + let writer = unsafe { ShmWriter::new(shm.clone()) }; thread::scope(|s| { for _ in 0..4 { s.spawn(|| { @@ -511,7 +513,7 @@ mod tests { } }); - let frames = collect_frames::(&shm); + let frames = collect_frames(&shm); let mut count = 0; for frame in frames.iter() { count += 1; @@ -534,7 +536,7 @@ mod tests { let writers = [(); 2].map(|()| { s.spawn(|| { // SAFETY: see `concurrent`. - let writer = unsafe { ShmWriter::<_, S_64K>::new(shm.clone()) }; + let writer = unsafe { ShmWriter::new(shm.clone()) }; barrier.wait(); let mut written = 0usize; // Bounded so the test terminates even if close is slow; @@ -555,7 +557,7 @@ mod tests { }); barrier.wait(); - let frames = collect_frames::(&shm); + let frames = collect_frames(&shm); let results = writers.map(|writer| writer.join().unwrap()); (frames, results) }); @@ -580,7 +582,7 @@ mod tests { fn corrupt_committed_descriptor_is_a_protocol_error() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer = unsafe { ShmWriter::<_, S_1K>::new(shm.clone()) }; + let writer = unsafe { ShmWriter::new(shm.clone()) }; assert!(writer.try_write_frame(b"hello")); // Point slot 0 at a span escaping the mapping. @@ -589,7 +591,7 @@ mod tests { shm.poke_u64(64, (bogus_len << 32) | bogus_offset); // SAFETY: see `collect_frames`. - let result = unsafe { close::<_, S_1K>(shm) }; + let result = unsafe { close(shm) }; assert!(result.unwrap_err() == ProtocolError::CorruptDescriptor { slot_index: 0 }); } @@ -597,7 +599,7 @@ mod tests { fn corrupt_aborted_descriptor_is_a_protocol_error() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer = unsafe { ShmWriter::<_, S_1K>::new(shm.clone()) }; + let writer = unsafe { ShmWriter::new(shm.clone()) }; assert!(writer.try_write_frame(b"hello")); // The aborted bit combined with payload bits is a value no protocol @@ -605,7 +607,7 @@ mod tests { shm.poke_u64(64, (1 << 63) | (8u64 << 32) | 8); // SAFETY: see `collect_frames`. - let result = unsafe { close::<_, S_1K>(shm) }; + let result = unsafe { close(shm) }; assert!(result.unwrap_err() == ProtocolError::CorruptDescriptor { slot_index: 0 }); } @@ -613,7 +615,7 @@ mod tests { fn overshot_claim_counter_clamps_to_the_table() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer = unsafe { ShmWriter::<_, S_1K>::new(shm.clone()) }; + let writer = unsafe { ShmWriter::new(shm.clone()) }; assert!(writer.try_write_frame(b"hello")); // A wildly inflated claim counter — mass claim failures or a foreign @@ -622,7 +624,7 @@ mod tests { // freeze as aborted. shm.poke_u64(0, (1 << 40) | 1); - let frames = collect_frames::(&shm); + let frames = collect_frames(&shm); let mut iter = frames.iter(); assert!(iter.next().unwrap() == b"hello"); assert!(iter.next() == None); @@ -650,7 +652,7 @@ mod tests { let result = std::panic::catch_unwind(|| { // SAFETY: Intentionally passing a misaligned pointer to test that // the geometry assertion correctly panics. - unsafe { ShmWriter::<_, S_1K>::new(misaligned_shm) }; + unsafe { ShmWriter::new(misaligned_shm) }; }); assert!(result.is_err(), "should panic on a misaligned region"); } @@ -691,7 +693,7 @@ mod tests { // SAFETY: `mapping` is a freshly mapped shared memory // region with a valid pointer and size; the protocol // synchronizes concurrent access. - let writer = unsafe { ShmWriter::<_, S_1M>::new(mapping) }; + let writer = unsafe { ShmWriter::new(mapping) }; for i in 0..FRAME_COUNT_EACH_CHILD { let frame_data = std::format!("{child_index} {i}"); assert!(writer.try_write_frame(frame_data.as_bytes())); @@ -709,7 +711,7 @@ mod tests { // SAFETY: the mapping is a valid shared-memory region created zeroed // and accessed only through the protocol. - let frames = unsafe { close::<_, S_1M>(mapping) }.unwrap(); + let frames = unsafe { close(mapping) }.unwrap(); assert!(frames.is_complete()); let collected = frames.iter().map(BStr::new).collect::>(); assert!(collected.len() == CHILD_COUNT * FRAME_COUNT_EACH_CHILD); @@ -747,7 +749,7 @@ mod tests { let c_path = crate::ipc::channel::os_c_string(std::ffi::OsStr::new(&shm_name)).unwrap(); let child_mapping = fspy_shm::open(c_path.as_c_str().as_thin()).unwrap().map().unwrap(); // SAFETY: see `real_shm_across_processes`. - let writer = unsafe { ShmWriter::<_, S_1M>::new(child_mapping) }; + let writer = unsafe { ShmWriter::new(child_mapping) }; let mut frame = writer.claim_frame(5.try_into().unwrap()).unwrap(); frame[..3].copy_from_slice(b"wor"); // Signal the parent that the frame is claimed and partially @@ -773,11 +775,11 @@ mod tests { // A surviving writer keeps working after the kill. // SAFETY: see `real_shm_across_processes`. - let writer = unsafe { ShmWriter::<_, S_1M>::new(mapping) }; + let writer = unsafe { ShmWriter::new(mapping) }; assert!(writer.try_write_frame(b"alive")); // SAFETY: see `real_shm_across_processes`. - let frames = unsafe { close::<_, S_1M>(writer.into_memory()) }.unwrap(); + let frames = unsafe { close(writer.into_memory()) }.unwrap(); let mut iter = frames.iter(); assert!(iter.next().unwrap() == b"alive"); assert!(iter.next() == None); diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs index 43c13f4c3..5429dbbb6 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs @@ -28,13 +28,13 @@ use super::{ /// The committed frames of a closed channel: validated spans borrowed from /// the mapping, which stays alive inside this value. Dropping it releases /// the mapping. -pub struct Frames { +pub struct Frames { mem: M, spans: Vec, complete: bool, } -impl Frames { +impl Frames { /// Iterates over the committed frames in claim order. pub fn iter(&self) -> impl Iterator { let base = self.mem.as_raw_slice().cast::().cast_const(); @@ -59,7 +59,7 @@ impl Frames { } } -impl fmt::Debug for Frames { +impl fmt::Debug for Frames { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("Frames") .field("frames", &self.spans.len()) @@ -96,16 +96,14 @@ pub enum ProtocolError { /// Panics when the region is not `u64`-aligned or its size is outside the /// supported range (see [`SharedState::borrow`]) — a broken caller, not /// corrupt shared data, which is reported as [`ProtocolError`] instead. -pub(super) unsafe fn close( - mem: M, -) -> Result, ProtocolError> { +pub(super) unsafe fn close(mem: M) -> Result, ProtocolError> { let spans; let complete; { // SAFETY: forwarded from this function's contract; the raw slice // stays valid while `mem` is borrowed here and beyond, since `mem` // moves into the returned `Frames`. - let state = unsafe { SharedState::::borrow(mem.as_raw_slice()) }; + let state = unsafe { SharedState::borrow(mem.as_raw_slice()) }; // The close boundary: claims at or before this snapshot are inside // it, later ones land in slots this pass never visits. The count is @@ -136,8 +134,8 @@ pub(super) unsafe fn close( Ok(Frames { mem, spans, complete }) } -fn freeze_committed_spans( - state: SharedState<'_, SLOTS>, +fn freeze_committed_spans( + state: SharedState<'_>, slot_count: usize, ) -> Result, ProtocolError> { let mut spans = Vec::new(); @@ -145,13 +143,8 @@ fn freeze_committed_spans( match slot::decode(state.freeze(slot_index)) { SlotState::Aborted => {} SlotState::Committed { payload_offset, payload_len } => { - let span = PayloadSpan::validate( - state.mapping_len(), - state.payload_base(), - payload_offset, - payload_len, - ) - .ok_or(ProtocolError::CorruptDescriptor { slot_index })?; + let span = PayloadSpan::validate(state.mapping_len(), payload_offset, payload_len) + .ok_or(ProtocolError::CorruptDescriptor { slot_index })?; spans.push(span); } // `freeze` only returns terminal values, so `Unfinished` is diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/state.rs b/crates/fspy_shared/src/ipc/channel/shm_io/state.rs index ca26d69b3..0d05fb38e 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/state.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/state.rs @@ -1,12 +1,13 @@ //! The only module that touches shared-memory bytes. //! //! [`SharedState`] wraps the raw mapping and exposes the protocol's atomic -//! operations. The header and descriptor table are one `repr(C)` struct, -//! [`Region`], borrowed from the mapping base with a single unsafe cast in -//! [`SharedState::borrow`]; every access after that is a plain field access -//! or an index into the slot array. Only the untyped payload area — which -//! must stay outside the [`Region`] referent so writers' exclusive `&mut` -//! payload spans never alias it — is still reached through raw pointers. +//! operations. One unsafe borrow in [`SharedState::borrow`] constructs +//! three typed views of the region — the `repr(C)` [`Header`], the +//! descriptor table as a slice of atomics sized by the mapping length, and +//! the untyped payload area as a raw slice. Every access after that is a +//! plain field access or a bounds-checked index. The payload area stays +//! raw because writers hold exclusive `&mut` borrows into it, which must +//! not alias any shared reference. //! //! # Shared atomics //! @@ -47,10 +48,7 @@ //! also makes the payload writes it published visible, so the borrows the //! receiver later hands out (see `reader`) read settled bytes. -use std::{ - marker::PhantomData, - sync::atomic::{AtomicU64, Ordering}, -}; +use std::sync::atomic::{AtomicU64, Ordering}; use super::{layout, slot}; @@ -75,16 +73,6 @@ struct Header { const _: () = assert!(size_of::
() == layout::HEADER_LEN); const _: () = assert!(align_of::
() == align_of::()); -/// The compile-time-laid-out prefix of the region: the header and the -/// descriptor table. The payload area follows it in the mapping but is -/// deliberately not a field — writers hold exclusive `&mut` borrows into -/// it, which must not alias the shared `&Region` borrow. -#[repr(C)] -struct Region { - header: Header, - slots: [AtomicU64; SLOTS], -} - /// Why a claim was not admitted. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub(super) enum ReserveError { @@ -103,23 +91,21 @@ pub(super) struct Reservation { pub(super) payload_offset: usize, } -/// A borrowed view of the shared mapping with protocol-level operations. +/// A borrowed view of the shared mapping with protocol-level operations: +/// the typed header, the descriptor table sized from the mapping length, +/// and the raw payload area. #[derive(Clone, Copy)] -pub(super) struct SharedState<'m, const SLOTS: usize> { - region: &'m Region, - base: *mut u8, +pub(super) struct SharedState<'m> { + header: &'m Header, + table: &'m [AtomicU64], + payloads: *mut [u8], + /// The real mapping length. Not derivable from the parts above: the + /// payload region rounds down to whole `u64`s, and re-deriving the + /// layout from a shortened length could shift the table boundary. len: usize, - _mapping: PhantomData<&'m ()>, } -impl SharedState<'_, SLOTS> { - /// Byte offset where the payload region starts: right after the - /// descriptor table. `u64`-aligned by construction. - const PAYLOAD_BASE: usize = { - assert!(size_of::>() == layout::payload_base_for_slots(SLOTS)); - size_of::>() - }; - +impl SharedState<'_> { /// Borrows a shared mapping. /// /// # Safety @@ -127,27 +113,43 @@ impl SharedState<'_, SLOTS> { /// - `mem` must be valid for reads and writes for the lifetime `'m` and /// its address must be stable. /// - The memory must have been zero-initialized when the region was - /// created, and accessed only through this protocol since, - /// instantiated with the same `SLOTS` by every process. + /// created, and accessed only through this protocol since. /// /// # Panics /// /// Panics when the mapping cannot host the protocol at all: base not - /// `u64`-aligned, too small for the header and table, or larger than + /// `u64`-aligned, smaller than the header, or larger than /// [`layout::MAX_MAPPING_LEN`]. These indicate a broken caller, not - /// runtime data. + /// runtime data; senders guard untrusted mappings with + /// [`super::is_supported_region_len`] first. + #[expect(clippy::cast_ptr_alignment, reason = "the base is asserted `u64`-aligned below")] pub(super) unsafe fn borrow(mem: *mut [u8]) -> Self { let base = mem.cast::(); let len = mem.len(); - assert!(base.addr().is_multiple_of(align_of::>())); - assert!((Self::PAYLOAD_BASE..=layout::MAX_MAPPING_LEN).contains(&len)); - // SAFETY: the region prefix `[base, base + size_of::())` is - // in bounds and aligned (both asserted above) and consists entirely - // of atomics zero-initialized at creation, so a shared borrow for - // `'m` is valid even while other threads and processes access the - // same memory — they do so through these same atomics. - let region = unsafe { &*base.cast::>() }; - Self { region, base, len, _mapping: PhantomData } + assert!(base.addr().is_multiple_of(align_of::
())); + assert!((layout::HEADER_LEN..=layout::MAX_MAPPING_LEN).contains(&len)); + // SAFETY: the header and the table lie inside the mapping (the + // header by the assert above, the table by `layout::max_slots`), + // are `u64`-aligned (aligned base, `u64`-multiple offsets), and + // consist entirely of atomics zero-initialized at creation — so + // shared borrows for `'m` are valid even while other threads and + // processes access the same memory through these same atomics. The + // payload area keeps the rest of the mapping as a raw slice; + // `layout` bounds every span carved from it. + unsafe { + Self { + header: &*base.cast::
(), + table: std::slice::from_raw_parts( + base.add(layout::HEADER_LEN).cast::(), + layout::max_slots(len), + ), + payloads: std::ptr::slice_from_raw_parts_mut( + base.add(layout::payload_base(len)), + layout::payload_region_len(len), + ), + len, + } + } } pub(super) const fn mapping_len(self) -> usize { @@ -155,17 +157,13 @@ impl SharedState<'_, SLOTS> { } /// Byte offset where the payload region starts. - #[expect( - clippy::unused_self, - reason = "reads an associated const; instance syntax keeps call sites uniform" - )] pub(super) const fn payload_base(self) -> usize { - Self::PAYLOAD_BASE + layout::payload_base(self.len) } /// Whether the CLOSED gate has been set. pub(super) fn is_closed(self) -> bool { - self.region.header.claims.load(Ordering::Relaxed) & CLOSED != 0 + self.header.claims.load(Ordering::Relaxed) & CLOSED != 0 } /// Atomically reserves one descriptor slot and one payload span. @@ -182,21 +180,20 @@ impl SharedState<'_, SLOTS> { // because the counter is not what locates payloads (descriptors are) // and a `u64` cannot realistically wrap. let payload_start = - self.region.header.payload_reserved.fetch_add(reserved_len as u64, Ordering::Relaxed); + self.header.payload_reserved.fetch_add(reserved_len as u64, Ordering::Relaxed); // Checked: a foreign scribble of the counter must fail the claim, // not wrap the bound into an out-of-bounds reservation. let payload_end = payload_start.checked_add(reserved_len as u64); - let payload_region_len = layout::payload_region_len(self.len, Self::PAYLOAD_BASE); - if payload_end.is_none_or(|end| end > payload_region_len as u64) { + if payload_end.is_none_or(|end| end > self.payloads.len() as u64) { return Err(ReserveError::Capacity); } - let claims = self.region.header.claims.fetch_add(1, Ordering::Relaxed); + let claims = self.header.claims.fetch_add(1, Ordering::Relaxed); if claims & CLOSED != 0 { return Err(ReserveError::Closed); } let slot_index = usize::try_from(claims).expect("claim count exceeds usize"); - if slot_index >= SLOTS { + if slot_index >= self.table.len() { return Err(ReserveError::Capacity); } @@ -204,7 +201,7 @@ impl SharedState<'_, SLOTS> { slot_index, // In bounds: `payload_start + reserved_len` fits the payload // region, which ends within the mapping (`layout`). - payload_offset: Self::PAYLOAD_BASE + payload_offset: self.payload_base() + usize::try_from(payload_start).expect("bounded by the payload region"), }) } @@ -214,26 +211,26 @@ impl SharedState<'_, SLOTS> { /// Must be called before the operation whose record was lost is /// performed (rule 1). pub(super) fn flag_incomplete(self) { - self.region.header.incomplete.fetch_or(1, Ordering::Relaxed); + self.header.incomplete.fetch_or(1, Ordering::Relaxed); } /// Whether any live writer lost a record. Read after the freeze pass /// (rule 1). pub(super) fn is_incomplete(self) -> bool { - self.region.header.incomplete.load(Ordering::Relaxed) != 0 + self.header.incomplete.load(Ordering::Relaxed) != 0 } /// Snapshots the number of admitted claims: the receiver's close /// boundary (rule 1). Clamped to the table capacity because failed /// claims overshoot the counter. pub(super) fn snapshot_claims(self) -> usize { - let claims = self.region.header.claims.load(Ordering::Relaxed); - usize::try_from(claims & !CLOSED).unwrap_or(usize::MAX).min(SLOTS) + let claims = self.header.claims.load(Ordering::Relaxed); + usize::try_from(claims & !CLOSED).unwrap_or(usize::MAX).min(self.table.len()) } /// Sets the CLOSED gate so stragglers stop claiming. pub(super) fn close_claims(self) { - self.region.header.claims.fetch_or(CLOSED, Ordering::Relaxed); + self.header.claims.fetch_or(CLOSED, Ordering::Relaxed); } /// Forces the page backing the header (and the table's first slots) to @@ -252,8 +249,7 @@ impl SharedState<'_, SLOTS> { /// Only Linux channels use this: elsewhere the first touch is cheap. #[cfg(target_os = "linux")] pub(super) fn pre_fault(self) { - let _ = - self.region.header.claims.compare_exchange(0, 0, Ordering::Relaxed, Ordering::Relaxed); + let _ = self.header.claims.compare_exchange(0, 0, Ordering::Relaxed, Ordering::Relaxed); } /// Publishes a committed descriptor into an unfinished slot. @@ -268,7 +264,7 @@ impl SharedState<'_, SLOTS> { /// indices of admitted reservations. pub(super) fn commit(self, slot_index: usize, descriptor: u64) -> bool { // Rule 2: `Release` orders every payload write before the descriptor. - self.region.slots[slot_index] + self.table[slot_index] .compare_exchange(slot::UNFINISHED, descriptor, Ordering::Release, Ordering::Relaxed) .is_ok() } @@ -282,7 +278,7 @@ impl SharedState<'_, SLOTS> { /// passes indices below its clamped snapshot. pub(super) fn freeze(self, slot_index: usize) -> u64 { // Rule 3: `Acquire` on failure makes a committed payload visible. - match self.region.slots[slot_index].compare_exchange( + match self.table[slot_index].compare_exchange( slot::UNFINISHED, slot::ABORTED, Ordering::AcqRel, @@ -296,9 +292,9 @@ impl SharedState<'_, SLOTS> { /// Pointer to a reserved payload span. The caller owns the span's /// exclusivity argument. pub(super) fn payload_ptr(self, offset: usize) -> *mut u8 { - debug_assert!(offset <= self.len); + debug_assert!((self.payload_base()..=self.len).contains(&offset)); // SAFETY: callers pass offsets of admitted reservations, which - // `layout` keeps inside the mapping. - unsafe { self.base.add(offset) } + // `layout` keeps inside the payload area. + unsafe { self.payloads.cast::().add(offset - self.payload_base()) } } } diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs index cf8b25ce4..650ac4d00 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs @@ -19,7 +19,7 @@ use super::{ /// reserved with atomic operations, filled in uniquely owned payload spans, /// and published with an atomic commit (see the module docs of /// [`super::state`]). -pub struct ShmWriter { +pub struct ShmWriter { mem: M, } @@ -48,7 +48,7 @@ pub enum WriteEncodedError { Claim(#[from] ClaimError), } -impl ShmWriter { +impl ShmWriter { /// Creates a writer backed by a shared-memory region. /// /// # Safety @@ -66,11 +66,11 @@ impl ShmWriter { // Validate the region geometry eagerly so misuse fails at // construction, not at the first claim. // SAFETY: forwarded from this function's contract. - let _ = unsafe { SharedState::::borrow(mem.as_raw_slice()) }; + let _ = unsafe { SharedState::borrow(mem.as_raw_slice()) }; Self { mem } } - fn state(&self) -> SharedState<'_, SLOTS> { + fn state(&self) -> SharedState<'_> { // SAFETY: `new` requires the region to stay valid and // protocol-governed for the writer's lifetime, and it validated the // geometry. @@ -87,7 +87,7 @@ impl ShmWriter { /// The frame is invisible to the receiver until [`FrameMut::finish`] /// commits it. Dropping the frame without finishing abandons the claim /// and marks the channel incomplete. - pub fn claim_frame(&self, frame_size: NonZeroUsize) -> Result, ClaimError> { + pub fn claim_frame(&self, frame_size: NonZeroUsize) -> Result, ClaimError> { let state = self.state(); let reservation = state.try_claim(frame_size.get()).map_err(|err| match err { ReserveError::Closed => ClaimError::Closed, @@ -184,14 +184,14 @@ impl ShmWriter { /// to perform the operation this record was meant to describe. A process /// that dies mid-frame runs no drop code and marks nothing — correctly so, /// since records are published before the recorded operation is performed. -pub struct FrameMut<'a, const SLOTS: usize> { - state: SharedState<'a, SLOTS>, +pub struct FrameMut<'a> { + state: SharedState<'a>, slot_index: usize, descriptor: u64, content: &'a mut [u8], } -impl std::fmt::Debug for FrameMut<'_, SLOTS> { +impl std::fmt::Debug for FrameMut<'_> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("FrameMut") .field("slot_index", &self.slot_index) @@ -200,7 +200,7 @@ impl std::fmt::Debug for FrameMut<'_, SLOTS> { } } -impl Deref for FrameMut<'_, SLOTS> { +impl Deref for FrameMut<'_> { type Target = [u8]; fn deref(&self) -> &Self::Target { @@ -208,13 +208,13 @@ impl Deref for FrameMut<'_, SLOTS> { } } -impl DerefMut for FrameMut<'_, SLOTS> { +impl DerefMut for FrameMut<'_> { fn deref_mut(&mut self) -> &mut Self::Target { self.content } } -impl FrameMut<'_, SLOTS> { +impl FrameMut<'_> { /// Commits the frame, making it visible to the receiver. /// /// If the receiver closed the channel and aborted this frame's slot @@ -226,7 +226,7 @@ impl FrameMut<'_, SLOTS> { } } -impl Drop for FrameMut<'_, SLOTS> { +impl Drop for FrameMut<'_> { fn drop(&mut self) { self.state.flag_incomplete(); } diff --git a/crates/fspy_shared/src/ipc/mod.rs b/crates/fspy_shared/src/ipc/mod.rs index 306f81d81..9c49e7371 100644 --- a/crates/fspy_shared/src/ipc/mod.rs +++ b/crates/fspy_shared/src/ipc/mod.rs @@ -3,16 +3,6 @@ pub mod channel; mod ipc_path; use std::fmt::Debug; -/// Descriptor-table size of the path-access channel, sized from a 4 GiB -/// region. -/// -/// Large enough to store path accesses in almost any realistic scenario, -/// and sparse, so no physical memory is used until it is actually written. -/// Every process on the channel — supervisor and preload clients — must -/// name this same table size. -#[cfg(not(target_env = "musl"))] -pub const SHM_SLOTS: usize = channel::slots_for_capacity(4 * 1024 * 1024 * 1024); - use bitflags::bitflags; pub use fspy_ipc_str::IpcStr; pub use ipc_path::IpcPath; From 907a55d67d146ed71c0a1d2c5bf561be6b48400f Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sat, 15 Aug 2026 10:47:34 +0800 Subject: [PATCH 19/92] docs(fspy-shm): update the README for dynamic typed-view sizing Co-Authored-By: Claude Fable 5 --- .../src/ipc/channel/shm_io/README.md | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/README.md b/crates/fspy_shared/src/ipc/channel/shm_io/README.md index d981b983a..b5422c2d5 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/README.md +++ b/crates/fspy_shared/src/ipc/channel/shm_io/README.md @@ -30,8 +30,8 @@ space, not memory: only pages that are actually written get backed. | header (64 B) | descriptor table (1/8 of the region) | payloads (the rest, grow up) | ``` -The header holds three `AtomicU64`s, and every one of them only ever -counts up: +The header is a `repr(C)` struct of three `AtomicU64`s, and every one of +them only ever counts up: - the **claim counter** — how many frames were ever claimed. Bit 63 is the CLOSED gate. @@ -44,11 +44,13 @@ bound is derived from the mapping length alone, so the region is self-describing: writers and the receiver compute the same layout from the size of the file they mapped, with nothing else to agree on. For a 4 GiB region that is ~67 million slots; the ~3.5 GiB payload region fits ~15–20 -million records of a few hundred bytes, so payload space runs out first. The split is fixed, not a movable frontier, -because fixed bounds are what make claiming wait-free: each counter is -checked against its own constant limit using the value `fetch_add` -returned, and overshooting a limit is harmless because nothing ever -locates data through a counter — descriptors are self-describing. +million records of a few hundred bytes, so payload space runs out first. + +The split is fixed rather than a movable frontier because fixed bounds are +what make claiming wait-free: each counter is checked against its region's +limit using the value `fetch_add` returned, and overshooting is harmless +because nothing ever locates data through a counter — a committed +descriptor carries its own offset and length. ## Writing a frame @@ -133,17 +135,17 @@ CLAIMED (slot 0) ---+ - Closing costs one pass over the claimed slots. No payload is copied. - On Linux, the first touch of the sparse backing file can cost milliseconds on journalling filesystems (it is the fault path, not block - allocation — `fallocate` does not help). Creators should run - [`pre_fault`] off any latency-sensitive path — for example on a - background thread, concurrently with spawning the first writer. Windows - and macOS fault cheaply and skip this. + allocation — `fallocate` does not help). Creators should run `pre_fault` + off any latency-sensitive path — for example on a background thread, + concurrently with spawning the first writer. Windows and macOS fault + cheaply and skip this. ## Files | File | Role | | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `mod.rs` | Public surface (`ShmWriter`, `FrameMut`, `close`, `Frames`), the protocol overview docs, and the integration tests — they run against a mocked region and are miri-clean (`cargo miri test -p fspy_shared shm_io`). | -| `layout.rs` | Pure geometry: header offsets, the table/payload split, span validation. Plain integer math, no pointers, no atomics. | +| `layout.rs` | Pure geometry: the sizing rule that turns a mapping length into table and payload bounds, payload rounding, and span validation. Plain integer math, no pointers, no atomics. | | `slot.rs` | The descriptor codec: pack and unpack one slot value, classify it as unfinished, aborted, committed, or corrupt. Pure. | | `state.rs` | The only module that touches shared memory: one unsafe borrow builds three typed views — the `repr(C)` header struct, the descriptor table as a slice of atomics, and the raw payload area — and the three-rule memory-ordering contract lives here. | | `writer.rs` | Claim, fill, finish. Owns the argument for why a claimed payload span is exclusively the writer's. | From c07dea99f2f2c7eefbe18538a1d9e864f4234ee9 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sat, 15 Aug 2026 10:50:06 +0800 Subject: [PATCH 20/92] docs(fspy-shm): plainer wording in the README Co-Authored-By: Claude Fable 5 --- .../src/ipc/channel/shm_io/README.md | 56 ++++++++++--------- .../fspy_shared/src/ipc/channel/shm_io/mod.rs | 2 +- 2 files changed, 30 insertions(+), 28 deletions(-) diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/README.md b/crates/fspy_shared/src/ipc/channel/shm_io/README.md index b5422c2d5..309b9a681 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/README.md +++ b/crates/fspy_shared/src/ipc/channel/shm_io/README.md @@ -46,11 +46,11 @@ size of the file they mapped, with nothing else to agree on. For a 4 GiB region that is ~67 million slots; the ~3.5 GiB payload region fits ~15–20 million records of a few hundred bytes, so payload space runs out first. -The split is fixed rather than a movable frontier because fixed bounds are -what make claiming wait-free: each counter is checked against its region's -limit using the value `fetch_add` returned, and overshooting is harmless -because nothing ever locates data through a counter — a committed -descriptor carries its own offset and length. +The line between table space and payload space never moves. That is what +keeps claiming free of retry loops: each counter is checked against a +limit that never changes, using the value `fetch_add` returned, and +overshooting a limit is harmless because nothing ever locates data through +a counter — a committed descriptor carries its own offset and length. ## Writing a frame @@ -62,9 +62,9 @@ Three steps: 2. **Fill.** The writer serializes into its payload span. The span is exclusively its own; nobody else knows it exists yet. 3. **Commit.** One compare-and-swap flips the frame's slot from zero to a - descriptor holding the payload's offset and length. The CAS is the - publication point: before it, the frame does not exist; after it, the - payload is immutable. + descriptor holding the payload's offset and length. Before this swap the + receiver cannot see the frame at all; after it, the frame is visible and + its payload never changes again. Committing is explicit (`FrameMut::finish`). What happens when it never runs is the heart of the design: @@ -77,12 +77,12 @@ runs is the heart of the design: to publish a record may still go on to act as if it had, so the channel stops claiming completeness. -This split assumes the intended usage contract: **a writer publishes a -record before performing the action the record describes.** Under that -contract, a dead writer's missing record describes an action that never +These rules assume one thing about how the channel is used: **a writer +publishes a record before performing the action the record describes.** +Then a dead writer's missing record describes an action that never happened, and a record refused after close describes an action performed -after the channel's boundary — both safe to ignore. A writer that records -_after_ acting must not rely on these semantics. +after the channel closed — both safe to ignore. A writer that records +_after_ acting must not rely on this. ## Closing and reading @@ -93,7 +93,8 @@ The receiver closes once: 2. **Gate** further claims by setting the CLOSED bit, so stragglers stop. 3. **Freeze** every slot in the snapshot: a compare-and-swap flips zero to ABORTED. If the slot was already committed, the swap fails and the frame - is kept. Exactly one side wins each slot; both outcomes are terminal. + is kept. Exactly one side wins each slot, and either way the slot + never changes again. 4. **Validate** each committed descriptor's bounds. A descriptor no correct writer could produce fails the whole channel — never a panic, never an out-of-bounds read. @@ -114,20 +115,21 @@ CLAIMED (slot 0) ---+ ## Why this is sound, in one list -- Frame traversal never reads payload bytes; every slot has a fixed place. - A half-written payload can never be parsed as metadata. +- Finding frames never involves reading payload bytes; every slot has a + fixed place. A half-written payload can never be mistaken for metadata. - A payload is reachable only through its committed descriptor. The commit is a `Release` write and the receiver's failed freeze is an `Acquire` read, so an observed descriptor implies fully visible payload bytes. -- Committed and aborted are terminal. No code path changes a terminal slot. +- Once a slot is committed or aborted, nothing ever changes it again. - Counters only grow. The receiver clamps them to the fixed capacities, so an inflated counter degrades into extra aborted slots, not corruption. -- The bounds checks on descriptors are what justify the `unsafe` borrow - construction: the receiver's memory safety never depends on another - process being correct. -- Byte _integrity_ does trust protocol compliance — a process scribbling - random memory is outside the model. That trust buys the borrow-in-place - reader and the absence of checksums. +- The bounds checks on descriptors are what make the `unsafe` reference + construction correct: whether the receiver stays memory-safe never + depends on another process behaving. +- Whether the bytes are _right_ does trust the other processes to follow + the protocol — one that scribbles random memory is outside the model. + That trust is why the receiver can read frames straight out of shared + memory, with no copies and no checksums. ## Performance notes @@ -148,9 +150,9 @@ CLAIMED (slot 0) ---+ | `layout.rs` | Pure geometry: the sizing rule that turns a mapping length into table and payload bounds, payload rounding, and span validation. Plain integer math, no pointers, no atomics. | | `slot.rs` | The descriptor codec: pack and unpack one slot value, classify it as unfinished, aborted, committed, or corrupt. Pure. | | `state.rs` | The only module that touches shared memory: one unsafe borrow builds three typed views — the `repr(C)` header struct, the descriptor table as a slice of atomics, and the raw payload area — and the three-rule memory-ordering contract lives here. | -| `writer.rs` | Claim, fill, finish. Owns the argument for why a claimed payload span is exclusively the writer's. | -| `reader.rs` | Close (snapshot, gate, freeze, validate) and `Frames`. Owns the argument for why borrowing committed spans is sound. | +| `writer.rs` | Claim, fill, finish. Explains why a claimed payload span belongs to its writer alone. | +| `reader.rs` | Close (snapshot, gate, freeze, validate) and `Frames`. Explains why handing out references to committed spans is safe. | -Each file carries one self-contained argument, so the protocol can be +Each file explains itself without the others, so the protocol can be reviewed module by module: the pure math first, then the atomics, then the -two aliasing arguments built on top of them. +two modules that say who may touch which bytes. diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs index 0ddd69dab..7d40c0b8f 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs @@ -1,4 +1,4 @@ -//! Crash-tolerant, nonblocking frame publication in a shared memory region. +//! A crash-tolerant, nonblocking frame channel in a shared memory region. //! //! Multiple writer processes append variable-length frames concurrently; one //! receiver closes the channel and collects every committed frame without From 1497e5114f01a4a2d663922f318d204b495a06de Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sat, 15 Aug 2026 10:59:23 +0800 Subject: [PATCH 21/92] refactor(fspy-shm): derive completeness from counter overshoot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The incomplete flag had one real writer — capacity exhaustion — and the counters already record that: a failed claim's bumps push a counter past its limit, counters never move backwards, and the bump precedes the operation whose record was lost, so the close snapshot either sees the overshoot or the loss belongs past the boundary. The flag's other writers were bug-only paths. So the flag word, FrameMut's Drop, and the write_encoded flagging wrapper are gone; abandoning a frame now leaves exactly what dying does — an unfinished slot the receiver ignores — and acting on an abandoned record is outside the usage contract. Oversized frames become an asserted precondition: a caller error, and the one loss counters could not record. Co-Authored-By: Claude Fable 5 --- crates/fspy_client_unix/src/lib.rs | 12 +-- .../src/windows/client.rs | 4 +- .../src/ipc/channel/shm_io/README.md | 28 ++++--- .../fspy_shared/src/ipc/channel/shm_io/mod.rs | 78 ++++++++++--------- .../src/ipc/channel/shm_io/reader.rs | 21 +++-- .../src/ipc/channel/shm_io/state.rs | 71 ++++++++--------- .../src/ipc/channel/shm_io/writer.rs | 62 +++++---------- 7 files changed, 126 insertions(+), 150 deletions(-) diff --git a/crates/fspy_client_unix/src/lib.rs b/crates/fspy_client_unix/src/lib.rs index f6814cf66..6dad097ac 100644 --- a/crates/fspy_client_unix/src/lib.rs +++ b/crates/fspy_client_unix/src/lib.rs @@ -86,15 +86,15 @@ impl Client { let Ok(mut frame) = ipc_sender.claim_frame(frame_size) else { // The receiver has closed the channel (this process outlived the - // run's tracking boundary) or the region is full (the claim - // itself already marked the trace incomplete). Either way the + // run's tracking boundary) or the region is full (a loss the + // receiver sees as counter overshoot). Either way the // interception must proceed without a record — a preload library // can never panic its host process. return Ok(()); }; let mut writer: &mut [u8] = &mut frame; - // A serialization failure drops `frame` unfinished, which marks the - // trace incomplete. + // A serialization failure drops `frame` unfinished; the receiver + // ignores the abandoned slot. PathAccess::serialize_into(&mut writer, &path_access)?; debug_assert_eq!(writer.len(), 0); if !writer.is_empty() { @@ -127,8 +127,8 @@ impl Client { // null-terminated arrays, as provided by the caller. let mut exec = unsafe { raw_exec.to_exec() }; let pre_exec = handle_exec(&mut exec, config, &self.encoded_payload, |mode, path| { - // A lost record already marked the trace incomplete inside - // `send`; the exec itself must proceed regardless. + // A failure here is a post-close skip, a full region, or an + // fspy bug; the exec itself must proceed regardless. let _ = self.send(mode, path); })?; RawExec::from_exec(exec, |raw_command| f(raw_command, pre_exec)) diff --git a/crates/fspy_preload_windows/src/windows/client.rs b/crates/fspy_preload_windows/src/windows/client.rs index cf8c076ed..ea5f532ab 100644 --- a/crates/fspy_preload_windows/src/windows/client.rs +++ b/crates/fspy_preload_windows/src/windows/client.rs @@ -41,8 +41,8 @@ impl<'a> Client<'a> { return; }; // A failed write means the receiver closed the channel (this process - // outlived the run's tracking boundary) or the record was lost — the - // latter already marked the trace incomplete. The intercepted call + // outlived the run's tracking boundary) or the region was full — a + // loss the receiver sees as counter overshoot. The intercepted call // must proceed either way; a detours DLL can never panic its host. let _ = sender.write_encoded(&access); } diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/README.md b/crates/fspy_shared/src/ipc/channel/shm_io/README.md index 309b9a681..2d7d39328 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/README.md +++ b/crates/fspy_shared/src/ipc/channel/shm_io/README.md @@ -30,14 +30,16 @@ space, not memory: only pages that are actually written get backed. | header (64 B) | descriptor table (1/8 of the region) | payloads (the rest, grow up) | ``` -The header is a `repr(C)` struct of three `AtomicU64`s, and every one of -them only ever counts up: +The header is a `repr(C)` struct of two `AtomicU64` counters, and both +only ever count up: - the **claim counter** — how many frames were ever claimed. Bit 63 is the CLOSED gate. -- the **incomplete flag** — nonzero once any live writer lost a record. -- the **payload counter** — how many payload bytes were ever reserved, - including by failed claims. Only writers read it. +- the **payload counter** — how many payload bytes were ever reserved. + +Failed claims count too. That is deliberate: a counter past its limit is +how the receiver learns that a record was lost and the frames are +incomplete — no separate flag needed. The table has one 8-byte slot per frame — an eighth of the region. Every bound is derived from the mapping length alone, so the region is @@ -58,7 +60,7 @@ Three steps: 1. **Claim.** Two `fetch_add`s — one reserves payload bytes, one reserves a slot. No retry loop, no lock. A claim that does not fit fails after the - fact; the wasted counter space does not matter (see above). + fact, and its counter bumps double as the loss report (see above). 2. **Fill.** The writer serializes into its payload span. The span is exclusively its own; nobody else knows it exists yet. 3. **Commit.** One compare-and-swap flips the frame's slot from zero to a @@ -73,16 +75,16 @@ runs is the heart of the design: zero. The receiver ignores it. Nothing else is affected, and no cleanup code ever runs or is needed. - **The process is alive but abandoned the frame** (dropped it without - finishing). The drop sets the incomplete flag: a live writer that failed - to publish a record may still go on to act as if it had, so the channel - stops claiming completeness. + finishing). Same thing: the slot stays zero and the receiver ignores it, + exactly as if the writer had died there. These rules assume one thing about how the channel is used: **a writer publishes a record before performing the action the record describes.** Then a dead writer's missing record describes an action that never happened, and a record refused after close describes an action performed after the channel closed — both safe to ignore. A writer that records -_after_ acting must not rely on this. +_after_ acting, or that abandons a frame and performs the action anyway, +steps outside this rule and loses records silently. ## Closing and reading @@ -121,8 +123,10 @@ CLAIMED (slot 0) ---+ is a `Release` write and the receiver's failed freeze is an `Acquire` read, so an observed descriptor implies fully visible payload bytes. - Once a slot is committed or aborted, nothing ever changes it again. -- Counters only grow. The receiver clamps them to the fixed capacities, so - an inflated counter degrades into extra aborted slots, not corruption. +- Counters only grow. The receiver clamps them to the fixed capacities — + an inflated counter degrades into extra aborted slots, not corruption — + and a counter past its limit is precisely how it learns that a record + was lost. - The bounds checks on descriptors are what make the `unsafe` reference construction correct: whether the receiver stays memory-safe never depends on another process behaving. diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs index 7d40c0b8f..78c7c2466 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs @@ -21,10 +21,10 @@ //! The layout is derived from the mapping length alone ([`layout`]), so //! the region is self-describing: every process computes the same table //! and payload bounds from the mapped size. One borrow constructs typed -//! views of the header (a `repr(C)` struct of three monotonic -//! `AtomicU64`s: a claim counter carrying the CLOSED gate bit, a payload -//! counter, and an incomplete flag) and of the descriptor table (a slice -//! of atomics); the payload area stays untyped bytes. +//! views of the header (a `repr(C)` struct of two monotonic `AtomicU64` +//! counters: claims, carrying the CLOSED gate bit, and payload bytes +//! reserved) and of the descriptor table (a slice of atomics); the payload +//! area stays untyped bytes. //! A claim is two wait-free `fetch_add`s — one reserves payload bytes, one //! reserves a descriptor slot — validated against the fixed region bounds //! from the returned old values. Failed claims overshoot the counters @@ -62,9 +62,9 @@ //! drops are sound because writers publish a record *before* performing the //! recorded operation: a process that died mid-frame never performed the //! operation, and one that claimed or committed after the snapshot performs -//! it outside the channel's boundary. A live writer that loses a -//! record *before* close (capacity, abandonment) flags the channel incomplete -//! ([`Frames::is_complete`]). +//! it outside the channel's boundary. A record lost to a full region +//! *before* close shows up as a counter past its limit, and the channel +//! reports itself incomplete ([`Frames::is_complete`]). //! //! Correctness never depends on writer-side cleanup: no exit hooks, PID //! checks, heartbeats, or timeouts. @@ -263,31 +263,34 @@ mod tests { } #[test] - fn oversized_frame_fails_and_marks_incomplete() { + fn full_region_marks_the_channel_incomplete() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. let writer = unsafe { ShmWriter::new(shm.clone()) }; assert!(writer.try_write_frame(b"test")); - // Larger than the payload region, and larger than the absolute frame - // limit: both fail the claim. The failed reservation stays counted — - // harmless, because the failure already marked the channel - // incomplete. + // Larger than the payload region: the claim fails, and its counter + // bump — past the region's limit — is what tells the receiver a + // record was lost. assert!(!writer.try_write_frame(&vec![0u8; 2048])); - assert!( - writer.claim_frame(((i32::MAX as usize) + 1).try_into().unwrap()).unwrap_err() - == ClaimError::Capacity - ); let frames = collect_frames(&shm); let mut iter = frames.iter(); assert!(iter.next().unwrap() == b"test"); assert!(iter.next() == None); - // The failed claims lost records while their process lived on. assert!(!frames.is_complete()); } + #[test] + #[should_panic = "payload_len <= layout::MAX_PAYLOAD_LEN"] + fn oversized_frame_is_a_caller_error() { + let shm = MockedShm::alloc(1024); + // SAFETY: see `single_thread_basic`. + let writer = unsafe { ShmWriter::new(shm) }; + let _ = writer.claim_frame(((i32::MAX as usize) + 1).try_into().unwrap()); + } + #[test] fn crash_after_claim_is_skipped() { let shm = MockedShm::alloc(1024); @@ -295,9 +298,9 @@ mod tests { let writer = unsafe { ShmWriter::new(shm.clone()) }; assert!(writer.try_write_frame(b"foo")); - // Simulate a crash right after claiming: no drop code runs. - let frame = writer.claim_frame(5.try_into().unwrap()).unwrap(); - std::mem::forget(frame); + // A crash right after claiming and an abandoned frame leave the + // identical state: an unfinished slot. + let _ = writer.claim_frame(5.try_into().unwrap()).unwrap(); assert!(writer.try_write_frame(b"bar")); @@ -317,10 +320,12 @@ mod tests { let writer = unsafe { ShmWriter::new(shm.clone()) }; assert!(writer.try_write_frame(b"foo")); - // Simulate a crash during writing. - let mut frame = writer.claim_frame(5.try_into().unwrap()).unwrap(); - frame[..3].copy_from_slice(b"wor"); - std::mem::forget(frame); + // Simulate a crash during writing: the frame is abandoned + // half-filled. + { + let mut frame = writer.claim_frame(5.try_into().unwrap()).unwrap(); + frame[..3].copy_from_slice(b"wor"); + } assert!(writer.try_write_frame(b"bar")); @@ -343,12 +348,13 @@ mod tests { assert!(writer.try_write_frame(b"foo")); // Crash after claim (slot stays zero, payload untouched). - std::mem::forget(writer.claim_frame(5.try_into().unwrap()).unwrap()); + let _ = writer.claim_frame(5.try_into().unwrap()).unwrap(); // Crash mid-write (slot stays zero, payload partially filled). - let mut frame = writer.claim_frame(7.try_into().unwrap()).unwrap(); - frame[..3].copy_from_slice(b"wor"); - std::mem::forget(frame); + { + let mut frame = writer.claim_frame(7.try_into().unwrap()).unwrap(); + frame[..3].copy_from_slice(b"wor"); + } assert!(writer.try_write_frame(b"bar")); @@ -361,21 +367,21 @@ mod tests { } #[test] - fn abandoned_frame_marks_the_channel_incomplete() { + fn abandoned_frame_is_ignored() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. let writer = unsafe { ShmWriter::new(shm.clone()) }; assert!(writer.try_write_frame(b"foo")); - // A live writer dropping an unfinished frame abandons a record it - // may still act on. - drop(writer.claim_frame(5.try_into().unwrap()).unwrap()); + // Dropping an unfinished frame abandons it: the receiver ignores + // the slot exactly as if the writer had died there. + let _ = writer.claim_frame(5.try_into().unwrap()).unwrap(); let frames = collect_frames(&shm); let mut iter = frames.iter(); assert!(iter.next().unwrap() == b"foo"); assert!(iter.next() == None); - assert!(!frames.is_complete()); + assert!(frames.is_complete()); } #[cfg(target_os = "linux")] @@ -758,7 +764,7 @@ mod tests { { println!("claimed"); } - std::mem::forget(frame); + let _ = frame; // Wait to be killed; nothing ever unparks this thread. loop { std::thread::park(); @@ -783,8 +789,8 @@ mod tests { let mut iter = frames.iter(); assert!(iter.next().unwrap() == b"alive"); assert!(iter.next() == None); - // Death runs no drop code, so the killed writer's lost frame does not - // mark the channel incomplete. + // The killed writer left only an unfinished slot; the counters + // stayed within their limits, so the channel is complete. assert!(frames.is_complete()); } } diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs index 5429dbbb6..91b291e9e 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs @@ -49,10 +49,10 @@ impl Frames { /// Whether every record a writer published made it in. /// - /// False when a live writer lost a record before the channel closed - /// (capacity exhaustion or an abandoned frame): the frames then - /// under-report what writers went on to do, and consumers that need - /// completeness must reject them. + /// False when the region ran out of space before the channel closed: a + /// claim failed, its record was lost, and the frames under-report what + /// writers went on to do. Consumers that need completeness must reject + /// them. #[must_use] pub const fn is_complete(&self) -> bool { self.complete @@ -109,8 +109,10 @@ pub(super) unsafe fn close(mem: M) -> Result, ProtocolE // it, later ones land in slots this pass never visits. The count is // clamped to the table capacity, so a counter inflated by failed // claims (or by a foreign scribble) degrades to a full-table sweep, - // not an error. - let slot_count = state.snapshot_claims(); + // not an error — and an overshot counter is exactly how the + // snapshot learns that a record was lost (rule 1 in `state`'s + // ordering contract). + let (slot_count, is_complete) = state.snapshot(); // Gate further claims. Cheap: the creator pre-faulted this page // where first touches are expensive. Claims racing between the @@ -123,12 +125,7 @@ pub(super) unsafe fn close(mem: M) -> Result, ProtocolE // of the descriptor table can no longer change — late writers lose // their commit race against `ABORTED`. spans = freeze_committed_spans(state, slot_count)?; - - // Read the incomplete flag only after freezing: a writer sets it - // before performing an operation whose record was lost, so any flag - // this load misses belongs to an operation performed after the - // boundary (rule 1 in `state`'s ordering contract). - complete = !state.is_incomplete(); + complete = is_complete; } Ok(Frames { mem, spans, complete }) diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/state.rs b/crates/fspy_shared/src/ipc/channel/shm_io/state.rs index 0d05fb38e..a3586fa4e 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/state.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/state.rs @@ -11,18 +11,19 @@ //! //! # Shared atomics //! -//! The header holds three independent monotonic `AtomicU64`s: +//! The header holds two independent monotonic `AtomicU64` counters: //! //! - the **claim counter**: bit 63 is the CLOSED gate, the low bits count //! claims ever attempted. Claiming is one wait-free `fetch_add`; the //! returned old value carries the claim's slot index, the gate, and — by //! comparison against the fixed table capacity — the capacity verdict. -//! Failed claims still count, so the counter can overshoot the capacity; -//! readers clamp instead of trusting it. //! - the **payload counter**: payload bytes ever reserved, bumped by another -//! wait-free `fetch_add`. Also overshoots on failure. Never read by the -//! receiver — committed descriptors are self-describing. -//! - the **incomplete flag**: nonzero once a live writer lost a record. +//! wait-free `fetch_add`. +//! +//! Failed claims still count, so a counter past its limit is the record of +//! a lost frame: the receiver derives completeness from exactly that, and +//! clamps instead of trusting the counts. Committed descriptors carry +//! their own offset and length, so the counters never locate data. //! //! # Memory-ordering contract //! @@ -35,11 +36,11 @@ //! Claims publish no payload data, so `Relaxed` suffices throughout. //! The CLOSED gate only stops stragglers from claiming (and allocating //! pages) forever; any claim admitted between the snapshot and the gate -//! lands beyond the snapshot and is never observed. The incomplete flag -//! rides the same rule: a writer sets it (`Relaxed` RMW) before -//! performing the operation whose record was lost, and the receiver -//! re-reads it after freezing; a flag the receiver misses therefore -//! belongs to an operation performed after the boundary. +//! lands beyond the snapshot and is never observed. Completeness rides +//! the same rule: a failed claim's counter bump is its loss report, made +//! before the writer performs the operation whose record was lost — so +//! the snapshot either sees the overshoot, or the loss belongs to an +//! operation performed after the boundary. //! 2. **Writer commit** — the slot compare-and-swap uses `Release` //! ([`SharedState::commit`]): every payload write happens-before the //! committed descriptor becomes visible. @@ -63,11 +64,9 @@ const CLOSED: u64 = 1 << 63; struct Header { /// Bit 63 is the CLOSED gate; the low bits count claims ever attempted. claims: AtomicU64, - /// Nonzero once a live writer lost a record. - incomplete: AtomicU64, /// Payload bytes ever reserved, including by failed claims. payload_reserved: AtomicU64, - _reserved: [u64; 5], + _reserved: [u64; 6], } const _: () = assert!(size_of::
() == layout::HEADER_LEN); @@ -168,11 +167,17 @@ impl SharedState<'_> { /// Atomically reserves one descriptor slot and one payload span. /// - /// Wait-free: two `fetch_add`s, no retry loop (rule 1). + /// Wait-free: two `fetch_add`s, no retry loop (rule 1). A claim that + /// does not fit fails, and its counter bumps are what tell the + /// receiver a record was lost. + /// + /// # Panics + /// + /// Panics when `payload_len` exceeds [`layout::MAX_PAYLOAD_LEN`] — a + /// caller error, not a capacity condition, and the one loss the + /// counters could not record. pub(super) fn try_claim(self, payload_len: usize) -> Result { - if payload_len > layout::MAX_PAYLOAD_LEN { - return Err(ReserveError::Capacity); - } + assert!(payload_len <= layout::MAX_PAYLOAD_LEN); let reserved_len = layout::reserved_payload_len(payload_len); // Payload bytes first, so a payload-capacity failure does not burn a @@ -206,26 +211,16 @@ impl SharedState<'_> { }) } - /// Records that a frame this process may still act on was lost. - /// - /// Must be called before the operation whose record was lost is - /// performed (rule 1). - pub(super) fn flag_incomplete(self) { - self.header.incomplete.fetch_or(1, Ordering::Relaxed); - } - - /// Whether any live writer lost a record. Read after the freeze pass - /// (rule 1). - pub(super) fn is_incomplete(self) -> bool { - self.header.incomplete.load(Ordering::Relaxed) != 0 - } - - /// Snapshots the number of admitted claims: the receiver's close - /// boundary (rule 1). Clamped to the table capacity because failed - /// claims overshoot the counter. - pub(super) fn snapshot_claims(self) -> usize { - let claims = self.header.claims.load(Ordering::Relaxed); - usize::try_from(claims & !CLOSED).unwrap_or(usize::MAX).min(self.table.len()) + /// Snapshots the claim and payload counters: the receiver's close + /// boundary (rule 1). Returns the admitted slot count, clamped to the + /// table capacity, and whether every record made it — false once either + /// counter overshot its limit, which is how a failed claim reports the + /// loss. + pub(super) fn snapshot(self) -> (usize, bool) { + let claims = self.header.claims.load(Ordering::Relaxed) & !CLOSED; + let payload = self.header.payload_reserved.load(Ordering::Relaxed); + let complete = claims <= self.table.len() as u64 && payload <= self.payloads.len() as u64; + (usize::try_from(claims).unwrap_or(usize::MAX).min(self.table.len()), complete) } /// Sets the CLOSED gate so stragglers stop claiming. diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs index 650ac4d00..1397d20b3 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs @@ -1,7 +1,6 @@ //! The writer side: claiming, filling, and committing frames. use std::{ - mem::ManuallyDrop, num::NonZeroUsize, ops::{Deref, DerefMut}, }; @@ -30,8 +29,8 @@ pub enum ClaimError { /// outside the channel's boundary. #[error("the channel has been closed by the receiver")] Closed, - /// The frame is oversized or the region is full. The claim has already - /// recorded the loss, so the channel will report itself incomplete. + /// The region is full. The claim's counter bumps already recorded the + /// loss, so the channel will report itself incomplete. #[error("no space left in the shared-memory region")] Capacity, } @@ -85,19 +84,18 @@ impl ShmWriter { /// Claims a frame of exactly `frame_size` bytes. /// /// The frame is invisible to the receiver until [`FrameMut::finish`] - /// commits it. Dropping the frame without finishing abandons the claim - /// and marks the channel incomplete. + /// commits it. Dropping the frame without finishing abandons the claim: + /// the receiver ignores the slot, exactly as if the writer had died. + /// + /// # Panics + /// + /// Panics when `frame_size` exceeds the frame limit of `i32::MAX` + /// bytes. pub fn claim_frame(&self, frame_size: NonZeroUsize) -> Result, ClaimError> { let state = self.state(); let reservation = state.try_claim(frame_size.get()).map_err(|err| match err { ReserveError::Closed => ClaimError::Closed, - ReserveError::Capacity => { - // The record is lost but this process lives on to perform the - // operation, so flag the channel incomplete before the caller - // proceeds. - state.flag_incomplete(); - ClaimError::Capacity - } + ReserveError::Capacity => ClaimError::Capacity, })?; let content_ptr = state.payload_ptr(reservation.payload_offset); @@ -119,22 +117,6 @@ impl ShmWriter { pub fn write_encoded>( &self, value: &T, - ) -> Result<(), WriteEncodedError> { - let result = self.write_encoded_inner(value); - if let Err(err) = &result - && !matches!(err, WriteEncodedError::Claim(_)) - { - // A pre-claim failure also loses a record this live process will - // still act on; claim errors have already been recorded (or are - // an ordinary post-close skip). - self.state().flag_incomplete(); - } - result - } - - fn write_encoded_inner>( - &self, - value: &T, ) -> Result<(), WriteEncodedError> { let serialized_size = usize::try_from(T::serialized_size(value)?).expect("serialized size exceeds usize"); @@ -178,12 +160,11 @@ impl ShmWriter { /// An exclusively owned, claimed-but-unpublished frame. /// /// [`FrameMut::finish`] commits the frame; it is the only way to make the -/// payload visible to the receiver. Dropping the frame instead abandons the -/// claim: the slot stays unfinished (the receiver will abort and ignore it) -/// and the channel is marked incomplete, because the dropping process is alive -/// to perform the operation this record was meant to describe. A process -/// that dies mid-frame runs no drop code and marks nothing — correctly so, -/// since records are published before the recorded operation is performed. +/// payload visible to the receiver. Dropping the frame instead abandons +/// the claim: the slot stays unfinished and the receiver ignores it, +/// exactly as if the writer had died there. A writer that abandons a frame +/// and still performs the operation it described steps outside the usage +/// contract — records are published before the recorded operation. pub struct FrameMut<'a> { state: SharedState<'a>, slot_index: usize, @@ -218,16 +199,9 @@ impl FrameMut<'_> { /// Commits the frame, making it visible to the receiver. /// /// If the receiver closed the channel and aborted this frame's slot - /// first, the frame is silently discarded: the access belongs to the - /// closed run's boundary race and is intentionally excluded either way. + /// first, the frame is silently discarded: the record belongs to the + /// close race and is intentionally excluded either way. pub fn finish(self) { - let this = ManuallyDrop::new(self); - this.state.commit(this.slot_index, this.descriptor); - } -} - -impl Drop for FrameMut<'_> { - fn drop(&mut self) { - self.state.flag_incomplete(); + self.state.commit(self.slot_index, self.descriptor); } } From 47ce474397ae342cc6868166fd5139034817a537 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sat, 15 Aug 2026 11:01:04 +0800 Subject: [PATCH 22/92] refactor(fspy): one skip-on-failure send where the usage lives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The protocol had two ways to write a record — write_encoded, with an error enum only one caller half-used, and the hand-rolled claim/serialize/finish sequence in the Unix client. Both callers want the same thing: serialize the record into a frame and skip it on any failure, because an intercepted call must proceed no matter what. That helper now lives once, on the channel's Sender; shm_io keeps only claim, fill, and finish. Also: a plain-words README section on how a full region is handled, and a mermaid dependency graph of the module files as a reading order. Co-Authored-By: Claude Fable 5 --- crates/fspy_client_unix/src/lib.rs | 45 +++++-------------- .../src/windows/client.rs | 8 ++-- crates/fspy_shared/src/ipc/channel/mod.rs | 33 ++++++++++++-- .../src/ipc/channel/shm_io/README.md | 41 +++++++++++++++-- .../fspy_shared/src/ipc/channel/shm_io/mod.rs | 2 +- .../src/ipc/channel/shm_io/writer.rs | 38 ---------------- 6 files changed, 82 insertions(+), 85 deletions(-) diff --git a/crates/fspy_client_unix/src/lib.rs b/crates/fspy_client_unix/src/lib.rs index 6dad097ac..119aa5670 100644 --- a/crates/fspy_client_unix/src/lib.rs +++ b/crates/fspy_client_unix/src/lib.rs @@ -8,7 +8,7 @@ pub mod convert; pub mod raw_exec; -use std::{ffi::OsStr, fmt::Debug, num::NonZeroUsize, os::unix::ffi::OsStrExt as _, path::Path}; +use std::{ffi::OsStr, fmt::Debug, os::unix::ffi::OsStrExt as _, path::Path}; use convert::{ToAbsolutePath, ToAccessMode}; use fspy_shared::ipc::{PathAccess, channel::Sender}; @@ -18,7 +18,6 @@ use fspy_shared_unix::{ spawn::{PreExec, handle_exec}, }; use raw_exec::RawExec; -use wincode::Serialize as _; pub struct Client { encoded_payload: EncodedPayload, @@ -66,43 +65,20 @@ impl Client { Self { encoded_payload, ipc_sender } } - fn send(&self, mode: fspy_shared::ipc::AccessMode, path: &Path) -> anyhow::Result<()> { + fn send(&self, mode: fspy_shared::ipc::AccessMode, path: &Path) { let Some(ipc_sender) = &self.ipc_sender else { - return Ok(()); + return; }; let path_bytes = path.as_os_str().as_bytes(); if path_bytes.starts_with(b"/dev/") || (cfg!(target_os = "linux") && (path_bytes.starts_with(b"/proc/") || path_bytes.starts_with(b"/sys/"))) { - return Ok(()); + return; } - let path_access = PathAccess { mode, path: path.into() }; - let serialized_size = usize::try_from(PathAccess::serialized_size(&path_access)?) - .expect("serialized size exceeds usize"); - - let frame_size = NonZeroUsize::new(serialized_size) - .expect("fspy: encoded PathAccess should never be empty"); - - let Ok(mut frame) = ipc_sender.claim_frame(frame_size) else { - // The receiver has closed the channel (this process outlived the - // run's tracking boundary) or the region is full (a loss the - // receiver sees as counter overshoot). Either way the - // interception must proceed without a record — a preload library - // can never panic its host process. - return Ok(()); - }; - let mut writer: &mut [u8] = &mut frame; - // A serialization failure drops `frame` unfinished; the receiver - // ignores the abandoned slot. - PathAccess::serialize_into(&mut writer, &path_access)?; - debug_assert_eq!(writer.len(), 0); - if !writer.is_empty() { - return Ok(()); - } - frame.finish(); - - Ok(()) + // The interception proceeds whether or not the record could be + // sent — a preload library can never panic its host process. + ipc_sender.send(&PathAccess { mode, path: path.into() }); } /// Resolves and reports an exec before forwarding its transformed arguments. @@ -127,9 +103,7 @@ impl Client { // null-terminated arrays, as provided by the caller. let mut exec = unsafe { raw_exec.to_exec() }; let pre_exec = handle_exec(&mut exec, config, &self.encoded_payload, |mode, path| { - // A failure here is a post-close skip, a full region, or an - // fspy bug; the exec itself must proceed regardless. - let _ = self.send(mode, path); + self.send(mode, path); })?; RawExec::from_exec(exec, |raw_command| f(raw_command, pre_exec)) } @@ -156,6 +130,7 @@ impl Client { let Some(abs_path) = path.to_absolute_path(&arena)? else { return Ok(()); }; - self.send(mode, Path::new(OsStr::from_bytes(abs_path.as_units()))) + self.send(mode, Path::new(OsStr::from_bytes(abs_path.as_units()))); + Ok(()) } } diff --git a/crates/fspy_preload_windows/src/windows/client.rs b/crates/fspy_preload_windows/src/windows/client.rs index ea5f532ab..76d2f79fd 100644 --- a/crates/fspy_preload_windows/src/windows/client.rs +++ b/crates/fspy_preload_windows/src/windows/client.rs @@ -40,11 +40,9 @@ impl<'a> Client<'a> { let Some(sender) = &self.ipc_sender else { return; }; - // A failed write means the receiver closed the channel (this process - // outlived the run's tracking boundary) or the region was full — a - // loss the receiver sees as counter overshoot. The intercepted call - // must proceed either way; a detours DLL can never panic its host. - let _ = sender.write_encoded(&access); + // The intercepted call proceeds whether or not the record could be + // sent; a detours DLL can never panic its host. + sender.send(&access); } pub unsafe fn prepare_child_process(&self, child_handle: HANDLE) -> BOOL { diff --git a/crates/fspy_shared/src/ipc/channel/mod.rs b/crates/fspy_shared/src/ipc/channel/mod.rs index b36fd1c0b..0ecfb49eb 100644 --- a/crates/fspy_shared/src/ipc/channel/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/mod.rs @@ -7,20 +7,20 @@ mod shm_io; -use std::{env::temp_dir, ffi::OsStr, io, ops::Deref, path::PathBuf}; +use std::{env::temp_dir, ffi::OsStr, io, num::NonZeroUsize, ops::Deref, path::PathBuf}; use allocator_api2::alloc::Global; use fspy_nostd::Fat; use fspy_nostd_alloc::OsCString; use fspy_shm::Mapping; use shm_io::ShmWriter; -pub use shm_io::{ClaimError, FrameMut, WriteEncodedError}; +pub use shm_io::{ClaimError, FrameMut}; /// The committed frames of a closed channel; borrows the shared mapping, /// which stays alive (and mapped) until this value drops. pub type Frames = shm_io::Frames; use uuid::Uuid; -use wincode::{SchemaRead, SchemaWrite}; +use wincode::{SchemaRead, SchemaWrite, Serialize as _, config::DefaultConfig}; use super::IpcStr; @@ -206,6 +206,33 @@ pub struct Sender { writer: ShmWriter, } +impl Sender { + /// Serializes one record into a committed frame. + /// + /// A record that cannot be sent is skipped, because that is all a + /// sender inside an intercepted call can do: the channel may have + /// closed (the record belongs past its boundary), or the region may be + /// full (a loss the receiver sees as counter overshoot and reports via + /// incompleteness). + pub fn send>(&self, value: &T) { + let Ok(serialized_size) = T::serialized_size(value) else { + return; + }; + let Ok(Some(frame_size)) = usize::try_from(serialized_size).map(NonZeroUsize::new) else { + return; + }; + let Ok(mut frame) = self.writer.claim_frame(frame_size) else { + return; + }; + let mut buf: &mut [u8] = &mut frame; + if T::serialize_into(&mut buf, value).is_err() || !buf.is_empty() { + // An abandoned frame; the receiver ignores its slot. + return; + } + frame.finish(); + } +} + impl Deref for Sender { type Target = ShmWriter; diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/README.md b/crates/fspy_shared/src/ipc/channel/shm_io/README.md index 2d7d39328..8ce915049 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/README.md +++ b/crates/fspy_shared/src/ipc/channel/shm_io/README.md @@ -86,6 +86,25 @@ after the channel closed — both safe to ignore. A writer that records _after_ acting, or that abandons a frame and performs the action anyway, steps outside this rule and loses records silently. +## When the region fills up + +The region is large — the payload area of a 4 GiB region holds tens of +millions of records — but it is not endless. When a claim asks for more +room than is left, in the payload area or in the table, the claim fails. +The writer skips that one record and carries on: recording must never +stop or crash the program doing the work. + +The loss is not silent. The failed claim still moved a counter, and +counters never move backwards, so from that moment on the counter stands +past its limit. When the receiver closes the channel it compares both +counters against their limits; if either went past, `is_complete` returns +false, and a reader that needs the full picture knows to throw the result +away. + +One limit is different: a single frame holds at most 2 GiB. Asking for +more is a mistake in the calling code, so it panics instead of counting +as overflow. + ## Closing and reading The receiver closes once: @@ -157,6 +176,22 @@ CLAIMED (slot 0) ---+ | `writer.rs` | Claim, fill, finish. Explains why a claimed payload span belongs to its writer alone. | | `reader.rs` | Close (snapshot, gate, freeze, validate) and `Frames`. Explains why handing out references to committed spans is safe. | -Each file explains itself without the others, so the protocol can be -reviewed module by module: the pure math first, then the atomics, then the -two modules that say who may touch which bytes. +Arrows point at what a file depends on: + +```mermaid +graph TD + mod["mod.rs
public surface"] --> writer["writer.rs"] + mod --> reader["reader.rs"] + writer --> state["state.rs
shared atomics"] + writer --> slot["slot.rs"] + reader --> state + reader --> slot + reader --> layout["layout.rs
pure math"] + state --> slot + state --> layout + slot --> layout +``` + +Read from the bottom up — `layout.rs` and `slot.rs` first, then +`state.rs`, then `writer.rs` and `reader.rs`, then `mod.rs` — and each +file only needs the ones below it. diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs index 78c7c2466..ac875ac47 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs @@ -79,7 +79,7 @@ use std::ptr::slice_from_raw_parts_mut; use fspy_shm::Mapping; pub use reader::{Frames, ProtocolError}; -pub use writer::{ClaimError, FrameMut, ShmWriter, WriteEncodedError}; +pub use writer::{ClaimError, FrameMut, ShmWriter}; // The region arithmetic in `layout` relies on `usize` accommodating sums of // 32-bit-bounded quantities, and the descriptor protocol on native 64-bit diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs index 1397d20b3..cccee2adb 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs @@ -5,8 +5,6 @@ use std::{ ops::{Deref, DerefMut}, }; -use wincode::{SchemaWrite, Serialize as _, config::DefaultConfig}; - use super::{ AsRawSlice, slot, state::{ReserveError, SharedState}, @@ -35,18 +33,6 @@ pub enum ClaimError { Capacity, } -#[derive(thiserror::Error, Debug)] -pub enum WriteEncodedError { - #[error("failed to encode value into shared memory")] - EncodeError(#[from] wincode::error::WriteError), - #[error("tried to write a frame of zero size into shared memory")] - ZeroSizedFrame, - #[error("encoded size diverged from the declared serialized size")] - SizeMismatch, - #[error(transparent)] - Claim(#[from] ClaimError), -} - impl ShmWriter { /// Creates a writer backed by a shared-memory region. /// @@ -113,30 +99,6 @@ impl ShmWriter { }) } - /// Writes one encoded value as a committed frame. - pub fn write_encoded>( - &self, - value: &T, - ) -> Result<(), WriteEncodedError> { - let serialized_size = - usize::try_from(T::serialized_size(value)?).expect("serialized size exceeds usize"); - - let Some(frame_size) = NonZeroUsize::new(serialized_size) else { - return Err(WriteEncodedError::ZeroSizedFrame); - }; - let mut frame = self.claim_frame(frame_size)?; - - let mut writer: &mut [u8] = &mut frame; - T::serialize_into(&mut writer, value)?; - if !writer.is_empty() { - // Dropping the partially filled frame leaves it unpublished. - return Err(WriteEncodedError::SizeMismatch); - } - - frame.finish(); - Ok(()) - } - // Unwrap `self` and return the underlying memory. #[cfg(test)] pub fn into_memory(self) -> M { From 46871b9eb9e646b3867ae62c016deeda942d3ff2 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sat, 15 Aug 2026 11:26:25 +0800 Subject: [PATCH 23/92] refactor(fspy-shm): consolidate to three files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The six-way split was designed around machinery the simplifications have since deleted, and its narrative had drifted: writer and reader derive their own payload references, so state was never the only file touching shared memory. Merge to the boundary that still earns its keep — pure integer math versus code that touches the mapping: - layout.rs absorbs the descriptor codec (both plain arithmetic) - shared.rs is state + writer + reader in reading order, with the reservation types and SharedState going private to it - mod.rs keeps the surface, overview docs, and integration tests Co-Authored-By: Claude Fable 5 --- .../src/ipc/channel/shm_io/README.md | 30 +- .../src/ipc/channel/shm_io/layout.rs | 110 +++- .../fspy_shared/src/ipc/channel/shm_io/mod.rs | 24 +- .../src/ipc/channel/shm_io/reader.rs | 155 ----- .../src/ipc/channel/shm_io/shared.rs | 618 ++++++++++++++++++ .../src/ipc/channel/shm_io/slot.rs | 109 --- .../src/ipc/channel/shm_io/state.rs | 295 --------- .../src/ipc/channel/shm_io/writer.rs | 169 ----- 8 files changed, 743 insertions(+), 767 deletions(-) delete mode 100644 crates/fspy_shared/src/ipc/channel/shm_io/reader.rs create mode 100644 crates/fspy_shared/src/ipc/channel/shm_io/shared.rs delete mode 100644 crates/fspy_shared/src/ipc/channel/shm_io/slot.rs delete mode 100644 crates/fspy_shared/src/ipc/channel/shm_io/state.rs delete mode 100644 crates/fspy_shared/src/ipc/channel/shm_io/writer.rs diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/README.md b/crates/fspy_shared/src/ipc/channel/shm_io/README.md index 8ce915049..97c5f281a 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/README.md +++ b/crates/fspy_shared/src/ipc/channel/shm_io/README.md @@ -167,31 +167,19 @@ CLAIMED (slot 0) ---+ ## Files -| File | Role | -| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `mod.rs` | Public surface (`ShmWriter`, `FrameMut`, `close`, `Frames`), the protocol overview docs, and the integration tests — they run against a mocked region and are miri-clean (`cargo miri test -p fspy_shared shm_io`). | -| `layout.rs` | Pure geometry: the sizing rule that turns a mapping length into table and payload bounds, payload rounding, and span validation. Plain integer math, no pointers, no atomics. | -| `slot.rs` | The descriptor codec: pack and unpack one slot value, classify it as unfinished, aborted, committed, or corrupt. Pure. | -| `state.rs` | The only module that touches shared memory: one unsafe borrow builds three typed views — the `repr(C)` header struct, the descriptor table as a slice of atomics, and the raw payload area — and the three-rule memory-ordering contract lives here. | -| `writer.rs` | Claim, fill, finish. Explains why a claimed payload span belongs to its writer alone. | -| `reader.rs` | Close (snapshot, gate, freeze, validate) and `Frames`. Explains why handing out references to committed spans is safe. | +| File | Role | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `mod.rs` | Public surface (`ShmWriter`, `FrameMut`, `close`, `Frames`), the protocol overview docs, and the integration tests — they run against a mocked region and are miri-clean (`cargo miri test -p fspy_shared shm_io`). | +| `shared.rs` | Everything that touches the mapping, in reading order: the typed views of the region and the ordering contract, then the writer side (claim, fill, finish), then the receiver side (close and `Frames`) with the reasoning for its borrows. | +| `layout.rs` | Everything that is plain integer math: the sizing rule that turns a mapping length into table and payload bounds, payload rounding, span validation, and the descriptor codec. No pointers, no atomics. | Arrows point at what a file depends on: ```mermaid graph TD - mod["mod.rs
public surface"] --> writer["writer.rs"] - mod --> reader["reader.rs"] - writer --> state["state.rs
shared atomics"] - writer --> slot["slot.rs"] - reader --> state - reader --> slot - reader --> layout["layout.rs
pure math"] - state --> slot - state --> layout - slot --> layout + mod["mod.rs
public surface"] --> shared["shared.rs
everything touching the mapping"] + shared --> layout["layout.rs
pure math"] ``` -Read from the bottom up — `layout.rs` and `slot.rs` first, then -`state.rs`, then `writer.rs` and `reader.rs`, then `mod.rs` — and each -file only needs the ones below it. +Read from the bottom up — `layout.rs`, then `shared.rs`, then `mod.rs` — +and each file only needs the ones below it. diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs index 6ff3b86cb..8ef5fed1c 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs @@ -6,10 +6,10 @@ //! | header | descriptor table (SLOTS slots) | payloads (grow up) | //! ``` //! -//! The header and table have compile-time layout and live in `state`'s -//! `repr(C)` region struct; this module holds the plain-integer arithmetic -//! around them: the table-sizing rule for a given capacity, payload -//! rounding, and payload-span validation. No pointers, no atomics. +//! This module holds everything about the region that is plain integer +//! math: the sizing rule that turns a mapping length into table and +//! payload bounds, payload rounding, payload-span validation, and the +//! descriptor-slot codec. No pointers, no atomics. //! //! Overflow safety follows from one bound enforced at construction time: //! the mapping length never exceeds [`MAX_MAPPING_LEN`], so all offsets fit @@ -122,6 +122,76 @@ impl PayloadSpan { } } +// --- The descriptor slot codec --------------------------------------------- +// +// One slot is a 64-bit value that publishes a frame: +// +// ```text +// bit 63 bits 32..=62 bits 0..=31 +// ABORTED payload length (31) payload offset (32) +// ``` +// +// | Value | State | +// | --------------------- | ----------------------------------------------- | +// | `0` | Unfinished: slot reserved, nothing published | +// | `1 << 63` | Aborted: the receiver froze the unfinished slot | +// | nonzero, bit 63 clear | Committed: offset and length of the payload | +// +// Committed lengths are nonzero (a zero-length frame is never claimed), so a +// committed value is always nonzero and the three states are disjoint. Once +// a slot is committed or aborted, nothing ever changes it again. + +pub(super) const UNFINISHED: u64 = 0; +pub(super) const ABORTED: u64 = 1 << 63; +const LEN_SHIFT: u32 = 32; +const OFFSET_MAX: u64 = u32::MAX as u64; + +/// A decoded descriptor slot value. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(super) enum SlotState { + /// The slot was reserved but no payload has been committed. + Unfinished, + /// The receiver froze the slot; its payload is permanently unreachable. + Aborted, + /// A payload was committed. The range is *unvalidated*: it must pass + /// [`PayloadSpan::validate`] before any access. + Committed { payload_offset: usize, payload_len: usize }, + /// A value no protocol operation produces. The region is corrupt. + Corrupt, +} + +/// Encodes a committed descriptor. +/// +/// The caller guarantees `payload_len` is `1..=MAX_PAYLOAD_LEN` and +/// `payload_offset` fits 32 bits; both hold for any admitted reservation. +pub(super) fn committed(payload_offset: usize, payload_len: usize) -> u64 { + debug_assert!(payload_len > 0 && payload_len <= MAX_PAYLOAD_LEN); + debug_assert!(payload_offset as u64 <= OFFSET_MAX); + ((payload_len as u64) << LEN_SHIFT) | payload_offset as u64 +} + +/// Decodes a slot value read back from shared memory. +pub(super) const fn decode(bits: u64) -> SlotState { + match bits { + UNFINISHED => SlotState::Unfinished, + ABORTED => SlotState::Aborted, + _ if bits & ABORTED != 0 => SlotState::Corrupt, + _ => { + // Bit 63 is clear, so the length field is at most 31 bits and + // cannot exceed `MAX_PAYLOAD_LEN`. + let payload_len = (bits >> LEN_SHIFT) as usize; + let payload_offset = (bits & OFFSET_MAX) as usize; + if payload_len == 0 { + // A nonzero offset with a zero length: not a committed value, + // because committed lengths are nonzero. + SlotState::Corrupt + } else { + SlotState::Committed { payload_offset, payload_len } + } + } + } +} + #[cfg(test)] mod tests { use assert2::assert; @@ -185,4 +255,36 @@ mod tests { // Oversized lengths are rejected before any arithmetic. assert!(PayloadSpan::validate(mapping_len, base, MAX_PAYLOAD_LEN + 1).is_none()); } + + #[test] + fn decode_recognizes_the_three_states() { + assert!(decode(UNFINISHED) == SlotState::Unfinished); + assert!(decode(ABORTED) == SlotState::Aborted); + assert!( + decode(committed(1016, 5)) + == SlotState::Committed { payload_offset: 1016, payload_len: 5 } + ); + } + + #[test] + fn committed_roundtrips_the_extremes() { + let max_offset = u32::MAX as usize; + let max_len = MAX_PAYLOAD_LEN; + assert!( + decode(committed(max_offset, max_len)) + == SlotState::Committed { payload_offset: max_offset, payload_len: max_len } + ); + assert!( + decode(committed(0, 1)) == SlotState::Committed { payload_offset: 0, payload_len: 1 } + ); + } + + #[test] + fn decode_rejects_values_no_writer_produces() { + // Aborted bit combined with other bits. + assert!(decode(ABORTED | 1) == SlotState::Corrupt); + assert!(decode(ABORTED | (1 << 62)) == SlotState::Corrupt); + // Zero length with a nonzero offset. + assert!(decode(42) == SlotState::Corrupt); + } } diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs index ac875ac47..cf18f8bee 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs @@ -23,13 +23,13 @@ //! and payload bounds from the mapped size. One borrow constructs typed //! views of the header (a `repr(C)` struct of two monotonic `AtomicU64` //! counters: claims, carrying the CLOSED gate bit, and payload bytes -//! reserved) and of the descriptor table (a slice of atomics); the payload -//! area stays untyped bytes. +//! reserved) and of the descriptor table (a slice of atomics) — see +//! [`shared`]; the payload area stays untyped bytes. //! A claim is two wait-free `fetch_add`s — one reserves payload bytes, one //! reserves a descriptor slot — validated against the fixed region bounds //! from the returned old values. Failed claims overshoot the counters //! harmlessly: readers clamp to the region capacities, and committed -//! descriptors are self-describing ([`slot`]), so the counters never locate +//! descriptors are self-describing ([`layout`]), so the counters never locate //! data. Every slot has a fixed location, so an unfinished frame can never //! hide a later one. //! @@ -45,11 +45,11 @@ //! //! A payload becomes reachable only through its committed descriptor, and a //! descriptor is committed only after the payload is fully written -//! ([`state`]'s ordering contract). The receiver never derives frame +//! ([`shared`]'s ordering contract). The receiver never derives frame //! locations from payload bytes, and the borrows [`Frames`] hands out cover //! exactly the validated committed spans — immutable under the protocol, //! and disjoint from everything a live writer may still touch (see -//! [`reader`]'s trust argument). +//! [`shared`]'s trust argument). //! //! # Close boundary //! @@ -70,16 +70,12 @@ //! checks, heartbeats, or timeouts. mod layout; -mod reader; -mod slot; -mod state; -mod writer; +mod shared; use std::ptr::slice_from_raw_parts_mut; use fspy_shm::Mapping; -pub use reader::{Frames, ProtocolError}; -pub use writer::{ClaimError, FrameMut, ShmWriter}; +pub use shared::{ClaimError, FrameMut, Frames, ProtocolError, ShmWriter}; // The region arithmetic in `layout` relies on `usize` accommodating sums of // 32-bit-bounded quantities, and the descriptor protocol on native 64-bit @@ -102,7 +98,7 @@ impl AsRawSlice for Mapping { /// Closes the channel without waiting for writers and returns the committed /// frames as borrows of the region, which moves into the returned -/// [`Frames`]. See [`reader::close`]. +/// [`Frames`]. See [`shared::close`]. /// /// # Safety /// @@ -110,7 +106,7 @@ impl AsRawSlice for Mapping { /// zero-initialized at creation, and accessed only through this protocol. pub unsafe fn close(mem: M) -> Result, ProtocolError> { // SAFETY: forwarded from this function's contract. - unsafe { reader::close(mem) } + unsafe { shared::close(mem) } } /// Materializes the page backing the protocol header without changing @@ -125,7 +121,7 @@ pub unsafe fn close(mem: M) -> Result, ProtocolError> { #[cfg(target_os = "linux")] pub unsafe fn pre_fault(mem: &impl AsRawSlice) { // SAFETY: forwarded from this function's contract. - unsafe { state::SharedState::borrow(mem.as_raw_slice()) }.pre_fault(); + unsafe { shared::pre_fault(mem) } } /// Whether a mapping of `len` bytes can host the protocol at all. diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs deleted file mode 100644 index 91b291e9e..000000000 --- a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs +++ /dev/null @@ -1,155 +0,0 @@ -//! The receiver side: closing the channel and borrowing committed frames. -//! -//! Closing never waits for writers. The close boundary is a snapshot of the -//! claim counter; unfinished slots inside the snapshot are atomically -//! frozen, committed descriptors are validated, and the CLOSED gate is set -//! so stragglers stop claiming. No payload byte is read or copied here: -//! [`Frames`] keeps the mapping alive and hands out borrows of the -//! validated committed spans on demand. -//! -//! Those borrows are sound because of the protocol, not despite it: -//! a committed span is never written again (committing consumes the -//! writer's frame), every borrow covers exactly one validated committed -//! span, and everything a live writer may still touch — counters, slots, -//! its own claimed or aborted spans — is disjoint from every committed -//! span. This rests on the constructor contract that the region is accessed -//! only through this protocol; a process scribbling outside the protocol is -//! outside the trust model. - -use std::{fmt, slice}; - -use super::{ - AsRawSlice, - layout::PayloadSpan, - slot::{self, SlotState}, - state::SharedState, -}; - -/// The committed frames of a closed channel: validated spans borrowed from -/// the mapping, which stays alive inside this value. Dropping it releases -/// the mapping. -pub struct Frames { - mem: M, - spans: Vec, - complete: bool, -} - -impl Frames { - /// Iterates over the committed frames in claim order. - pub fn iter(&self) -> impl Iterator { - let base = self.mem.as_raw_slice().cast::().cast_const(); - self.spans.iter().map(move |span| { - // SAFETY: `close` validated the span against this mapping's - // layout, and a committed span is immutable for the mapping's - // lifetime (see the module docs), so the shared borrow is valid - // for as long as `self` lives. - unsafe { slice::from_raw_parts(base.add(span.offset), span.len) } - }) - } - - /// Whether every record a writer published made it in. - /// - /// False when the region ran out of space before the channel closed: a - /// claim failed, its record was lost, and the frames under-report what - /// writers went on to do. Consumers that need completeness must reject - /// them. - #[must_use] - pub const fn is_complete(&self) -> bool { - self.complete - } -} - -impl fmt::Debug for Frames { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("Frames") - .field("frames", &self.spans.len()) - .field("complete", &self.complete) - .finish_non_exhaustive() - } -} - -/// Shared-memory metadata that could not have been produced by this -/// protocol. The region was corrupted; its frames are unusable. -#[derive(thiserror::Error, Clone, Copy, PartialEq, Eq, Debug)] -pub enum ProtocolError { - #[error("corrupt shared-memory frame descriptor at slot {slot_index}")] - CorruptDescriptor { slot_index: usize }, -} - -/// Closes the channel and returns the committed frames as borrows of the -/// mapping, which moves into the returned [`Frames`]. -/// -/// Never blocks on writers: writers admitted before the snapshot race per -/// slot, and each raced slot independently ends up committed (included) or -/// aborted (excluded). Claims after the snapshot land in slots this pass -/// never visits until the CLOSED gate — set before returning — stops them. -/// See the crate-level protocol docs in [`super`]. -/// -/// # Safety -/// -/// Same contract as [`super::ShmWriter::new`]: `mem` must be a stable, valid -/// pointer to the whole region, zero-initialized at creation and accessed -/// only through this protocol. -/// -/// # Panics -/// -/// Panics when the region is not `u64`-aligned or its size is outside the -/// supported range (see [`SharedState::borrow`]) — a broken caller, not -/// corrupt shared data, which is reported as [`ProtocolError`] instead. -pub(super) unsafe fn close(mem: M) -> Result, ProtocolError> { - let spans; - let complete; - { - // SAFETY: forwarded from this function's contract; the raw slice - // stays valid while `mem` is borrowed here and beyond, since `mem` - // moves into the returned `Frames`. - let state = unsafe { SharedState::borrow(mem.as_raw_slice()) }; - - // The close boundary: claims at or before this snapshot are inside - // it, later ones land in slots this pass never visits. The count is - // clamped to the table capacity, so a counter inflated by failed - // claims (or by a foreign scribble) degrades to a full-table sweep, - // not an error — and an overshot counter is exactly how the - // snapshot learns that a record was lost (rule 1 in `state`'s - // ordering contract). - let (slot_count, is_complete) = state.snapshot(); - - // Gate further claims. Cheap: the creator pre-faulted this page - // where first touches are expensive. Claims racing between the - // snapshot and this gate are dropped soundly (see the module docs - // in `super`). - state.close_claims(); - - // Freeze pass: drive every admitted slot to a terminal state and - // collect the committed spans. After this loop the snapshot's slice - // of the descriptor table can no longer change — late writers lose - // their commit race against `ABORTED`. - spans = freeze_committed_spans(state, slot_count)?; - complete = is_complete; - } - - Ok(Frames { mem, spans, complete }) -} - -fn freeze_committed_spans( - state: SharedState<'_>, - slot_count: usize, -) -> Result, ProtocolError> { - let mut spans = Vec::new(); - for slot_index in 0..slot_count { - match slot::decode(state.freeze(slot_index)) { - SlotState::Aborted => {} - SlotState::Committed { payload_offset, payload_len } => { - let span = PayloadSpan::validate(state.mapping_len(), payload_offset, payload_len) - .ok_or(ProtocolError::CorruptDescriptor { slot_index })?; - spans.push(span); - } - // `freeze` only returns terminal values, so `Unfinished` is - // unreachable and grouped with the corrupt case. - SlotState::Unfinished | SlotState::Corrupt => { - return Err(ProtocolError::CorruptDescriptor { slot_index }); - } - } - } - Ok(spans) -} diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/shared.rs b/crates/fspy_shared/src/ipc/channel/shm_io/shared.rs new file mode 100644 index 000000000..6962d259a --- /dev/null +++ b/crates/fspy_shared/src/ipc/channel/shm_io/shared.rs @@ -0,0 +1,618 @@ +//! Everything that touches shared-memory bytes, in reading order: the +//! typed views of the region, the writer side (claim, fill, finish), and +//! the receiver side (close and [`Frames`]). +//! +//! One unsafe borrow in [`SharedState::borrow`] constructs three typed +//! views of the region — the `repr(C)` [`Header`], the descriptor table as +//! a slice of atomics sized by the mapping length, and the untyped payload +//! area as a raw slice. Every access after that is a plain field access or +//! a bounds-checked index. The payload area stays raw because writers hold +//! exclusive `&mut` borrows into it, which must not alias any shared +//! reference. +//! +//! # Shared atomics +//! +//! The header holds two independent monotonic `AtomicU64` counters: +//! +//! - the **claim counter**: bit 63 is the CLOSED gate, the low bits count +//! claims ever attempted. Claiming is one wait-free `fetch_add`; the +//! returned old value carries the claim's slot index, the gate, and — by +//! comparison against the fixed table capacity — the capacity verdict. +//! - the **payload counter**: payload bytes ever reserved, bumped by another +//! wait-free `fetch_add`. +//! +//! Failed claims still count, so a counter past its limit is the record of +//! a lost frame: the receiver derives completeness from exactly that, and +//! clamps instead of trusting the counts. Committed descriptors carry +//! their own offset and length, so the counters never locate data. +//! +//! # Memory-ordering contract +//! +//! Three synchronization rules cover the whole protocol: +//! +//! 1. **Claim versus close** — the receiver's close boundary is a plain +//! snapshot load of the claim counter: claims ordered at or before the +//! value it reads (in the counter's modification order) are in the +//! snapshot; later ones receive slot indices the receiver never visits. +//! Claims publish no payload data, so `Relaxed` suffices throughout. +//! The CLOSED gate only stops stragglers from claiming (and allocating +//! pages) forever; any claim admitted between the snapshot and the gate +//! lands beyond the snapshot and is never observed. Completeness rides +//! the same rule: a failed claim's counter bump is its loss report, made +//! before the writer performs the operation whose record was lost — so +//! the snapshot either sees the overshoot, or the loss belongs to an +//! operation performed after the boundary. +//! 2. **Writer commit** — the slot compare-and-swap uses `Release` +//! ([`SharedState::commit`]): every payload write happens-before the +//! committed descriptor becomes visible. +//! 3. **Receiver observation** — the freeze compare-and-swap uses `Acquire` +//! on failure ([`SharedState::freeze`]): observing a committed descriptor +//! also makes the payload writes it published visible, so the borrows +//! [`Frames`] later hands out read settled bytes. + +use std::{ + fmt, + num::NonZeroUsize, + ops::{Deref, DerefMut}, + slice, + sync::atomic::{AtomicU64, Ordering}, +}; + +use super::{ + AsRawSlice, + layout::{self, PayloadSpan, SlotState}, +}; + +/// The CLOSED gate bit of the claim counter. The low 63 bits count claims, +/// so no realistic claim volume can carry into the gate. +const CLOSED: u64 = 1 << 63; + +/// The region header: two protocol counters, padded so the descriptor +/// table starts off their cache line and there is room for future header +/// fields, which must start zeroed. +#[repr(C)] +struct Header { + /// Bit 63 is the CLOSED gate; the low bits count claims ever attempted. + claims: AtomicU64, + /// Payload bytes ever reserved, including by failed claims. + payload_reserved: AtomicU64, + _reserved: [u64; 6], +} + +const _: () = assert!(size_of::
() == layout::HEADER_LEN); +const _: () = assert!(align_of::
() == align_of::()); + +/// Why a claim was not admitted. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum ReserveError { + /// The receiver has closed the channel. + Closed, + /// The region is out of capacity. + Capacity, +} + +/// A successful reservation of one descriptor slot and one payload span. +#[derive(Debug)] +struct Reservation { + /// Index of the reserved descriptor slot. + slot_index: usize, + /// Byte offset of the reserved payload span. `u64`-aligned. + payload_offset: usize, +} + +/// A borrowed view of the shared mapping with protocol-level operations: +/// the typed header, the descriptor table sized from the mapping length, +/// and the raw payload area. +#[derive(Clone, Copy)] +struct SharedState<'m> { + header: &'m Header, + table: &'m [AtomicU64], + payloads: *mut [u8], + /// The real mapping length. Not derivable from the parts above: the + /// payload region rounds down to whole `u64`s, and re-deriving the + /// layout from a shortened length could shift the table boundary. + len: usize, +} + +impl SharedState<'_> { + /// Borrows a shared mapping. + /// + /// # Safety + /// + /// - `mem` must be valid for reads and writes for the lifetime `'m` and + /// its address must be stable. + /// - The memory must have been zero-initialized when the region was + /// created, and accessed only through this protocol since. + /// + /// # Panics + /// + /// Panics when the mapping cannot host the protocol at all: base not + /// `u64`-aligned, smaller than the header, or larger than + /// [`layout::MAX_MAPPING_LEN`]. These indicate a broken caller, not + /// runtime data; senders guard untrusted mappings with + /// [`super::is_supported_region_len`] first. + #[expect(clippy::cast_ptr_alignment, reason = "the base is asserted `u64`-aligned below")] + unsafe fn borrow(mem: *mut [u8]) -> Self { + let base = mem.cast::(); + let len = mem.len(); + assert!(base.addr().is_multiple_of(align_of::
())); + assert!((layout::HEADER_LEN..=layout::MAX_MAPPING_LEN).contains(&len)); + // SAFETY: the header and the table lie inside the mapping (the + // header by the assert above, the table by `layout::max_slots`), + // are `u64`-aligned (aligned base, `u64`-multiple offsets), and + // consist entirely of atomics zero-initialized at creation — so + // shared borrows for `'m` are valid even while other threads and + // processes access the same memory through these same atomics. The + // payload area keeps the rest of the mapping as a raw slice; + // `layout` bounds every span carved from it. + unsafe { + Self { + header: &*base.cast::
(), + table: slice::from_raw_parts( + base.add(layout::HEADER_LEN).cast::(), + layout::max_slots(len), + ), + payloads: std::ptr::slice_from_raw_parts_mut( + base.add(layout::payload_base(len)), + layout::payload_region_len(len), + ), + len, + } + } + } + + const fn mapping_len(self) -> usize { + self.len + } + + /// Byte offset where the payload region starts. + const fn payload_base(self) -> usize { + layout::payload_base(self.len) + } + + /// Whether the CLOSED gate has been set. + fn is_closed(self) -> bool { + self.header.claims.load(Ordering::Relaxed) & CLOSED != 0 + } + + /// Atomically reserves one descriptor slot and one payload span. + /// + /// Wait-free: two `fetch_add`s, no retry loop (rule 1). A claim that + /// does not fit fails, and its counter bumps are what tell the + /// receiver a record was lost. + /// + /// # Panics + /// + /// Panics when `payload_len` exceeds [`layout::MAX_PAYLOAD_LEN`] — a + /// caller error, not a capacity condition, and the one loss the + /// counters could not record. + fn try_claim(self, payload_len: usize) -> Result { + assert!(payload_len <= layout::MAX_PAYLOAD_LEN); + let reserved_len = layout::reserved_payload_len(payload_len); + + // Payload bytes first, so a payload-capacity failure does not burn a + // slot. A failed reservation stays counted — overshoot is harmless + // because the counter is not what locates payloads (descriptors are) + // and a `u64` cannot realistically wrap. + let payload_start = + self.header.payload_reserved.fetch_add(reserved_len as u64, Ordering::Relaxed); + // Checked: a foreign scribble of the counter must fail the claim, + // not wrap the bound into an out-of-bounds reservation. + let payload_end = payload_start.checked_add(reserved_len as u64); + if payload_end.is_none_or(|end| end > self.payloads.len() as u64) { + return Err(ReserveError::Capacity); + } + + let claims = self.header.claims.fetch_add(1, Ordering::Relaxed); + if claims & CLOSED != 0 { + return Err(ReserveError::Closed); + } + let slot_index = usize::try_from(claims).expect("claim count exceeds usize"); + if slot_index >= self.table.len() { + return Err(ReserveError::Capacity); + } + + Ok(Reservation { + slot_index, + // In bounds: `payload_start + reserved_len` fits the payload + // region, which ends within the mapping (`layout`). + payload_offset: self.payload_base() + + usize::try_from(payload_start).expect("bounded by the payload region"), + }) + } + + /// Snapshots the claim and payload counters: the receiver's close + /// boundary (rule 1). Returns the admitted slot count, clamped to the + /// table capacity, and whether every record made it — false once either + /// counter overshot its limit, which is how a failed claim reports the + /// loss. + fn snapshot(self) -> (usize, bool) { + let claims = self.header.claims.load(Ordering::Relaxed) & !CLOSED; + let payload = self.header.payload_reserved.load(Ordering::Relaxed); + let complete = claims <= self.table.len() as u64 && payload <= self.payloads.len() as u64; + (usize::try_from(claims).unwrap_or(usize::MAX).min(self.table.len()), complete) + } + + /// Sets the CLOSED gate so stragglers stop claiming. + fn close_claims(self) { + self.header.claims.fetch_or(CLOSED, Ordering::Relaxed); + } + + /// Forces the page backing the header (and the table's first slots) to + /// be materialized by the operating system before anyone touches it on + /// a latency-sensitive path. + /// + /// A compare-exchange of zero with zero on the claim counter: on an + /// untouched region it performs a real write — allocating the first + /// block of a sparse backing file, which can cost milliseconds on + /// journalling filesystems — without changing protocol state. If a + /// claim got there first, the page is already backed and the failed + /// exchange changes nothing. (An `or` of zero would not do: the + /// compiler may lower it to a plain load, which materializes only a + /// hole page without allocating the block.) + /// + /// Only Linux channels use this: elsewhere the first touch is cheap. + #[cfg(target_os = "linux")] + fn pre_fault(self) { + let _ = self.header.claims.compare_exchange(0, 0, Ordering::Relaxed, Ordering::Relaxed); + } + + /// Publishes a committed descriptor into an unfinished slot. + /// + /// Returns false when the receiver aborted the slot first; the payload is + /// then permanently unreachable and the writer must not touch it again + /// either way. + /// + /// # Panics + /// + /// Panics when `slot_index` lies outside the table; callers only pass + /// indices of admitted reservations. + fn commit(self, slot_index: usize, descriptor: u64) -> bool { + // Rule 2: `Release` orders every payload write before the descriptor. + self.table[slot_index] + .compare_exchange(layout::UNFINISHED, descriptor, Ordering::Release, Ordering::Relaxed) + .is_ok() + } + + /// Freezes one slot during close and returns its terminal value: `ABORTED` + /// when the receiver won the race, the committed descriptor otherwise. + /// + /// # Panics + /// + /// Panics when `slot_index` lies outside the table; the receiver only + /// passes indices below its clamped snapshot. + fn freeze(self, slot_index: usize) -> u64 { + // Rule 3: `Acquire` on failure makes a committed payload visible. + match self.table[slot_index].compare_exchange( + layout::UNFINISHED, + layout::ABORTED, + Ordering::AcqRel, + Ordering::Acquire, + ) { + Ok(_) => layout::ABORTED, + Err(terminal) => terminal, + } + } + + /// Pointer to a reserved payload span. The caller owns the span's + /// exclusivity argument. + fn payload_ptr(self, offset: usize) -> *mut u8 { + debug_assert!((self.payload_base()..=self.len).contains(&offset)); + // SAFETY: callers pass offsets of admitted reservations, which + // `layout` keeps inside the payload area. + unsafe { self.payloads.cast::().add(offset - self.payload_base()) } + } +} + +// --- The writer side: claim, fill, finish ---------------------------------- + +/// A concurrent shared-memory frame writer. +/// +/// Safe to use across threads and processes at the same time: frames are +/// reserved with atomic operations, filled in uniquely owned payload spans, +/// and published with an atomic commit (see the ordering contract above). +pub struct ShmWriter { + mem: M, +} + +/// Why a frame could not be claimed. +#[derive(thiserror::Error, Clone, Copy, PartialEq, Eq, Debug)] +pub enum ClaimError { + /// The receiver closed the channel; anything after this point is + /// outside the channel's boundary. + #[error("the channel has been closed by the receiver")] + Closed, + /// The region is full. The claim's counter bumps already recorded the + /// loss, so the channel will report itself incomplete. + #[error("no space left in the shared-memory region")] + Capacity, +} + +impl ShmWriter { + /// Creates a writer backed by a shared-memory region. + /// + /// # Safety + /// + /// - `mem.as_raw_slice()` must return a stable, valid pointer to the + /// whole region for the writer's lifetime. + /// - The region must have been zero-initialized when it was created and + /// accessed only through this protocol since. + /// + /// # Panics + /// + /// Panics when the region is not `u64`-aligned or its size is outside the + /// supported range (see [`SharedState::borrow`]). + pub unsafe fn new(mem: M) -> Self { + // Validate the region geometry eagerly so misuse fails at + // construction, not at the first claim. + // SAFETY: forwarded from this function's contract. + let _ = unsafe { SharedState::borrow(mem.as_raw_slice()) }; + Self { mem } + } + + fn state(&self) -> SharedState<'_> { + // SAFETY: `new` requires the region to stay valid and + // protocol-governed for the writer's lifetime, and it validated the + // geometry. + unsafe { SharedState::borrow(self.mem.as_raw_slice()) } + } + + /// Whether the receiver has closed the channel. + pub fn is_closed(&self) -> bool { + self.state().is_closed() + } + + /// Claims a frame of exactly `frame_size` bytes. + /// + /// The frame is invisible to the receiver until [`FrameMut::finish`] + /// commits it. Dropping the frame without finishing abandons the claim: + /// the receiver ignores the slot, exactly as if the writer had died. + /// + /// # Panics + /// + /// Panics when `frame_size` exceeds the frame limit of `i32::MAX` + /// bytes. + pub fn claim_frame(&self, frame_size: NonZeroUsize) -> Result, ClaimError> { + let state = self.state(); + let reservation = state.try_claim(frame_size.get()).map_err(|err| match err { + ReserveError::Closed => ClaimError::Closed, + ReserveError::Capacity => ClaimError::Capacity, + })?; + + let content_ptr = state.payload_ptr(reservation.payload_offset); + // SAFETY: the claim reserved + // `[payload_offset, payload_offset + frame_size)` exclusively for + // this frame: other writers reserve disjoint spans, and the receiver + // never reads a payload before observing its committed descriptor — + // which `finish` publishes only when it consumes this borrow. + let content = unsafe { slice::from_raw_parts_mut(content_ptr, frame_size.get()) }; + Ok(FrameMut { + state, + slot_index: reservation.slot_index, + descriptor: layout::committed(reservation.payload_offset, frame_size.get()), + content, + }) + } + + // Unwrap `self` and return the underlying memory. + #[cfg(test)] + pub fn into_memory(self) -> M { + self.mem + } + + #[cfg(test)] + pub fn try_write_frame(&self, frame: &[u8]) -> bool { + let Some(frame_size) = NonZeroUsize::new(frame.len()) else { + return false; + }; + let Ok(mut frame_mut) = self.claim_frame(frame_size) else { + return false; + }; + frame_mut.copy_from_slice(frame); + frame_mut.finish(); + true + } +} + +/// An exclusively owned, claimed-but-unpublished frame. +/// +/// [`FrameMut::finish`] commits the frame; it is the only way to make the +/// payload visible to the receiver. Dropping the frame instead abandons +/// the claim: the slot stays unfinished and the receiver ignores it, +/// exactly as if the writer had died there. A writer that abandons a frame +/// and still performs the operation it described steps outside the usage +/// contract — records are published before the recorded operation. +pub struct FrameMut<'a> { + state: SharedState<'a>, + slot_index: usize, + descriptor: u64, + content: &'a mut [u8], +} + +impl fmt::Debug for FrameMut<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("FrameMut") + .field("slot_index", &self.slot_index) + .field("len", &self.content.len()) + .finish_non_exhaustive() + } +} + +impl Deref for FrameMut<'_> { + type Target = [u8]; + + fn deref(&self) -> &Self::Target { + self.content + } +} + +impl DerefMut for FrameMut<'_> { + fn deref_mut(&mut self) -> &mut Self::Target { + self.content + } +} + +impl FrameMut<'_> { + /// Commits the frame, making it visible to the receiver. + /// + /// If the receiver closed the channel and aborted this frame's slot + /// first, the frame is silently discarded: the record belongs to the + /// close race and is intentionally excluded either way. + pub fn finish(self) { + self.state.commit(self.slot_index, self.descriptor); + } +} + +// --- The receiver side: close and Frames ------------------------------------ +// +// Closing never waits for writers, and no payload byte is read or copied: +// `Frames` keeps the mapping alive and hands out borrows of the validated +// committed spans on demand. Those borrows are sound because of the +// protocol, not despite it: a committed span is never written again +// (committing consumes the writer's frame), every borrow covers exactly one +// validated committed span, and everything a live writer may still touch — +// counters, slots, its own claimed or abandoned spans — is disjoint from +// every committed span. This rests on the constructor contract that the +// region is accessed only through this protocol; a process scribbling +// outside the protocol is outside the trust model. + +/// The committed frames of a closed channel: validated spans borrowed from +/// the mapping, which stays alive inside this value. Dropping it releases +/// the mapping. +pub struct Frames { + mem: M, + spans: Vec, + complete: bool, +} + +impl Frames { + /// Iterates over the committed frames in claim order. + pub fn iter(&self) -> impl Iterator { + let base = self.mem.as_raw_slice().cast::().cast_const(); + self.spans.iter().map(move |span| { + // SAFETY: `close` validated the span against this mapping's + // layout, and a committed span is immutable for the mapping's + // lifetime (see the section comment above), so the shared borrow + // is valid for as long as `self` lives. + unsafe { slice::from_raw_parts(base.add(span.offset), span.len) } + }) + } + + /// Whether every record a writer published made it in. + /// + /// False when the region ran out of space before the channel closed: a + /// claim failed, its record was lost, and the frames under-report what + /// writers went on to do. Consumers that need completeness must reject + /// them. + #[must_use] + pub const fn is_complete(&self) -> bool { + self.complete + } +} + +impl fmt::Debug for Frames { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Frames") + .field("frames", &self.spans.len()) + .field("complete", &self.complete) + .finish_non_exhaustive() + } +} + +/// Shared-memory metadata that could not have been produced by this +/// protocol. The region was corrupted; its frames are unusable. +#[derive(thiserror::Error, Clone, Copy, PartialEq, Eq, Debug)] +pub enum ProtocolError { + #[error("corrupt shared-memory frame descriptor at slot {slot_index}")] + CorruptDescriptor { slot_index: usize }, +} + +/// Closes the channel and returns the committed frames as borrows of the +/// mapping, which moves into the returned [`Frames`]. +/// +/// Never blocks on writers: writers admitted before the snapshot race per +/// slot, and each raced slot independently ends up committed (included) or +/// aborted (excluded). Claims after the snapshot land in slots this pass +/// never visits until the CLOSED gate — set before returning — stops them. +/// See the crate-level protocol docs in [`super`]. +/// +/// # Safety +/// +/// Same contract as [`ShmWriter::new`]: `mem` must be a stable, valid +/// pointer to the whole region, zero-initialized at creation and accessed +/// only through this protocol. +/// +/// # Panics +/// +/// Panics when the region is not `u64`-aligned or its size is outside the +/// supported range (see [`SharedState::borrow`]) — a broken caller, not +/// corrupt shared data, which is reported as [`ProtocolError`] instead. +pub(super) unsafe fn close(mem: M) -> Result, ProtocolError> { + let spans; + let complete; + { + // SAFETY: forwarded from this function's contract; the raw slice + // stays valid while `mem` is borrowed here and beyond, since `mem` + // moves into the returned `Frames`. + let state = unsafe { SharedState::borrow(mem.as_raw_slice()) }; + + // The close boundary: claims at or before this snapshot are inside + // it, later ones land in slots this pass never visits. The count is + // clamped to the table capacity, so a counter inflated by failed + // claims (or by a foreign scribble) degrades to a full-table sweep, + // not an error — and an overshot counter is exactly how the + // snapshot learns that a record was lost (rule 1 in the ordering + // contract above). + let (slot_count, is_complete) = state.snapshot(); + + // Gate further claims. Cheap: the creator pre-faulted this page + // where first touches are expensive. Claims racing between the + // snapshot and this gate are dropped soundly (see the module docs + // in `super`). + state.close_claims(); + + // Freeze pass: drive every admitted slot to a terminal state and + // collect the committed spans. After this loop the snapshot's slice + // of the descriptor table can no longer change — late writers lose + // their commit race against `ABORTED`. + spans = freeze_committed_spans(state, slot_count)?; + complete = is_complete; + } + + Ok(Frames { mem, spans, complete }) +} + +fn freeze_committed_spans( + state: SharedState<'_>, + slot_count: usize, +) -> Result, ProtocolError> { + let mut spans = Vec::new(); + for slot_index in 0..slot_count { + match layout::decode(state.freeze(slot_index)) { + SlotState::Aborted => {} + SlotState::Committed { payload_offset, payload_len } => { + let span = PayloadSpan::validate(state.mapping_len(), payload_offset, payload_len) + .ok_or(ProtocolError::CorruptDescriptor { slot_index })?; + spans.push(span); + } + // `freeze` only returns terminal values, so `Unfinished` is + // unreachable and grouped with the corrupt case. + SlotState::Unfinished | SlotState::Corrupt => { + return Err(ProtocolError::CorruptDescriptor { slot_index }); + } + } + } + Ok(spans) +} + +/// Materializes the pages every region touch starts with — see +/// [`SharedState::pre_fault`]. +/// +/// # Safety +/// +/// Same contract as [`ShmWriter::new`]. +#[cfg(target_os = "linux")] +pub(super) unsafe fn pre_fault(mem: &impl AsRawSlice) { + // SAFETY: forwarded from this function's contract. + unsafe { SharedState::borrow(mem.as_raw_slice()) }.pre_fault(); +} diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/slot.rs b/crates/fspy_shared/src/ipc/channel/shm_io/slot.rs deleted file mode 100644 index 712f815e0..000000000 --- a/crates/fspy_shared/src/ipc/channel/shm_io/slot.rs +++ /dev/null @@ -1,109 +0,0 @@ -//! Encoding of one descriptor slot — the 64-bit value that publishes a frame. -//! -//! ```text -//! bit 63 bits 32..=62 bits 0..=31 -//! ABORTED payload length (31) payload offset (32) -//! ``` -//! -//! | Value | State | -//! | ------------------------- | ------------------------------------------- | -//! | `0` | Unfinished: slot reserved, nothing published | -//! | `1 << 63` | Aborted: the receiver froze the unfinished slot | -//! | nonzero, bit 63 clear | Committed: offset and length of the payload | -//! -//! Committed lengths are nonzero (a zero-length frame is never claimed), so a -//! committed value is always nonzero and the three states are disjoint. -//! Committed and aborted are terminal: no protocol operation overwrites them. -//! -//! Like [`super::layout`], this module is pure; the compare-and-swap -//! transitions live in [`super::state`]. - -pub(super) const UNFINISHED: u64 = 0; -pub(super) const ABORTED: u64 = 1 << 63; -const LEN_SHIFT: u32 = 32; -const OFFSET_MAX: u64 = u32::MAX as u64; - -/// A decoded descriptor slot value. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub(super) enum SlotState { - /// The slot was reserved but no payload has been committed. - Unfinished, - /// The receiver froze the slot; its payload is permanently unreachable. - Aborted, - /// A payload was committed. The range is *unvalidated*: it must pass - /// [`super::layout::PayloadSpan::validate`] before any access. - Committed { payload_offset: usize, payload_len: usize }, - /// A value no protocol operation produces. The trace is corrupt. - Corrupt, -} - -/// Encodes a committed descriptor. -/// -/// The caller guarantees `payload_len` is `1..=MAX_PAYLOAD_LEN` and -/// `payload_offset` fits 32 bits; both hold for any admitted reservation. -pub(super) fn committed(payload_offset: usize, payload_len: usize) -> u64 { - debug_assert!(payload_len > 0 && payload_len <= super::layout::MAX_PAYLOAD_LEN); - debug_assert!(payload_offset as u64 <= OFFSET_MAX); - ((payload_len as u64) << LEN_SHIFT) | payload_offset as u64 -} - -/// Decodes a slot value read back from shared memory. -pub(super) const fn decode(bits: u64) -> SlotState { - match bits { - UNFINISHED => SlotState::Unfinished, - ABORTED => SlotState::Aborted, - _ if bits & ABORTED != 0 => SlotState::Corrupt, - _ => { - // Bit 63 is clear, so the length field is at most 31 bits and - // cannot exceed `MAX_PAYLOAD_LEN`. - let payload_len = (bits >> LEN_SHIFT) as usize; - let payload_offset = (bits & OFFSET_MAX) as usize; - if payload_len == 0 { - // A nonzero offset with a zero length: not a committed value, - // because committed lengths are nonzero. - SlotState::Corrupt - } else { - SlotState::Committed { payload_offset, payload_len } - } - } - } -} - -#[cfg(test)] -mod tests { - use assert2::assert; - - use super::*; - - #[test] - fn decode_recognizes_the_three_states() { - assert!(decode(UNFINISHED) == SlotState::Unfinished); - assert!(decode(ABORTED) == SlotState::Aborted); - assert!( - decode(committed(1016, 5)) - == SlotState::Committed { payload_offset: 1016, payload_len: 5 } - ); - } - - #[test] - fn committed_roundtrips_the_extremes() { - let max_offset = u32::MAX as usize; - let max_len = super::super::layout::MAX_PAYLOAD_LEN; - assert!( - decode(committed(max_offset, max_len)) - == SlotState::Committed { payload_offset: max_offset, payload_len: max_len } - ); - assert!( - decode(committed(0, 1)) == SlotState::Committed { payload_offset: 0, payload_len: 1 } - ); - } - - #[test] - fn decode_rejects_values_no_writer_produces() { - // Aborted bit combined with other bits. - assert!(decode(ABORTED | 1) == SlotState::Corrupt); - assert!(decode(ABORTED | (1 << 62)) == SlotState::Corrupt); - // Zero length with a nonzero offset. - assert!(decode(42) == SlotState::Corrupt); - } -} diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/state.rs b/crates/fspy_shared/src/ipc/channel/shm_io/state.rs deleted file mode 100644 index a3586fa4e..000000000 --- a/crates/fspy_shared/src/ipc/channel/shm_io/state.rs +++ /dev/null @@ -1,295 +0,0 @@ -//! The only module that touches shared-memory bytes. -//! -//! [`SharedState`] wraps the raw mapping and exposes the protocol's atomic -//! operations. One unsafe borrow in [`SharedState::borrow`] constructs -//! three typed views of the region — the `repr(C)` [`Header`], the -//! descriptor table as a slice of atomics sized by the mapping length, and -//! the untyped payload area as a raw slice. Every access after that is a -//! plain field access or a bounds-checked index. The payload area stays -//! raw because writers hold exclusive `&mut` borrows into it, which must -//! not alias any shared reference. -//! -//! # Shared atomics -//! -//! The header holds two independent monotonic `AtomicU64` counters: -//! -//! - the **claim counter**: bit 63 is the CLOSED gate, the low bits count -//! claims ever attempted. Claiming is one wait-free `fetch_add`; the -//! returned old value carries the claim's slot index, the gate, and — by -//! comparison against the fixed table capacity — the capacity verdict. -//! - the **payload counter**: payload bytes ever reserved, bumped by another -//! wait-free `fetch_add`. -//! -//! Failed claims still count, so a counter past its limit is the record of -//! a lost frame: the receiver derives completeness from exactly that, and -//! clamps instead of trusting the counts. Committed descriptors carry -//! their own offset and length, so the counters never locate data. -//! -//! # Memory-ordering contract -//! -//! Three synchronization rules cover the whole protocol: -//! -//! 1. **Claim versus close** — the receiver's close boundary is a plain -//! snapshot load of the claim counter: claims ordered at or before the -//! value it reads (in the counter's modification order) are in the -//! snapshot; later ones receive slot indices the receiver never visits. -//! Claims publish no payload data, so `Relaxed` suffices throughout. -//! The CLOSED gate only stops stragglers from claiming (and allocating -//! pages) forever; any claim admitted between the snapshot and the gate -//! lands beyond the snapshot and is never observed. Completeness rides -//! the same rule: a failed claim's counter bump is its loss report, made -//! before the writer performs the operation whose record was lost — so -//! the snapshot either sees the overshoot, or the loss belongs to an -//! operation performed after the boundary. -//! 2. **Writer commit** — the slot compare-and-swap uses `Release` -//! ([`SharedState::commit`]): every payload write happens-before the -//! committed descriptor becomes visible. -//! 3. **Receiver observation** — the freeze compare-and-swap uses `Acquire` -//! on failure ([`SharedState::freeze`]): observing a committed descriptor -//! also makes the payload writes it published visible, so the borrows the -//! receiver later hands out (see `reader`) read settled bytes. - -use std::sync::atomic::{AtomicU64, Ordering}; - -use super::{layout, slot}; - -/// The CLOSED gate bit of the claim counter. The low 63 bits count claims, -/// so no realistic claim volume can carry into the gate. -const CLOSED: u64 = 1 << 63; - -/// The region header: three protocol atomics, padded so the descriptor -/// table starts off their cache line and there is room for future header -/// fields, which must start zeroed. -#[repr(C)] -struct Header { - /// Bit 63 is the CLOSED gate; the low bits count claims ever attempted. - claims: AtomicU64, - /// Payload bytes ever reserved, including by failed claims. - payload_reserved: AtomicU64, - _reserved: [u64; 6], -} - -const _: () = assert!(size_of::
() == layout::HEADER_LEN); -const _: () = assert!(align_of::
() == align_of::()); - -/// Why a claim was not admitted. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub(super) enum ReserveError { - /// The receiver has closed the channel. - Closed, - /// The frame is oversized, or its region is out of capacity. - Capacity, -} - -/// A successful reservation of one descriptor slot and one payload span. -#[derive(Debug)] -pub(super) struct Reservation { - /// Index of the reserved descriptor slot. - pub(super) slot_index: usize, - /// Byte offset of the reserved payload span. `u64`-aligned. - pub(super) payload_offset: usize, -} - -/// A borrowed view of the shared mapping with protocol-level operations: -/// the typed header, the descriptor table sized from the mapping length, -/// and the raw payload area. -#[derive(Clone, Copy)] -pub(super) struct SharedState<'m> { - header: &'m Header, - table: &'m [AtomicU64], - payloads: *mut [u8], - /// The real mapping length. Not derivable from the parts above: the - /// payload region rounds down to whole `u64`s, and re-deriving the - /// layout from a shortened length could shift the table boundary. - len: usize, -} - -impl SharedState<'_> { - /// Borrows a shared mapping. - /// - /// # Safety - /// - /// - `mem` must be valid for reads and writes for the lifetime `'m` and - /// its address must be stable. - /// - The memory must have been zero-initialized when the region was - /// created, and accessed only through this protocol since. - /// - /// # Panics - /// - /// Panics when the mapping cannot host the protocol at all: base not - /// `u64`-aligned, smaller than the header, or larger than - /// [`layout::MAX_MAPPING_LEN`]. These indicate a broken caller, not - /// runtime data; senders guard untrusted mappings with - /// [`super::is_supported_region_len`] first. - #[expect(clippy::cast_ptr_alignment, reason = "the base is asserted `u64`-aligned below")] - pub(super) unsafe fn borrow(mem: *mut [u8]) -> Self { - let base = mem.cast::(); - let len = mem.len(); - assert!(base.addr().is_multiple_of(align_of::
())); - assert!((layout::HEADER_LEN..=layout::MAX_MAPPING_LEN).contains(&len)); - // SAFETY: the header and the table lie inside the mapping (the - // header by the assert above, the table by `layout::max_slots`), - // are `u64`-aligned (aligned base, `u64`-multiple offsets), and - // consist entirely of atomics zero-initialized at creation — so - // shared borrows for `'m` are valid even while other threads and - // processes access the same memory through these same atomics. The - // payload area keeps the rest of the mapping as a raw slice; - // `layout` bounds every span carved from it. - unsafe { - Self { - header: &*base.cast::
(), - table: std::slice::from_raw_parts( - base.add(layout::HEADER_LEN).cast::(), - layout::max_slots(len), - ), - payloads: std::ptr::slice_from_raw_parts_mut( - base.add(layout::payload_base(len)), - layout::payload_region_len(len), - ), - len, - } - } - } - - pub(super) const fn mapping_len(self) -> usize { - self.len - } - - /// Byte offset where the payload region starts. - pub(super) const fn payload_base(self) -> usize { - layout::payload_base(self.len) - } - - /// Whether the CLOSED gate has been set. - pub(super) fn is_closed(self) -> bool { - self.header.claims.load(Ordering::Relaxed) & CLOSED != 0 - } - - /// Atomically reserves one descriptor slot and one payload span. - /// - /// Wait-free: two `fetch_add`s, no retry loop (rule 1). A claim that - /// does not fit fails, and its counter bumps are what tell the - /// receiver a record was lost. - /// - /// # Panics - /// - /// Panics when `payload_len` exceeds [`layout::MAX_PAYLOAD_LEN`] — a - /// caller error, not a capacity condition, and the one loss the - /// counters could not record. - pub(super) fn try_claim(self, payload_len: usize) -> Result { - assert!(payload_len <= layout::MAX_PAYLOAD_LEN); - let reserved_len = layout::reserved_payload_len(payload_len); - - // Payload bytes first, so a payload-capacity failure does not burn a - // slot. A failed reservation stays counted — overshoot is harmless - // because the counter is not what locates payloads (descriptors are) - // and a `u64` cannot realistically wrap. - let payload_start = - self.header.payload_reserved.fetch_add(reserved_len as u64, Ordering::Relaxed); - // Checked: a foreign scribble of the counter must fail the claim, - // not wrap the bound into an out-of-bounds reservation. - let payload_end = payload_start.checked_add(reserved_len as u64); - if payload_end.is_none_or(|end| end > self.payloads.len() as u64) { - return Err(ReserveError::Capacity); - } - - let claims = self.header.claims.fetch_add(1, Ordering::Relaxed); - if claims & CLOSED != 0 { - return Err(ReserveError::Closed); - } - let slot_index = usize::try_from(claims).expect("claim count exceeds usize"); - if slot_index >= self.table.len() { - return Err(ReserveError::Capacity); - } - - Ok(Reservation { - slot_index, - // In bounds: `payload_start + reserved_len` fits the payload - // region, which ends within the mapping (`layout`). - payload_offset: self.payload_base() - + usize::try_from(payload_start).expect("bounded by the payload region"), - }) - } - - /// Snapshots the claim and payload counters: the receiver's close - /// boundary (rule 1). Returns the admitted slot count, clamped to the - /// table capacity, and whether every record made it — false once either - /// counter overshot its limit, which is how a failed claim reports the - /// loss. - pub(super) fn snapshot(self) -> (usize, bool) { - let claims = self.header.claims.load(Ordering::Relaxed) & !CLOSED; - let payload = self.header.payload_reserved.load(Ordering::Relaxed); - let complete = claims <= self.table.len() as u64 && payload <= self.payloads.len() as u64; - (usize::try_from(claims).unwrap_or(usize::MAX).min(self.table.len()), complete) - } - - /// Sets the CLOSED gate so stragglers stop claiming. - pub(super) fn close_claims(self) { - self.header.claims.fetch_or(CLOSED, Ordering::Relaxed); - } - - /// Forces the page backing the header (and the table's first slots) to - /// be materialized by the operating system before anyone touches it on - /// a latency-sensitive path. - /// - /// A compare-exchange of zero with zero on the claim counter: on an - /// untouched region it performs a real write — allocating the first - /// block of a sparse backing file, which can cost milliseconds on - /// journalling filesystems — without changing protocol state. If a - /// claim got there first, the page is already backed and the failed - /// exchange changes nothing. (An `or` of zero would not do: the - /// compiler may lower it to a plain load, which materializes only a - /// hole page without allocating the block.) - /// - /// Only Linux channels use this: elsewhere the first touch is cheap. - #[cfg(target_os = "linux")] - pub(super) fn pre_fault(self) { - let _ = self.header.claims.compare_exchange(0, 0, Ordering::Relaxed, Ordering::Relaxed); - } - - /// Publishes a committed descriptor into an unfinished slot. - /// - /// Returns false when the receiver aborted the slot first; the payload is - /// then permanently unreachable and the writer must not touch it again - /// either way. - /// - /// # Panics - /// - /// Panics when `slot_index` lies outside the table; callers only pass - /// indices of admitted reservations. - pub(super) fn commit(self, slot_index: usize, descriptor: u64) -> bool { - // Rule 2: `Release` orders every payload write before the descriptor. - self.table[slot_index] - .compare_exchange(slot::UNFINISHED, descriptor, Ordering::Release, Ordering::Relaxed) - .is_ok() - } - - /// Freezes one slot during close and returns its terminal value: `ABORTED` - /// when the receiver won the race, the committed descriptor otherwise. - /// - /// # Panics - /// - /// Panics when `slot_index` lies outside the table; the receiver only - /// passes indices below its clamped snapshot. - pub(super) fn freeze(self, slot_index: usize) -> u64 { - // Rule 3: `Acquire` on failure makes a committed payload visible. - match self.table[slot_index].compare_exchange( - slot::UNFINISHED, - slot::ABORTED, - Ordering::AcqRel, - Ordering::Acquire, - ) { - Ok(_) => slot::ABORTED, - Err(terminal) => terminal, - } - } - - /// Pointer to a reserved payload span. The caller owns the span's - /// exclusivity argument. - pub(super) fn payload_ptr(self, offset: usize) -> *mut u8 { - debug_assert!((self.payload_base()..=self.len).contains(&offset)); - // SAFETY: callers pass offsets of admitted reservations, which - // `layout` keeps inside the payload area. - unsafe { self.payloads.cast::().add(offset - self.payload_base()) } - } -} diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs deleted file mode 100644 index cccee2adb..000000000 --- a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs +++ /dev/null @@ -1,169 +0,0 @@ -//! The writer side: claiming, filling, and committing frames. - -use std::{ - num::NonZeroUsize, - ops::{Deref, DerefMut}, -}; - -use super::{ - AsRawSlice, slot, - state::{ReserveError, SharedState}, -}; - -/// A concurrent shared-memory frame writer. -/// -/// Safe to use across threads and processes at the same time: frames are -/// reserved with atomic operations, filled in uniquely owned payload spans, -/// and published with an atomic commit (see the module docs of -/// [`super::state`]). -pub struct ShmWriter { - mem: M, -} - -/// Why a frame could not be claimed. -#[derive(thiserror::Error, Clone, Copy, PartialEq, Eq, Debug)] -pub enum ClaimError { - /// The receiver closed the channel; anything after this point is - /// outside the channel's boundary. - #[error("the channel has been closed by the receiver")] - Closed, - /// The region is full. The claim's counter bumps already recorded the - /// loss, so the channel will report itself incomplete. - #[error("no space left in the shared-memory region")] - Capacity, -} - -impl ShmWriter { - /// Creates a writer backed by a shared-memory region. - /// - /// # Safety - /// - /// - `mem.as_raw_slice()` must return a stable, valid pointer to the - /// whole region for the writer's lifetime. - /// - The region must have been zero-initialized when it was created and - /// accessed only through this protocol since. - /// - /// # Panics - /// - /// Panics when the region is not `u64`-aligned or its size is outside the - /// supported range (see [`SharedState::borrow`]). - pub unsafe fn new(mem: M) -> Self { - // Validate the region geometry eagerly so misuse fails at - // construction, not at the first claim. - // SAFETY: forwarded from this function's contract. - let _ = unsafe { SharedState::borrow(mem.as_raw_slice()) }; - Self { mem } - } - - fn state(&self) -> SharedState<'_> { - // SAFETY: `new` requires the region to stay valid and - // protocol-governed for the writer's lifetime, and it validated the - // geometry. - unsafe { SharedState::borrow(self.mem.as_raw_slice()) } - } - - /// Whether the receiver has closed the channel. - pub fn is_closed(&self) -> bool { - self.state().is_closed() - } - - /// Claims a frame of exactly `frame_size` bytes. - /// - /// The frame is invisible to the receiver until [`FrameMut::finish`] - /// commits it. Dropping the frame without finishing abandons the claim: - /// the receiver ignores the slot, exactly as if the writer had died. - /// - /// # Panics - /// - /// Panics when `frame_size` exceeds the frame limit of `i32::MAX` - /// bytes. - pub fn claim_frame(&self, frame_size: NonZeroUsize) -> Result, ClaimError> { - let state = self.state(); - let reservation = state.try_claim(frame_size.get()).map_err(|err| match err { - ReserveError::Closed => ClaimError::Closed, - ReserveError::Capacity => ClaimError::Capacity, - })?; - - let content_ptr = state.payload_ptr(reservation.payload_offset); - // SAFETY: the allocator compare-and-swap reserved - // `[payload_offset, payload_offset + frame_size)` exclusively for - // this frame: other writers reserve disjoint spans, and the receiver - // never reads a payload before observing its committed descriptor — - // which `finish` publishes only when it consumes this borrow. - let content = unsafe { std::slice::from_raw_parts_mut(content_ptr, frame_size.get()) }; - Ok(FrameMut { - state, - slot_index: reservation.slot_index, - descriptor: slot::committed(reservation.payload_offset, frame_size.get()), - content, - }) - } - - // Unwrap `self` and return the underlying memory. - #[cfg(test)] - pub fn into_memory(self) -> M { - self.mem - } - - #[cfg(test)] - pub fn try_write_frame(&self, frame: &[u8]) -> bool { - let Some(frame_size) = NonZeroUsize::new(frame.len()) else { - return false; - }; - let Ok(mut frame_mut) = self.claim_frame(frame_size) else { - return false; - }; - frame_mut.copy_from_slice(frame); - frame_mut.finish(); - true - } -} - -/// An exclusively owned, claimed-but-unpublished frame. -/// -/// [`FrameMut::finish`] commits the frame; it is the only way to make the -/// payload visible to the receiver. Dropping the frame instead abandons -/// the claim: the slot stays unfinished and the receiver ignores it, -/// exactly as if the writer had died there. A writer that abandons a frame -/// and still performs the operation it described steps outside the usage -/// contract — records are published before the recorded operation. -pub struct FrameMut<'a> { - state: SharedState<'a>, - slot_index: usize, - descriptor: u64, - content: &'a mut [u8], -} - -impl std::fmt::Debug for FrameMut<'_> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("FrameMut") - .field("slot_index", &self.slot_index) - .field("len", &self.content.len()) - .finish_non_exhaustive() - } -} - -impl Deref for FrameMut<'_> { - type Target = [u8]; - - fn deref(&self) -> &Self::Target { - self.content - } -} - -impl DerefMut for FrameMut<'_> { - fn deref_mut(&mut self) -> &mut Self::Target { - self.content - } -} - -impl FrameMut<'_> { - /// Commits the frame, making it visible to the receiver. - /// - /// If the receiver closed the channel and aborted this frame's slot - /// first, the frame is silently discarded: the record belongs to the - /// close race and is intentionally excluded either way. - pub fn finish(self) { - self.state.commit(self.slot_index, self.descriptor); - } -} From fad9c0b9b67c0a619198fb37e47f23b3a63a32af Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sat, 15 Aug 2026 11:53:59 +0800 Subject: [PATCH 24/92] refactor(fspy-shm): report lost records with a header flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completeness by counter overshoot had two holes. A frame larger than a descriptor can describe (> i32::MAX bytes) could not be reported at all, so claiming one panicked — reachable in the preloads, whose record lengths come from path strings the traced program controls, breaking the promise that a preload never panics its host. And a writer killed inside a failed claim left an overshot counter behind, marking a channel incomplete over a record whose operation never ran. Replace the overshoot rule with a loss flag in the header's reserved space: every failed claim stores it before the writer moves on, and the receiver reads it once at close. The non-overflow path is untouched — the flag's cache line is only written by a claim that is already failing. The report-before-perform order gives the same rule-1 guarantee as commit-before-perform: a report the receiver misses belongs to an operation performed after close, and a writer that dies before reporting never performed the operation at all. Oversized frames now fail like any other refused claim, without poisoning the counters, so the channel stays usable for the records after them. Co-Authored-By: Claude Fable 5 --- crates/fspy_shared/src/ipc/channel/mod.rs | 4 +- .../src/ipc/channel/shm_io/README.md | 42 ++++---- .../fspy_shared/src/ipc/channel/shm_io/mod.rs | 46 +++++---- .../src/ipc/channel/shm_io/shared.rs | 99 +++++++++++-------- 4 files changed, 110 insertions(+), 81 deletions(-) diff --git a/crates/fspy_shared/src/ipc/channel/mod.rs b/crates/fspy_shared/src/ipc/channel/mod.rs index 0ecfb49eb..20a6e8479 100644 --- a/crates/fspy_shared/src/ipc/channel/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/mod.rs @@ -212,8 +212,8 @@ impl Sender { /// A record that cannot be sent is skipped, because that is all a /// sender inside an intercepted call can do: the channel may have /// closed (the record belongs past its boundary), or the region may be - /// full (a loss the receiver sees as counter overshoot and reports via - /// incompleteness). + /// full (a loss the failed claim flags, so the receiver reports the + /// frames incomplete). pub fn send>(&self, value: &T) { let Ok(serialized_size) = T::serialized_size(value) else { return; diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/README.md b/crates/fspy_shared/src/ipc/channel/shm_io/README.md index 97c5f281a..585d5ff00 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/README.md +++ b/crates/fspy_shared/src/ipc/channel/shm_io/README.md @@ -30,16 +30,14 @@ space, not memory: only pages that are actually written get backed. | header (64 B) | descriptor table (1/8 of the region) | payloads (the rest, grow up) | ``` -The header is a `repr(C)` struct of two `AtomicU64` counters, and both -only ever count up: +The header is a `repr(C)` struct of two `AtomicU64` counters, which only +ever count up, and one flag: - the **claim counter** — how many frames were ever claimed. Bit 63 is the CLOSED gate. - the **payload counter** — how many payload bytes were ever reserved. - -Failed claims count too. That is deliberate: a counter past its limit is -how the receiver learns that a record was lost and the frames are -incomplete — no separate flag needed. +- the **loss flag** — set by a writer whose claim failed, so the receiver + knows a record was lost and the frames are incomplete. The table has one 8-byte slot per frame — an eighth of the region. Every bound is derived from the mapping length alone, so the region is @@ -60,7 +58,7 @@ Three steps: 1. **Claim.** Two `fetch_add`s — one reserves payload bytes, one reserves a slot. No retry loop, no lock. A claim that does not fit fails after the - fact, and its counter bumps double as the loss report (see above). + fact and sets the loss flag before the writer moves on. 2. **Fill.** The writer serializes into its payload span. The span is exclusively its own; nobody else knows it exists yet. 3. **Commit.** One compare-and-swap flips the frame's slot from zero to a @@ -94,16 +92,22 @@ room than is left, in the payload area or in the table, the claim fails. The writer skips that one record and carries on: recording must never stop or crash the program doing the work. -The loss is not silent. The failed claim still moved a counter, and -counters never move backwards, so from that moment on the counter stands -past its limit. When the receiver closes the channel it compares both -counters against their limits; if either went past, `is_complete` returns -false, and a reader that needs the full picture knows to throw the result -away. +The loss is not silent. Before moving on, the failed claim sets the loss +flag in the header. When the receiver closes the channel it reads the +flag once; if it is set, `is_complete` returns false, and a reader that +needs the full picture knows to throw the result away. + +Setting the flag before moving on matters for the same reason committing +a record before acting does. If the receiver's read misses the flag, the +flag was set after close — so the skipped record describes an action +performed after the channel closed, which the receiver never promised to +include. And a writer that dies before setting the flag never performed +its action, so nothing was actually lost. -One limit is different: a single frame holds at most 2 GiB. Asking for -more is a mistake in the calling code, so it panics instead of counting -as overflow. +One more limit: a single frame holds at most 2 GiB, because a descriptor +cannot describe more. Such a claim is refused the same way — the record +is skipped and the flag is set — and the channel stays usable for every +record after it. ## Closing and reading @@ -143,9 +147,9 @@ CLAIMED (slot 0) ---+ read, so an observed descriptor implies fully visible payload bytes. - Once a slot is committed or aborted, nothing ever changes it again. - Counters only grow. The receiver clamps them to the fixed capacities — - an inflated counter degrades into extra aborted slots, not corruption — - and a counter past its limit is precisely how it learns that a record - was lost. + an inflated counter degrades into extra aborted slots, not corruption. + Loss is reported separately: a failed claim sets the loss flag before + the writer carries on. - The bounds checks on descriptors are what make the `unsafe` reference construction correct: whether the receiver stays memory-safe never depends on another process behaving. diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs index cf18f8bee..140e43816 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs @@ -22,16 +22,16 @@ //! the region is self-describing: every process computes the same table //! and payload bounds from the mapped size. One borrow constructs typed //! views of the header (a `repr(C)` struct of two monotonic `AtomicU64` -//! counters: claims, carrying the CLOSED gate bit, and payload bytes -//! reserved) and of the descriptor table (a slice of atomics) — see -//! [`shared`]; the payload area stays untyped bytes. +//! counters — claims, carrying the CLOSED gate bit, and payload bytes +//! reserved — plus a loss flag) and of the descriptor table (a slice of +//! atomics) — see [`shared`]; the payload area stays untyped bytes. //! A claim is two wait-free `fetch_add`s — one reserves payload bytes, one //! reserves a descriptor slot — validated against the fixed region bounds -//! from the returned old values. Failed claims overshoot the counters -//! harmlessly: readers clamp to the region capacities, and committed -//! descriptors are self-describing ([`layout`]), so the counters never locate -//! data. Every slot has a fixed location, so an unfinished frame can never -//! hide a later one. +//! from the returned old values. A failed claim sets the loss flag and +//! leaves the counters bumped, harmlessly: readers clamp to the region +//! capacities, and committed descriptors are self-describing ([`layout`]), +//! so the counters never locate data. Every slot has a fixed location, so +//! an unfinished frame can never hide a later one. //! //! # Frame lifecycle //! @@ -62,9 +62,9 @@ //! drops are sound because writers publish a record *before* performing the //! recorded operation: a process that died mid-frame never performed the //! operation, and one that claimed or committed after the snapshot performs -//! it outside the channel's boundary. A record lost to a full region -//! *before* close shows up as a counter past its limit, and the channel -//! reports itself incomplete ([`Frames::is_complete`]). +//! it outside the channel's boundary. A record refused *before* close — a +//! full region, an oversized frame — sets the loss flag first, and the +//! channel reports itself incomplete ([`Frames::is_complete`]). //! //! Correctness never depends on writer-side cleanup: no exit hooks, PID //! checks, heartbeats, or timeouts. @@ -266,9 +266,8 @@ mod tests { assert!(writer.try_write_frame(b"test")); - // Larger than the payload region: the claim fails, and its counter - // bump — past the region's limit — is what tells the receiver a - // record was lost. + // Larger than the payload region: the claim fails and sets the + // loss flag, which is what tells the receiver a record was lost. assert!(!writer.try_write_frame(&vec![0u8; 2048])); let frames = collect_frames(&shm); @@ -279,12 +278,23 @@ mod tests { } #[test] - #[should_panic = "payload_len <= layout::MAX_PAYLOAD_LEN"] - fn oversized_frame_is_a_caller_error() { + fn oversized_frame_is_refused_and_marks_incomplete() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer = unsafe { ShmWriter::new(shm) }; - let _ = writer.claim_frame(((i32::MAX as usize) + 1).try_into().unwrap()); + let writer = unsafe { ShmWriter::new(shm.clone()) }; + + // No descriptor can describe a frame this long: the claim is + // refused without touching the counters, so later records still + // flow — but the loss flag marks the channel incomplete. + let oversized = ((i32::MAX as usize) + 1).try_into().unwrap(); + assert!(matches!(writer.claim_frame(oversized), Err(ClaimError::Capacity))); + assert!(writer.try_write_frame(b"still open")); + + let frames = collect_frames(&shm); + let mut iter = frames.iter(); + assert!(iter.next().unwrap() == b"still open"); + assert!(iter.next() == None); + assert!(!frames.is_complete()); } #[test] diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/shared.rs b/crates/fspy_shared/src/ipc/channel/shm_io/shared.rs index 6962d259a..7a31bff2e 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/shared.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/shared.rs @@ -12,7 +12,8 @@ //! //! # Shared atomics //! -//! The header holds two independent monotonic `AtomicU64` counters: +//! The header holds two independent monotonic `AtomicU64` counters and a +//! loss flag: //! //! - the **claim counter**: bit 63 is the CLOSED gate, the low bits count //! claims ever attempted. Claiming is one wait-free `fetch_add`; the @@ -20,11 +21,13 @@ //! comparison against the fixed table capacity — the capacity verdict. //! - the **payload counter**: payload bytes ever reserved, bumped by another //! wait-free `fetch_add`. +//! - the **loss flag**: set by every failed claim before the writer moves +//! on. The receiver derives completeness from it alone. //! -//! Failed claims still count, so a counter past its limit is the record of -//! a lost frame: the receiver derives completeness from exactly that, and -//! clamps instead of trusting the counts. Committed descriptors carry -//! their own offset and length, so the counters never locate data. +//! Failed claims leave the counters bumped; that is harmless, because the +//! receiver clamps instead of trusting the counts, and committed +//! descriptors carry their own offset and length, so the counters never +//! locate data. //! //! # Memory-ordering contract //! @@ -38,10 +41,12 @@ //! The CLOSED gate only stops stragglers from claiming (and allocating //! pages) forever; any claim admitted between the snapshot and the gate //! lands beyond the snapshot and is never observed. Completeness rides -//! the same rule: a failed claim's counter bump is its loss report, made -//! before the writer performs the operation whose record was lost — so -//! the snapshot either sees the overshoot, or the loss belongs to an -//! operation performed after the boundary. +//! the same style of argument: a failed claim sets the loss flag before +//! the writer performs the operation whose record was lost — so the +//! receiver's one read of the flag either sees the loss, or the loss +//! belongs to an operation performed after the boundary. A writer that +//! dies before setting the flag never performed its operation, so +//! nothing was actually lost. //! 2. **Writer commit** — the slot compare-and-swap uses `Release` //! ([`SharedState::commit`]): every payload write happens-before the //! committed descriptor becomes visible. @@ -76,7 +81,11 @@ struct Header { claims: AtomicU64, /// Payload bytes ever reserved, including by failed claims. payload_reserved: AtomicU64, - _reserved: [u64; 6], + /// Nonzero once a claim failed: a record was lost and the channel is + /// incomplete. Semantically a flag; a whole `u64` keeps the header a + /// plain row of `u64` words. + lost: AtomicU64, + _reserved: [u64; 5], } const _: () = assert!(size_of::
() == layout::HEADER_LEN); @@ -178,16 +187,15 @@ impl SharedState<'_> { /// Atomically reserves one descriptor slot and one payload span. /// /// Wait-free: two `fetch_add`s, no retry loop (rule 1). A claim that - /// does not fit fails, and its counter bumps are what tell the - /// receiver a record was lost. - /// - /// # Panics - /// - /// Panics when `payload_len` exceeds [`layout::MAX_PAYLOAD_LEN`] — a - /// caller error, not a capacity condition, and the one loss the - /// counters could not record. + /// does not fit fails after setting the loss flag, which is what tells + /// the receiver a record was lost. fn try_claim(self, payload_len: usize) -> Result { - assert!(payload_len <= layout::MAX_PAYLOAD_LEN); + // No descriptor can describe a payload this long; refuse it before + // touching the counters, so the channel keeps working for every + // record after it. + if payload_len > layout::MAX_PAYLOAD_LEN { + return Err(self.report_loss()); + } let reserved_len = layout::reserved_payload_len(payload_len); // Payload bytes first, so a payload-capacity failure does not burn a @@ -200,16 +208,18 @@ impl SharedState<'_> { // not wrap the bound into an out-of-bounds reservation. let payload_end = payload_start.checked_add(reserved_len as u64); if payload_end.is_none_or(|end| end > self.payloads.len() as u64) { - return Err(ReserveError::Capacity); + return Err(self.report_loss()); } let claims = self.header.claims.fetch_add(1, Ordering::Relaxed); if claims & CLOSED != 0 { + // Not a loss: a record refused after close describes an + // operation performed outside the channel's boundary. return Err(ReserveError::Closed); } let slot_index = usize::try_from(claims).expect("claim count exceeds usize"); if slot_index >= self.table.len() { - return Err(ReserveError::Capacity); + return Err(self.report_loss()); } Ok(Reservation { @@ -221,15 +231,21 @@ impl SharedState<'_> { }) } - /// Snapshots the claim and payload counters: the receiver's close - /// boundary (rule 1). Returns the admitted slot count, clamped to the - /// table capacity, and whether every record made it — false once either - /// counter overshot its limit, which is how a failed claim reports the - /// loss. + /// Records that a claim failed and its record was lost, before the + /// caller moves on (rule 1). Returns the error the failed claim + /// reports. + fn report_loss(self) -> ReserveError { + self.header.lost.store(1, Ordering::Relaxed); + ReserveError::Capacity + } + + /// Snapshots the claim counter and the loss flag; the counter load is + /// the receiver's close boundary (rule 1). Returns the admitted slot + /// count, clamped to the table capacity, and whether every record made + /// it: false once any claim failed and set the flag. fn snapshot(self) -> (usize, bool) { let claims = self.header.claims.load(Ordering::Relaxed) & !CLOSED; - let payload = self.header.payload_reserved.load(Ordering::Relaxed); - let complete = claims <= self.table.len() as u64 && payload <= self.payloads.len() as u64; + let complete = self.header.lost.load(Ordering::Relaxed) == 0; (usize::try_from(claims).unwrap_or(usize::MAX).min(self.table.len()), complete) } @@ -322,8 +338,9 @@ pub enum ClaimError { /// outside the channel's boundary. #[error("the channel has been closed by the receiver")] Closed, - /// The region is full. The claim's counter bumps already recorded the - /// loss, so the channel will report itself incomplete. + /// The claim was refused for space: the region was full, or the frame + /// was larger than the `i32::MAX`-byte frame limit. The loss is + /// already recorded, so the channel will report itself incomplete. #[error("no space left in the shared-memory region")] Capacity, } @@ -367,11 +384,8 @@ impl ShmWriter { /// The frame is invisible to the receiver until [`FrameMut::finish`] /// commits it. Dropping the frame without finishing abandons the claim: /// the receiver ignores the slot, exactly as if the writer had died. - /// - /// # Panics - /// - /// Panics when `frame_size` exceeds the frame limit of `i32::MAX` - /// bytes. + /// Frames larger than `i32::MAX` bytes are refused as + /// [`ClaimError::Capacity`]. pub fn claim_frame(&self, frame_size: NonZeroUsize) -> Result, ClaimError> { let state = self.state(); let reservation = state.try_claim(frame_size.get()).map_err(|err| match err { @@ -500,10 +514,10 @@ impl Frames { /// Whether every record a writer published made it in. /// - /// False when the region ran out of space before the channel closed: a - /// claim failed, its record was lost, and the frames under-report what - /// writers went on to do. Consumers that need completeness must reject - /// them. + /// False when a claim failed before the channel closed — the region + /// was out of space, or a frame exceeded the frame limit: its record + /// was lost, and the frames under-report what writers went on to do. + /// Consumers that need completeness must reject them. #[must_use] pub const fn is_complete(&self) -> bool { self.complete @@ -560,9 +574,10 @@ pub(super) unsafe fn close(mem: M) -> Result, ProtocolE // it, later ones land in slots this pass never visits. The count is // clamped to the table capacity, so a counter inflated by failed // claims (or by a foreign scribble) degrades to a full-table sweep, - // not an error — and an overshot counter is exactly how the - // snapshot learns that a record was lost (rule 1 in the ordering - // contract above). + // not an error. The snapshot also reads the loss flag: its one + // read either sees a loss, or the loss belongs to an operation + // performed after this boundary (rule 1 in the ordering contract + // above). let (slot_count, is_complete) = state.snapshot(); // Gate further claims. Cheap: the creator pre-faulted this page From f06323759c1256dfd3eb5b73030cfde08b04b9f0 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sat, 15 Aug 2026 12:33:56 +0800 Subject: [PATCH 25/92] refactor(fspy-shm): derive the header size from the header struct MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HEADER_LEN predates the typed header: when layout.rs was offset math only, there was no struct to measure, so the size was a literal and a const assert tied the struct to it after the fact. Move the Header struct into layout.rs — it describes the region's shape, which is that file's job — and derive HEADER_LEN from size_of. The sizing math now follows the struct automatically; the one remaining literal is an assert pinning the header to a single cache line, which is a design intent no struct can express. CLOSED moves along with it, keeping all the bit meanings next to the slot codec. Co-Authored-By: Claude Fable 5 --- .../src/ipc/channel/shm_io/README.md | 4 +- .../src/ipc/channel/shm_io/layout.rs | 41 +++++++++++++++---- .../src/ipc/channel/shm_io/shared.rs | 25 +---------- 3 files changed, 37 insertions(+), 33 deletions(-) diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/README.md b/crates/fspy_shared/src/ipc/channel/shm_io/README.md index 585d5ff00..edf975683 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/README.md +++ b/crates/fspy_shared/src/ipc/channel/shm_io/README.md @@ -175,14 +175,14 @@ CLAIMED (slot 0) ---+ | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `mod.rs` | Public surface (`ShmWriter`, `FrameMut`, `close`, `Frames`), the protocol overview docs, and the integration tests — they run against a mocked region and are miri-clean (`cargo miri test -p fspy_shared shm_io`). | | `shared.rs` | Everything that touches the mapping, in reading order: the typed views of the region and the ordering contract, then the writer side (claim, fill, finish), then the receiver side (close and `Frames`) with the reasoning for its borrows. | -| `layout.rs` | Everything that is plain integer math: the sizing rule that turns a mapping length into table and payload bounds, payload rounding, span validation, and the descriptor codec. No pointers, no atomics. | +| `layout.rs` | The region's shape: the header struct, the sizing rule that turns a mapping length into table and payload bounds, payload rounding, span validation, and the descriptor codec. Describes memory, never touches it. | Arrows point at what a file depends on: ```mermaid graph TD mod["mod.rs
public surface"] --> shared["shared.rs
everything touching the mapping"] - shared --> layout["layout.rs
pure math"] + shared --> layout["layout.rs
the region's shape"] ``` Read from the bottom up — `layout.rs`, then `shared.rs`, then `mod.rs` — diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs index 8ef5fed1c..b2e8e3dfd 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs @@ -6,19 +6,46 @@ //! | header | descriptor table (SLOTS slots) | payloads (grow up) | //! ``` //! -//! This module holds everything about the region that is plain integer -//! math: the sizing rule that turns a mapping length into table and -//! payload bounds, payload rounding, payload-span validation, and the -//! descriptor-slot codec. No pointers, no atomics. +//! This module holds the region's shape, with no memory access: the +//! header struct, the sizing rule that turns a mapping length into table +//! and payload bounds, payload rounding, payload-span validation, and the +//! descriptor-slot codec. The operations on the region live in +//! [`super::shared`]. //! //! Overflow safety follows from one bound enforced at construction time: //! the mapping length never exceeds [`MAX_MAPPING_LEN`], so all offsets fit //! the 32-bit descriptor fields and all sums fit `usize` on the 64-bit //! targets the parent module asserts. -/// Byte size of the region header. Kept in this type-free module for the -/// sizing arithmetic; `state` asserts it equals `size_of::
()`. -pub(super) const HEADER_LEN: usize = 64; +use std::sync::atomic::AtomicU64; + +/// The CLOSED gate bit of the claim counter. The low 63 bits count claims, +/// so no realistic claim volume can carry into the gate. +pub(super) const CLOSED: u64 = 1 << 63; + +/// The region header: two protocol counters and the loss flag, padded so +/// the descriptor table starts off their cache line and there is room for +/// future header fields, which must start zeroed. +#[repr(C)] +pub(super) struct Header { + /// Bit 63 is the CLOSED gate; the low bits count claims ever attempted. + pub(super) claims: AtomicU64, + /// Payload bytes ever reserved, including by failed claims. + pub(super) payload_reserved: AtomicU64, + /// Nonzero once a claim failed: a record was lost and the channel is + /// incomplete. Semantically a flag; a whole `u64` keeps the header a + /// plain row of `u64` words. + pub(super) lost: AtomicU64, + _reserved: [u64; 5], +} + +// One cache line: shrink `_reserved` when adding a field. The alignment is +// what lets a `u64`-aligned mapping base be cast to `&Header`. +const _: () = assert!(size_of::
() == 64); +const _: () = assert!(align_of::
() == align_of::()); + +/// Byte size of the region header, taken from [`Header`] itself. +pub(super) const HEADER_LEN: usize = size_of::
(); /// Byte size of one descriptor slot. pub(super) const SLOT_LEN: usize = size_of::(); diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/shared.rs b/crates/fspy_shared/src/ipc/channel/shm_io/shared.rs index 7a31bff2e..97a7ab378 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/shared.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/shared.rs @@ -65,32 +65,9 @@ use std::{ use super::{ AsRawSlice, - layout::{self, PayloadSpan, SlotState}, + layout::{self, CLOSED, Header, PayloadSpan, SlotState}, }; -/// The CLOSED gate bit of the claim counter. The low 63 bits count claims, -/// so no realistic claim volume can carry into the gate. -const CLOSED: u64 = 1 << 63; - -/// The region header: two protocol counters, padded so the descriptor -/// table starts off their cache line and there is room for future header -/// fields, which must start zeroed. -#[repr(C)] -struct Header { - /// Bit 63 is the CLOSED gate; the low bits count claims ever attempted. - claims: AtomicU64, - /// Payload bytes ever reserved, including by failed claims. - payload_reserved: AtomicU64, - /// Nonzero once a claim failed: a record was lost and the channel is - /// incomplete. Semantically a flag; a whole `u64` keeps the header a - /// plain row of `u64` words. - lost: AtomicU64, - _reserved: [u64; 5], -} - -const _: () = assert!(size_of::
() == layout::HEADER_LEN); -const _: () = assert!(align_of::
() == align_of::()); - /// Why a claim was not admitted. #[derive(Clone, Copy, PartialEq, Eq, Debug)] enum ReserveError { From db554cd924578021f512e544684fb0c57f4ab94f Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sat, 15 Aug 2026 12:57:21 +0800 Subject: [PATCH 26/92] refactor(fspy-shm): drop code the simplifications left behind Every one of these is a leftover from a deleted design, kept alive only by habit or by tests: - ReserveError duplicated ClaimError variant for variant; try_claim now returns ClaimError directly and the mapping in claim_frame goes away. - Reservation was a named pair passed once between two functions in the same file; a destructured tuple says the same thing. - SharedState::mapping_len wrapped a field its one caller can read. - The close/pre_fault wrappers in mod.rs re-stated shared's docs to delegate one call; the functions are now re-exported like the rest of the surface, with the wrapper's doc text folded into the real ones. - Sender's Deref to ShmWriter served only tests, which now reach the writer field directly; FrameMut, ClaimError, and ProtocolError are no longer nameable outside the channel (production never names them), the error types staying test-visible for assertions. - into_memory is gated to the non-miri test that is its only caller. Co-Authored-By: Claude Fable 5 --- crates/fspy_shared/src/ipc/channel/mod.rs | 26 +++---- .../src/ipc/channel/shm_io/README.md | 2 +- .../fspy_shared/src/ipc/channel/shm_io/mod.rs | 36 ++------- .../src/ipc/channel/shm_io/shared.rs | 73 +++++++------------ 4 files changed, 44 insertions(+), 93 deletions(-) diff --git a/crates/fspy_shared/src/ipc/channel/mod.rs b/crates/fspy_shared/src/ipc/channel/mod.rs index 20a6e8479..36a9c2635 100644 --- a/crates/fspy_shared/src/ipc/channel/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/mod.rs @@ -7,14 +7,13 @@ mod shm_io; -use std::{env::temp_dir, ffi::OsStr, io, num::NonZeroUsize, ops::Deref, path::PathBuf}; +use std::{env::temp_dir, ffi::OsStr, io, num::NonZeroUsize, path::PathBuf}; use allocator_api2::alloc::Global; use fspy_nostd::Fat; use fspy_nostd_alloc::OsCString; use fspy_shm::Mapping; use shm_io::ShmWriter; -pub use shm_io::{ClaimError, FrameMut}; /// The committed frames of a closed channel; borrows the shared mapping, /// which stays alive (and mapped) until this value drops. @@ -233,14 +232,6 @@ impl Sender { } } -impl Deref for Sender { - type Target = ShmWriter; - - fn deref(&self) -> &Self::Target { - &self.writer - } -} - // SAFETY: `Sender` only accesses the shared mapping through the `shm_io` // protocol, which synchronizes concurrent writers and the receiver with // atomic operations; the mapping's address is stable and independently owned. @@ -320,7 +311,7 @@ mod tests { let mut command = command_for_fn!(conf, |conf: ChannelConf| { let sender = conf.sender().unwrap(); let frame_size = NonZeroUsize::new(2).unwrap(); - let mut frame = sender.claim_frame(frame_size).unwrap(); + let mut frame = sender.writer.claim_frame(frame_size).unwrap(); frame.copy_from_slice(&[4, 2]); frame.finish(); }); @@ -343,7 +334,7 @@ mod tests { let cmd = command_for_fn!(conf, |conf: ChannelConf| { let sender = conf.sender().unwrap(); let frame_size = NonZeroUsize::new(2).unwrap(); - let mut frame = sender.claim_frame(frame_size).unwrap(); + let mut frame = sender.writer.claim_frame(frame_size).unwrap(); frame.copy_from_slice(&[4, 2]); frame.finish(); }); @@ -392,7 +383,7 @@ mod tests { let (conf, receiver) = channel(4096).unwrap(); let sender = conf.sender().unwrap(); - let mut frame = sender.claim_frame(NonZeroUsize::new(2).unwrap()).unwrap(); + let mut frame = sender.writer.claim_frame(NonZeroUsize::new(2).unwrap()).unwrap(); frame.copy_from_slice(&[4, 2]); frame.finish(); @@ -401,7 +392,8 @@ mod tests { assert!(frames.is_complete()); assert!( - sender.claim_frame(NonZeroUsize::new(2).unwrap()).unwrap_err() == ClaimError::Closed + sender.writer.claim_frame(NonZeroUsize::new(2).unwrap()).unwrap_err() + == shm_io::ClaimError::Closed ); } @@ -413,8 +405,10 @@ mod tests { let cmd = command_for_fn!((conf.clone(), i), |(conf, i): (ChannelConf, u16)| { let sender = conf.sender().unwrap(); let data_to_send = i.to_string(); - let mut frame = - sender.claim_frame(NonZeroUsize::new(data_to_send.len()).unwrap()).unwrap(); + let mut frame = sender + .writer + .claim_frame(NonZeroUsize::new(data_to_send.len()).unwrap()) + .unwrap(); frame.copy_from_slice(data_to_send.as_bytes()); frame.finish(); }); diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/README.md b/crates/fspy_shared/src/ipc/channel/shm_io/README.md index edf975683..8b5b9c515 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/README.md +++ b/crates/fspy_shared/src/ipc/channel/shm_io/README.md @@ -173,7 +173,7 @@ CLAIMED (slot 0) ---+ | File | Role | | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `mod.rs` | Public surface (`ShmWriter`, `FrameMut`, `close`, `Frames`), the protocol overview docs, and the integration tests — they run against a mocked region and are miri-clean (`cargo miri test -p fspy_shared shm_io`). | +| `mod.rs` | Public surface (`ShmWriter`, `close`, `Frames`), the protocol overview docs, and the integration tests — they run against a mocked region and are miri-clean (`cargo miri test -p fspy_shared shm_io`). | | `shared.rs` | Everything that touches the mapping, in reading order: the typed views of the region and the ordering contract, then the writer side (claim, fill, finish), then the receiver side (close and `Frames`) with the reasoning for its borrows. | | `layout.rs` | The region's shape: the header struct, the sizing rule that turns a mapping length into table and payload bounds, payload rounding, span validation, and the descriptor codec. Describes memory, never touches it. | diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs index 140e43816..3cb1ca324 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs @@ -75,7 +75,13 @@ mod shared; use std::ptr::slice_from_raw_parts_mut; use fspy_shm::Mapping; -pub use shared::{ClaimError, FrameMut, Frames, ProtocolError, ShmWriter}; +#[cfg(target_os = "linux")] +pub use shared::pre_fault; +// The error types appear in return values either way; only tests need to +// name them. +#[cfg(test)] +pub use shared::{ClaimError, ProtocolError}; +pub use shared::{Frames, ShmWriter, close}; // The region arithmetic in `layout` relies on `usize` accommodating sums of // 32-bit-bounded quantities, and the descriptor protocol on native 64-bit @@ -96,34 +102,6 @@ impl AsRawSlice for Mapping { } } -/// Closes the channel without waiting for writers and returns the committed -/// frames as borrows of the region, which moves into the returned -/// [`Frames`]. See [`shared::close`]. -/// -/// # Safety -/// -/// Same contract as [`ShmWriter::new`]: the region must be stable and valid, -/// zero-initialized at creation, and accessed only through this protocol. -pub unsafe fn close(mem: M) -> Result, ProtocolError> { - // SAFETY: forwarded from this function's contract. - unsafe { shared::close(mem) } -} - -/// Materializes the page backing the protocol header without changing -/// protocol state, so that neither a writer's first claim nor [`close`]'s -/// snapshot pays for the backing file's first block allocation — a -/// millisecond-scale cost on some journalling filesystems, for reads of -/// holes as well as writes. Run it off any latency-sensitive path. -/// -/// # Safety -/// -/// Same contract as [`ShmWriter::new`]. -#[cfg(target_os = "linux")] -pub unsafe fn pre_fault(mem: &impl AsRawSlice) { - // SAFETY: forwarded from this function's contract. - unsafe { shared::pre_fault(mem) } -} - /// Whether a mapping of `len` bytes can host the protocol at all. /// /// Senders opening a file they do not control should refuse unsupported diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/shared.rs b/crates/fspy_shared/src/ipc/channel/shm_io/shared.rs index 97a7ab378..eb09ae1b1 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/shared.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/shared.rs @@ -68,24 +68,6 @@ use super::{ layout::{self, CLOSED, Header, PayloadSpan, SlotState}, }; -/// Why a claim was not admitted. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -enum ReserveError { - /// The receiver has closed the channel. - Closed, - /// The region is out of capacity. - Capacity, -} - -/// A successful reservation of one descriptor slot and one payload span. -#[derive(Debug)] -struct Reservation { - /// Index of the reserved descriptor slot. - slot_index: usize, - /// Byte offset of the reserved payload span. `u64`-aligned. - payload_offset: usize, -} - /// A borrowed view of the shared mapping with protocol-level operations: /// the typed header, the descriptor table sized from the mapping length, /// and the raw payload area. @@ -147,10 +129,6 @@ impl SharedState<'_> { } } - const fn mapping_len(self) -> usize { - self.len - } - /// Byte offset where the payload region starts. const fn payload_base(self) -> usize { layout::payload_base(self.len) @@ -161,12 +139,14 @@ impl SharedState<'_> { self.header.claims.load(Ordering::Relaxed) & CLOSED != 0 } - /// Atomically reserves one descriptor slot and one payload span. + /// Atomically reserves one descriptor slot and one payload span, + /// returning the slot index and the payload's byte offset in the + /// mapping. /// /// Wait-free: two `fetch_add`s, no retry loop (rule 1). A claim that /// does not fit fails after setting the loss flag, which is what tells /// the receiver a record was lost. - fn try_claim(self, payload_len: usize) -> Result { + fn try_claim(self, payload_len: usize) -> Result<(usize, usize), ClaimError> { // No descriptor can describe a payload this long; refuse it before // touching the counters, so the channel keeps working for every // record after it. @@ -192,28 +172,26 @@ impl SharedState<'_> { if claims & CLOSED != 0 { // Not a loss: a record refused after close describes an // operation performed outside the channel's boundary. - return Err(ReserveError::Closed); + return Err(ClaimError::Closed); } let slot_index = usize::try_from(claims).expect("claim count exceeds usize"); if slot_index >= self.table.len() { return Err(self.report_loss()); } - Ok(Reservation { - slot_index, - // In bounds: `payload_start + reserved_len` fits the payload - // region, which ends within the mapping (`layout`). - payload_offset: self.payload_base() - + usize::try_from(payload_start).expect("bounded by the payload region"), - }) + // In bounds: `payload_start + reserved_len` fits the payload + // region, which ends within the mapping (`layout`). + let payload_offset = self.payload_base() + + usize::try_from(payload_start).expect("bounded by the payload region"); + Ok((slot_index, payload_offset)) } /// Records that a claim failed and its record was lost, before the /// caller moves on (rule 1). Returns the error the failed claim /// reports. - fn report_loss(self) -> ReserveError { + fn report_loss(self) -> ClaimError { self.header.lost.store(1, Ordering::Relaxed); - ReserveError::Capacity + ClaimError::Capacity } /// Snapshots the claim counter and the loss flag; the counter load is @@ -365,12 +343,9 @@ impl ShmWriter { /// [`ClaimError::Capacity`]. pub fn claim_frame(&self, frame_size: NonZeroUsize) -> Result, ClaimError> { let state = self.state(); - let reservation = state.try_claim(frame_size.get()).map_err(|err| match err { - ReserveError::Closed => ClaimError::Closed, - ReserveError::Capacity => ClaimError::Capacity, - })?; + let (slot_index, payload_offset) = state.try_claim(frame_size.get())?; - let content_ptr = state.payload_ptr(reservation.payload_offset); + let content_ptr = state.payload_ptr(payload_offset); // SAFETY: the claim reserved // `[payload_offset, payload_offset + frame_size)` exclusively for // this frame: other writers reserve disjoint spans, and the receiver @@ -379,14 +354,14 @@ impl ShmWriter { let content = unsafe { slice::from_raw_parts_mut(content_ptr, frame_size.get()) }; Ok(FrameMut { state, - slot_index: reservation.slot_index, - descriptor: layout::committed(reservation.payload_offset, frame_size.get()), + slot_index, + descriptor: layout::committed(payload_offset, frame_size.get()), content, }) } // Unwrap `self` and return the underlying memory. - #[cfg(test)] + #[cfg(all(test, not(miri)))] pub fn into_memory(self) -> M { self.mem } @@ -538,7 +513,7 @@ pub enum ProtocolError { /// Panics when the region is not `u64`-aligned or its size is outside the /// supported range (see [`SharedState::borrow`]) — a broken caller, not /// corrupt shared data, which is reported as [`ProtocolError`] instead. -pub(super) unsafe fn close(mem: M) -> Result, ProtocolError> { +pub unsafe fn close(mem: M) -> Result, ProtocolError> { let spans; let complete; { @@ -583,7 +558,7 @@ fn freeze_committed_spans( match layout::decode(state.freeze(slot_index)) { SlotState::Aborted => {} SlotState::Committed { payload_offset, payload_len } => { - let span = PayloadSpan::validate(state.mapping_len(), payload_offset, payload_len) + let span = PayloadSpan::validate(state.len, payload_offset, payload_len) .ok_or(ProtocolError::CorruptDescriptor { slot_index })?; spans.push(span); } @@ -597,14 +572,18 @@ fn freeze_committed_spans( Ok(spans) } -/// Materializes the pages every region touch starts with — see -/// [`SharedState::pre_fault`]. +/// Materializes the page backing the protocol header without changing +/// protocol state, so that neither a writer's first claim nor [`close`]'s +/// snapshot pays for the backing file's first block allocation — a +/// millisecond-scale cost on some journalling filesystems, for reads of +/// holes as well as writes. Run it off any latency-sensitive path. See +/// [`SharedState::pre_fault`] for the mechanism. /// /// # Safety /// /// Same contract as [`ShmWriter::new`]. #[cfg(target_os = "linux")] -pub(super) unsafe fn pre_fault(mem: &impl AsRawSlice) { +pub unsafe fn pre_fault(mem: &impl AsRawSlice) { // SAFETY: forwarded from this function's contract. unsafe { SharedState::borrow(mem.as_raw_slice()) }.pre_fault(); } From df8b8a7625ecba00c55f0d3baa03124529243484 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sat, 15 Aug 2026 14:25:41 +0800 Subject: [PATCH 27/92] refactor(fspy-shm): decode descriptors straight to validated spans SlotState classified every slot value the receiver could read, but its only consumer treats all invalid classes identically, and the validation that followed already refuses every pattern the classification singled out: an unfinished zero decodes a zero length, and any value carrying the aborted bit decodes a length beyond the 31-bit limit. Collapse decode and validate into one step returning Option; the freeze loop keeps a slot's span, skips ABORTED, and calls everything else corrupt. The three-state table stays as the comment documenting what slot values mean. Co-Authored-By: Claude Fable 5 --- .../src/ipc/channel/shm_io/layout.rs | 95 +++++++------------ .../src/ipc/channel/shm_io/shared.rs | 23 ++--- 2 files changed, 46 insertions(+), 72 deletions(-) diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs index b2e8e3dfd..090242f1e 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs @@ -167,26 +167,18 @@ impl PayloadSpan { // Committed lengths are nonzero (a zero-length frame is never claimed), so a // committed value is always nonzero and the three states are disjoint. Once // a slot is committed or aborted, nothing ever changes it again. +// +// The receiver never classifies invalid values: [`decode`] accepts exactly +// the committed descriptors a correct writer can produce for the mapping, +// and refuses everything else alike — an unfinished `0` decodes a zero +// length, and any value carrying the aborted bit decodes a length beyond +// [`MAX_PAYLOAD_LEN`], so both fail [`PayloadSpan::validate`]. pub(super) const UNFINISHED: u64 = 0; pub(super) const ABORTED: u64 = 1 << 63; const LEN_SHIFT: u32 = 32; const OFFSET_MAX: u64 = u32::MAX as u64; -/// A decoded descriptor slot value. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub(super) enum SlotState { - /// The slot was reserved but no payload has been committed. - Unfinished, - /// The receiver froze the slot; its payload is permanently unreachable. - Aborted, - /// A payload was committed. The range is *unvalidated*: it must pass - /// [`PayloadSpan::validate`] before any access. - Committed { payload_offset: usize, payload_len: usize }, - /// A value no protocol operation produces. The region is corrupt. - Corrupt, -} - /// Encodes a committed descriptor. /// /// The caller guarantees `payload_len` is `1..=MAX_PAYLOAD_LEN` and @@ -197,26 +189,11 @@ pub(super) fn committed(payload_offset: usize, payload_len: usize) -> u64 { ((payload_len as u64) << LEN_SHIFT) | payload_offset as u64 } -/// Decodes a slot value read back from shared memory. -pub(super) const fn decode(bits: u64) -> SlotState { - match bits { - UNFINISHED => SlotState::Unfinished, - ABORTED => SlotState::Aborted, - _ if bits & ABORTED != 0 => SlotState::Corrupt, - _ => { - // Bit 63 is clear, so the length field is at most 31 bits and - // cannot exceed `MAX_PAYLOAD_LEN`. - let payload_len = (bits >> LEN_SHIFT) as usize; - let payload_offset = (bits & OFFSET_MAX) as usize; - if payload_len == 0 { - // A nonzero offset with a zero length: not a committed value, - // because committed lengths are nonzero. - SlotState::Corrupt - } else { - SlotState::Committed { payload_offset, payload_len } - } - } - } +/// Decodes a slot value read back from shared memory as a committed +/// descriptor. Returns `None` for any value that is not one a correct +/// writer could have committed for a `mapping_len`-byte mapping. +pub(super) const fn decode(mapping_len: usize, bits: u64) -> Option { + PayloadSpan::validate(mapping_len, (bits & OFFSET_MAX) as usize, (bits >> LEN_SHIFT) as usize) } #[cfg(test)] @@ -284,34 +261,34 @@ mod tests { } #[test] - fn decode_recognizes_the_three_states() { - assert!(decode(UNFINISHED) == SlotState::Unfinished); - assert!(decode(ABORTED) == SlotState::Aborted); - assert!( - decode(committed(1016, 5)) - == SlotState::Committed { payload_offset: 1016, payload_len: 5 } - ); - } + fn decode_roundtrips_committed_values() { + let mapping_len = 1024; + let base = payload_base(mapping_len); + let span = decode(mapping_len, committed(base, 5)).unwrap(); + assert!(span.offset == base && span.len == 5); - #[test] - fn committed_roundtrips_the_extremes() { - let max_offset = u32::MAX as usize; - let max_len = MAX_PAYLOAD_LEN; - assert!( - decode(committed(max_offset, max_len)) - == SlotState::Committed { payload_offset: max_offset, payload_len: max_len } - ); - assert!( - decode(committed(0, 1)) == SlotState::Committed { payload_offset: 0, payload_len: 1 } - ); + // The extremes of the descriptor fields on the largest mapping: the + // 31-bit length limit, and a span ending exactly at the region end. + let mapping_len = MAX_MAPPING_LEN; + let base = payload_base(mapping_len); + let span = decode(mapping_len, committed(base, MAX_PAYLOAD_LEN)).unwrap(); + assert!(span.offset == base && span.len == MAX_PAYLOAD_LEN); + let last = base + payload_region_len(mapping_len) - 8; + let span = decode(mapping_len, committed(last, 8)).unwrap(); + assert!(span.offset == last && span.len == 8); } #[test] - fn decode_rejects_values_no_writer_produces() { - // Aborted bit combined with other bits. - assert!(decode(ABORTED | 1) == SlotState::Corrupt); - assert!(decode(ABORTED | (1 << 62)) == SlotState::Corrupt); - // Zero length with a nonzero offset. - assert!(decode(42) == SlotState::Corrupt); + fn decode_rejects_values_no_writer_commits() { + let mapping_len = 1024; + // The non-committed slot states. + assert!(decode(mapping_len, UNFINISHED).is_none()); + assert!(decode(mapping_len, ABORTED).is_none()); + // The aborted bit combined with other bits: the length field then + // exceeds `MAX_PAYLOAD_LEN`. + assert!(decode(mapping_len, ABORTED | 1).is_none()); + assert!(decode(mapping_len, ABORTED | (1 << 62)).is_none()); + // A zero length with a nonzero offset. + assert!(decode(mapping_len, 42).is_none()); } } diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/shared.rs b/crates/fspy_shared/src/ipc/channel/shm_io/shared.rs index eb09ae1b1..743c394f7 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/shared.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/shared.rs @@ -65,7 +65,7 @@ use std::{ use super::{ AsRawSlice, - layout::{self, CLOSED, Header, PayloadSpan, SlotState}, + layout::{self, CLOSED, Header, PayloadSpan}, }; /// A borrowed view of the shared mapping with protocol-level operations: @@ -555,19 +555,16 @@ fn freeze_committed_spans( ) -> Result, ProtocolError> { let mut spans = Vec::new(); for slot_index in 0..slot_count { - match layout::decode(state.freeze(slot_index)) { - SlotState::Aborted => {} - SlotState::Committed { payload_offset, payload_len } => { - let span = PayloadSpan::validate(state.len, payload_offset, payload_len) - .ok_or(ProtocolError::CorruptDescriptor { slot_index })?; - spans.push(span); - } - // `freeze` only returns terminal values, so `Unfinished` is - // unreachable and grouped with the corrupt case. - SlotState::Unfinished | SlotState::Corrupt => { - return Err(ProtocolError::CorruptDescriptor { slot_index }); - } + let bits = state.freeze(slot_index); + if bits == layout::ABORTED { + continue; } + // Freeze only returns terminal values, so anything else must be a + // committed descriptor with a valid span; a foreign scribble fails + // the decode. + let span = layout::decode(state.len, bits) + .ok_or(ProtocolError::CorruptDescriptor { slot_index })?; + spans.push(span); } Ok(spans) } From 9d5e7455cf3f630c72cd476e6bfb7b1ef0c0b1b3 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sat, 15 Aug 2026 14:38:50 +0800 Subject: [PATCH 28/92] refactor(fspy-shm): wrap the receiver end in a type like the writer's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The free unsafe close made the module's unsafe boundary uneven: the writer pays its contract once at attach and operates safely, while the receiver re-asserted the same contract at every close call — in the channel, far from the creation-time facts the SAFETY comment cites. ShmReceiver mirrors ShmWriter: one unsafe constructor with the identical contract, eager geometry validation, and a safe consuming close. The channel constructs it where the region is created, so Receiver::close is now safe code. pre_fault stays a free function: it is a creator-side warm-up on a throwaway view, belonging to neither endpoint. Co-Authored-By: Claude Fable 5 --- crates/fspy_shared/src/ipc/channel/mod.rs | 27 ++-- .../src/ipc/channel/shm_io/README.md | 2 +- .../fspy_shared/src/ipc/channel/shm_io/mod.rs | 20 +-- .../src/ipc/channel/shm_io/shared.rs | 135 +++++++++++------- 4 files changed, 108 insertions(+), 76 deletions(-) diff --git a/crates/fspy_shared/src/ipc/channel/mod.rs b/crates/fspy_shared/src/ipc/channel/mod.rs index 36a9c2635..a752aa2b1 100644 --- a/crates/fspy_shared/src/ipc/channel/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/mod.rs @@ -13,7 +13,7 @@ use allocator_api2::alloc::Global; use fspy_nostd::Fat; use fspy_nostd_alloc::OsCString; use fspy_shm::Mapping; -use shm_io::ShmWriter; +use shm_io::{ShmReceiver, ShmWriter}; /// The committed frames of a closed channel; borrows the shared mapping, /// which stays alive (and mapped) until this value drops. @@ -68,7 +68,12 @@ pub fn channel(capacity: usize) -> io::Result<(ChannelConf, Receiver)> { let conf = ChannelConf { shm_id: IpcStr::from_os_c_str(keeper.path.as_c_str()).to_boxed() }; - Ok((conf, Receiver { _keeper: keeper, mapping })) + // SAFETY: the region was created zero-initialized just above, its mapping + // address is stable and independently owned, and every attached process + // accesses it only through the `shm_io` protocol. + let shm = unsafe { ShmReceiver::new(mapping) }; + + Ok((conf, Receiver { _keeper: keeper, shm })) } /// Encodes `path` as an owned NUL-terminated platform C string. @@ -249,13 +254,13 @@ pub struct Receiver { /// Keeps the shared memory's backing file alive for as long as senders /// may attach. _keeper: ShmKeeper, - mapping: Mapping, + shm: ShmReceiver, } -// SAFETY: `Receiver` only holds the mapping; it accesses it exclusively -// through the `shm_io` protocol in `close`, which synchronizes with senders -// via atomic operations. The mapping's address is stable and independently -// owned. +// SAFETY: `Receiver` only holds the mapping (inside the protocol receiver); +// it accesses it exclusively through the `shm_io` protocol in `close`, which +// synchronizes with senders via atomic operations. The mapping's address is +// stable and independently owned. unsafe impl Send for Receiver {} // SAFETY: see the `Send` impl. @@ -277,15 +282,11 @@ impl Receiver { /// Fails only when the shared-memory metadata was corrupted (a protocol /// impossibility for correct senders); the trace is then unusable. pub fn close(self) -> io::Result { - let Self { _keeper: keeper, mapping } = self; + let Self { _keeper: keeper, shm } = self; // Remove the backing file first so no new process attaches while the // channel closes. drop(keeper); - // SAFETY: `mapping` was created zero-initialized by `channel`, its - // address is stable, and all attached processes access it only - // through the `shm_io` protocol. - unsafe { shm_io::close(mapping) } - .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err)) + shm.close().map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err)) } } diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/README.md b/crates/fspy_shared/src/ipc/channel/shm_io/README.md index 8b5b9c515..bdf6d4bbe 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/README.md +++ b/crates/fspy_shared/src/ipc/channel/shm_io/README.md @@ -173,7 +173,7 @@ CLAIMED (slot 0) ---+ | File | Role | | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `mod.rs` | Public surface (`ShmWriter`, `close`, `Frames`), the protocol overview docs, and the integration tests — they run against a mocked region and are miri-clean (`cargo miri test -p fspy_shared shm_io`). | +| `mod.rs` | Public surface (`ShmWriter`, `ShmReceiver`, `Frames`), the protocol overview docs, and the integration tests — they run against a mocked region and are miri-clean (`cargo miri test -p fspy_shared shm_io`). | | `shared.rs` | Everything that touches the mapping, in reading order: the typed views of the region and the ordering contract, then the writer side (claim, fill, finish), then the receiver side (close and `Frames`) with the reasoning for its borrows. | | `layout.rs` | The region's shape: the header struct, the sizing rule that turns a mapping length into table and payload bounds, payload rounding, span validation, and the descriptor codec. Describes memory, never touches it. | diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs index 3cb1ca324..5b07b5a2e 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs @@ -53,11 +53,11 @@ //! //! # Close boundary //! -//! [`close`]'s boundary is a snapshot of the claim counter. A writer -//! admitted before the snapshot races the freeze pass per slot and its -//! frame is either included (commit won) or ignored (abort won) — never +//! [`ShmReceiver::close`]'s boundary is a snapshot of the claim counter. +//! A writer admitted before the snapshot races the freeze pass per slot and +//! its frame is either included (commit won) or ignored (abort won) — never //! torn; a claim after the snapshot lands in a slot the receiver never -//! visits and is dropped, and the CLOSED gate set before [`close`] returns +//! visits and is dropped, and the CLOSED gate set before close returns //! stops stragglers from claiming (and materializing pages) forever. Both //! drops are sound because writers publish a record *before* performing the //! recorded operation: a process that died mid-frame never performed the @@ -81,7 +81,7 @@ pub use shared::pre_fault; // name them. #[cfg(test)] pub use shared::{ClaimError, ProtocolError}; -pub use shared::{Frames, ShmWriter, close}; +pub use shared::{Frames, ShmReceiver, ShmWriter}; // The region arithmetic in `layout` relies on `usize` accommodating sums of // 32-bit-bounded quantities, and the descriptor protocol on native 64-bit @@ -186,7 +186,7 @@ mod tests { fn collect_frames(shm: &MockedShm) -> Frames { // SAFETY: `MockedShm` provides a stable, zero-initialized allocation // accessed only through the protocol. - unsafe { close(shm.clone()) }.unwrap() + unsafe { ShmReceiver::new(shm.clone()) }.close().unwrap() } #[test] @@ -581,7 +581,7 @@ mod tests { shm.poke_u64(64, (bogus_len << 32) | bogus_offset); // SAFETY: see `collect_frames`. - let result = unsafe { close(shm) }; + let result = unsafe { ShmReceiver::new(shm) }.close(); assert!(result.unwrap_err() == ProtocolError::CorruptDescriptor { slot_index: 0 }); } @@ -597,7 +597,7 @@ mod tests { shm.poke_u64(64, (1 << 63) | (8u64 << 32) | 8); // SAFETY: see `collect_frames`. - let result = unsafe { close(shm) }; + let result = unsafe { ShmReceiver::new(shm) }.close(); assert!(result.unwrap_err() == ProtocolError::CorruptDescriptor { slot_index: 0 }); } @@ -701,7 +701,7 @@ mod tests { // SAFETY: the mapping is a valid shared-memory region created zeroed // and accessed only through the protocol. - let frames = unsafe { close(mapping) }.unwrap(); + let frames = unsafe { ShmReceiver::new(mapping) }.close().unwrap(); assert!(frames.is_complete()); let collected = frames.iter().map(BStr::new).collect::>(); assert!(collected.len() == CHILD_COUNT * FRAME_COUNT_EACH_CHILD); @@ -769,7 +769,7 @@ mod tests { assert!(writer.try_write_frame(b"alive")); // SAFETY: see `real_shm_across_processes`. - let frames = unsafe { close(writer.into_memory()) }.unwrap(); + let frames = unsafe { ShmReceiver::new(writer.into_memory()) }.close().unwrap(); let mut iter = frames.iter(); assert!(iter.next().unwrap() == b"alive"); assert!(iter.next() == None); diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/shared.rs b/crates/fspy_shared/src/ipc/channel/shm_io/shared.rs index 743c394f7..9797deb13 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/shared.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/shared.rs @@ -493,60 +493,90 @@ pub enum ProtocolError { CorruptDescriptor { slot_index: usize }, } -/// Closes the channel and returns the committed frames as borrows of the -/// mapping, which moves into the returned [`Frames`]. +/// The receiver end of a channel. /// -/// Never blocks on writers: writers admitted before the snapshot race per -/// slot, and each raced slot independently ends up committed (included) or -/// aborted (excluded). Claims after the snapshot land in slots this pass -/// never visits until the CLOSED gate — set before returning — stops them. -/// See the crate-level protocol docs in [`super`]. -/// -/// # Safety -/// -/// Same contract as [`ShmWriter::new`]: `mem` must be a stable, valid -/// pointer to the whole region, zero-initialized at creation and accessed -/// only through this protocol. -/// -/// # Panics -/// -/// Panics when the region is not `u64`-aligned or its size is outside the -/// supported range (see [`SharedState::borrow`]) — a broken caller, not -/// corrupt shared data, which is reported as [`ProtocolError`] instead. -pub unsafe fn close(mem: M) -> Result, ProtocolError> { - let spans; - let complete; - { - // SAFETY: forwarded from this function's contract; the raw slice - // stays valid while `mem` is borrowed here and beyond, since `mem` - // moves into the returned `Frames`. - let state = unsafe { SharedState::borrow(mem.as_raw_slice()) }; - - // The close boundary: claims at or before this snapshot are inside - // it, later ones land in slots this pass never visits. The count is - // clamped to the table capacity, so a counter inflated by failed - // claims (or by a foreign scribble) degrades to a full-table sweep, - // not an error. The snapshot also reads the loss flag: its one - // read either sees a loss, or the loss belongs to an operation - // performed after this boundary (rule 1 in the ordering contract - // above). - let (slot_count, is_complete) = state.snapshot(); - - // Gate further claims. Cheap: the creator pre-faulted this page - // where first touches are expensive. Claims racing between the - // snapshot and this gate are dropped soundly (see the module docs - // in `super`). - state.close_claims(); - - // Freeze pass: drive every admitted slot to a terminal state and - // collect the committed spans. After this loop the snapshot's slice - // of the descriptor table can no longer change — late writers lose - // their commit race against `ABORTED`. - spans = freeze_committed_spans(state, slot_count)?; - complete = is_complete; +/// Attaching to the region is the one unsafe step, sharing +/// [`ShmWriter::new`]'s contract; closing is safe. +pub struct ShmReceiver { + mem: M, +} + +impl ShmReceiver { + /// Creates the receiver backed by a shared-memory region. + /// + /// # Safety + /// + /// Same contract as [`ShmWriter::new`]: + /// + /// - `mem.as_raw_slice()` must return a stable, valid pointer to the + /// whole region for the lifetime of the receiver and of the + /// [`Frames`] it closes into. + /// - The region must have been zero-initialized when it was created and + /// accessed only through this protocol since. + /// + /// # Panics + /// + /// Panics when the region is not `u64`-aligned or its size is outside + /// the supported range (see [`SharedState::borrow`]). + pub unsafe fn new(mem: M) -> Self { + // Validate the region geometry eagerly so misuse fails at + // construction, not at close. + // SAFETY: forwarded from this function's contract. + let _ = unsafe { SharedState::borrow(mem.as_raw_slice()) }; + Self { mem } } - Ok(Frames { mem, spans, complete }) + /// Closes the channel and returns the committed frames as borrows of + /// the mapping, which moves into the returned [`Frames`]. + /// + /// Never blocks on writers: writers admitted before the snapshot race + /// per slot, and each raced slot independently ends up committed + /// (included) or aborted (excluded). Claims after the snapshot land in + /// slots this pass never visits until the CLOSED gate — set before + /// this returns — stops them. See the crate-level protocol docs in + /// [`super`]. + /// + /// # Errors + /// + /// [`ProtocolError`] when the shared-memory metadata could not have + /// been produced by a correct writer; the region was corrupted and its + /// frames are unusable. + pub fn close(self) -> Result, ProtocolError> { + let spans; + let complete; + { + // SAFETY: `new` requires the region to stay valid and + // protocol-governed, and it validated the geometry; the raw + // slice stays valid while `self.mem` is borrowed here and + // beyond, since it moves into the returned `Frames`. + let state = unsafe { SharedState::borrow(self.mem.as_raw_slice()) }; + + // The close boundary: claims at or before this snapshot are + // inside it, later ones land in slots this pass never visits. + // The count is clamped to the table capacity, so a counter + // inflated by failed claims (or by a foreign scribble) degrades + // to a full-table sweep, not an error. The snapshot also reads + // the loss flag: its one read either sees a loss, or the loss + // belongs to an operation performed after this boundary (rule 1 + // in the ordering contract above). + let (slot_count, is_complete) = state.snapshot(); + + // Gate further claims. Cheap: the creator pre-faulted this page + // where first touches are expensive. Claims racing between the + // snapshot and this gate are dropped soundly (see the module + // docs in `super`). + state.close_claims(); + + // Freeze pass: drive every admitted slot to a terminal state + // and collect the committed spans. After this loop the + // snapshot's slice of the descriptor table can no longer change + // — late writers lose their commit race against `ABORTED`. + spans = freeze_committed_spans(state, slot_count)?; + complete = is_complete; + } + + Ok(Frames { mem: self.mem, spans, complete }) + } } fn freeze_committed_spans( @@ -570,7 +600,8 @@ fn freeze_committed_spans( } /// Materializes the page backing the protocol header without changing -/// protocol state, so that neither a writer's first claim nor [`close`]'s +/// protocol state, so that neither a writer's first claim nor +/// [`ShmReceiver::close`]'s /// snapshot pays for the backing file's first block allocation — a /// millisecond-scale cost on some journalling filesystems, for reads of /// holes as well as writes. Run it off any latency-sensitive path. See From 8090249c9a29409828729f324233ee59921544a8 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sat, 15 Aug 2026 14:44:26 +0800 Subject: [PATCH 29/92] refactor(fspy-shm): fold shared.rs into mod.rs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mod.rs stopped earning its keep as a separate facade: the wrapper functions are gone, the curated surface decayed into verbatim re-exports, and the boundary itself manufactured the cfg(test) re-export of the error types, needed only so tests one file away could name them. Merge the mechanism into the module root — overview docs, region views and ordering contract, writer side, receiver side, then the tests — and keep the boundary that still means something: layout.rs describes the region, mod.rs operates on it. Co-Authored-By: Claude Fable 5 --- .../src/ipc/channel/shm_io/README.md | 21 +- .../src/ipc/channel/shm_io/layout.rs | 2 +- .../fspy_shared/src/ipc/channel/shm_io/mod.rs | 628 +++++++++++++++++- .../src/ipc/channel/shm_io/shared.rs | 617 ----------------- 4 files changed, 622 insertions(+), 646 deletions(-) delete mode 100644 crates/fspy_shared/src/ipc/channel/shm_io/shared.rs diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/README.md b/crates/fspy_shared/src/ipc/channel/shm_io/README.md index bdf6d4bbe..033064f2d 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/README.md +++ b/crates/fspy_shared/src/ipc/channel/shm_io/README.md @@ -171,19 +171,10 @@ CLAIMED (slot 0) ---+ ## Files -| File | Role | -| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `mod.rs` | Public surface (`ShmWriter`, `ShmReceiver`, `Frames`), the protocol overview docs, and the integration tests — they run against a mocked region and are miri-clean (`cargo miri test -p fspy_shared shm_io`). | -| `shared.rs` | Everything that touches the mapping, in reading order: the typed views of the region and the ordering contract, then the writer side (claim, fill, finish), then the receiver side (close and `Frames`) with the reasoning for its borrows. | -| `layout.rs` | The region's shape: the header struct, the sizing rule that turns a mapping length into table and payload bounds, payload rounding, span validation, and the descriptor codec. Describes memory, never touches it. | - -Arrows point at what a file depends on: - -```mermaid -graph TD - mod["mod.rs
public surface"] --> shared["shared.rs
everything touching the mapping"] - shared --> layout["layout.rs
the region's shape"] -``` +| File | Role | +| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `mod.rs` | The protocol, in reading order: the overview docs, the typed views of the region and the ordering contract, the writer side (claim, fill, finish), the receiver side (close and `Frames`), and the integration tests — miri-clean (`cargo miri test -p fspy_shared shm_io`). | +| `layout.rs` | The region's shape: the header struct, the sizing rule that turns a mapping length into table and payload bounds, payload rounding, span validation, and the descriptor codec. Describes memory, never touches it. | -Read from the bottom up — `layout.rs`, then `shared.rs`, then `mod.rs` — -and each file only needs the ones below it. +`mod.rs` depends on `layout.rs`; read `layout.rs` first, then `mod.rs` top +to bottom. diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs index 090242f1e..a8469d68e 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs @@ -10,7 +10,7 @@ //! header struct, the sizing rule that turns a mapping length into table //! and payload bounds, payload rounding, payload-span validation, and the //! descriptor-slot codec. The operations on the region live in -//! [`super::shared`]. +//! [`super`]. //! //! Overflow safety follows from one bound enforced at construction time: //! the mapping length never exceeds [`MAX_MAPPING_LEN`], so all offsets fit diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs index 5b07b5a2e..164c0812c 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs @@ -24,7 +24,7 @@ //! views of the header (a `repr(C)` struct of two monotonic `AtomicU64` //! counters — claims, carrying the CLOSED gate bit, and payload bytes //! reserved — plus a loss flag) and of the descriptor table (a slice of -//! atomics) — see [`shared`]; the payload area stays untyped bytes. +//! atomics); the payload area stays untyped bytes. //! A claim is two wait-free `fetch_add`s — one reserves payload bytes, one //! reserves a descriptor slot — validated against the fixed region bounds //! from the returned old values. A failed claim sets the loss flag and @@ -45,11 +45,11 @@ //! //! A payload becomes reachable only through its committed descriptor, and a //! descriptor is committed only after the payload is fully written -//! ([`shared`]'s ordering contract). The receiver never derives frame +//! (the ordering contract below). The receiver never derives frame //! locations from payload bytes, and the borrows [`Frames`] hands out cover //! exactly the validated committed spans — immutable under the protocol, -//! and disjoint from everything a live writer may still touch (see -//! [`shared`]'s trust argument). +//! and disjoint from everything a live writer may still touch (see the +//! receiver section's trust argument below). //! //! # Close boundary //! @@ -70,18 +70,18 @@ //! checks, heartbeats, or timeouts. mod layout; -mod shared; -use std::ptr::slice_from_raw_parts_mut; +use std::{ + fmt, + num::NonZeroUsize, + ops::{Deref, DerefMut}, + ptr::slice_from_raw_parts_mut, + slice, + sync::atomic::{AtomicU64, Ordering}, +}; use fspy_shm::Mapping; -#[cfg(target_os = "linux")] -pub use shared::pre_fault; -// The error types appear in return values either way; only tests need to -// name them. -#[cfg(test)] -pub use shared::{ClaimError, ProtocolError}; -pub use shared::{Frames, ShmReceiver, ShmWriter}; +use layout::{CLOSED, Header, PayloadSpan}; // The region arithmetic in `layout` relies on `usize` accommodating sums of // 32-bit-bounded quantities, and the descriptor protocol on native 64-bit @@ -112,6 +112,608 @@ pub fn is_supported_region_len(len: usize) -> bool { (layout::HEADER_LEN..=layout::MAX_MAPPING_LEN).contains(&len) } +// --- The region views and the ordering contract ---------------------------- +// One unsafe borrow in `SharedState::borrow` constructs three typed +// views of the region — the `repr(C)` `Header`, the descriptor table as +// a slice of atomics sized by the mapping length, and the untyped payload +// area as a raw slice. Every access after that is a plain field access or +// a bounds-checked index. The payload area stays raw because writers hold +// exclusive `&mut` borrows into it, which must not alias any shared +// reference. +// +// # Shared atomics +// +// The header holds two independent monotonic `AtomicU64` counters and a +// loss flag: +// +// - the **claim counter**: bit 63 is the CLOSED gate, the low bits count +// claims ever attempted. Claiming is one wait-free `fetch_add`; the +// returned old value carries the claim's slot index, the gate, and — by +// comparison against the fixed table capacity — the capacity verdict. +// - the **payload counter**: payload bytes ever reserved, bumped by another +// wait-free `fetch_add`. +// - the **loss flag**: set by every failed claim before the writer moves +// on. The receiver derives completeness from it alone. +// +// Failed claims leave the counters bumped; that is harmless, because the +// receiver clamps instead of trusting the counts, and committed +// descriptors carry their own offset and length, so the counters never +// locate data. +// +// # Memory-ordering contract +// +// Three synchronization rules cover the whole protocol: +// +// 1. **Claim versus close** — the receiver's close boundary is a plain +// snapshot load of the claim counter: claims ordered at or before the +// value it reads (in the counter's modification order) are in the +// snapshot; later ones receive slot indices the receiver never visits. +// Claims publish no payload data, so `Relaxed` suffices throughout. +// The CLOSED gate only stops stragglers from claiming (and allocating +// pages) forever; any claim admitted between the snapshot and the gate +// lands beyond the snapshot and is never observed. Completeness rides +// the same style of argument: a failed claim sets the loss flag before +// the writer performs the operation whose record was lost — so the +// receiver's one read of the flag either sees the loss, or the loss +// belongs to an operation performed after the boundary. A writer that +// dies before setting the flag never performed its operation, so +// nothing was actually lost. +// 2. **Writer commit** — the slot compare-and-swap uses `Release` +// (`SharedState::commit`): every payload write happens-before the +// committed descriptor becomes visible. +// 3. **Receiver observation** — the freeze compare-and-swap uses `Acquire` +// on failure (`SharedState::freeze`): observing a committed descriptor +// also makes the payload writes it published visible, so the borrows +// `Frames` later hands out read settled bytes. + +/// A borrowed view of the shared mapping with protocol-level operations: +/// the typed header, the descriptor table sized from the mapping length, +/// and the raw payload area. +#[derive(Clone, Copy)] +struct SharedState<'m> { + header: &'m Header, + table: &'m [AtomicU64], + payloads: *mut [u8], + /// The real mapping length. Not derivable from the parts above: the + /// payload region rounds down to whole `u64`s, and re-deriving the + /// layout from a shortened length could shift the table boundary. + len: usize, +} + +impl SharedState<'_> { + /// Borrows a shared mapping. + /// + /// # Safety + /// + /// - `mem` must be valid for reads and writes for the lifetime `'m` and + /// its address must be stable. + /// - The memory must have been zero-initialized when the region was + /// created, and accessed only through this protocol since. + /// + /// # Panics + /// + /// Panics when the mapping cannot host the protocol at all: base not + /// `u64`-aligned, smaller than the header, or larger than + /// [`layout::MAX_MAPPING_LEN`]. These indicate a broken caller, not + /// runtime data; senders guard untrusted mappings with + /// [`is_supported_region_len`] first. + #[expect(clippy::cast_ptr_alignment, reason = "the base is asserted `u64`-aligned below")] + unsafe fn borrow(mem: *mut [u8]) -> Self { + let base = mem.cast::(); + let len = mem.len(); + assert!(base.addr().is_multiple_of(align_of::
())); + assert!((layout::HEADER_LEN..=layout::MAX_MAPPING_LEN).contains(&len)); + // SAFETY: the header and the table lie inside the mapping (the + // header by the assert above, the table by `layout::max_slots`), + // are `u64`-aligned (aligned base, `u64`-multiple offsets), and + // consist entirely of atomics zero-initialized at creation — so + // shared borrows for `'m` are valid even while other threads and + // processes access the same memory through these same atomics. The + // payload area keeps the rest of the mapping as a raw slice; + // `layout` bounds every span carved from it. + unsafe { + Self { + header: &*base.cast::
(), + table: slice::from_raw_parts( + base.add(layout::HEADER_LEN).cast::(), + layout::max_slots(len), + ), + payloads: std::ptr::slice_from_raw_parts_mut( + base.add(layout::payload_base(len)), + layout::payload_region_len(len), + ), + len, + } + } + } + + /// Byte offset where the payload region starts. + const fn payload_base(self) -> usize { + layout::payload_base(self.len) + } + + /// Whether the CLOSED gate has been set. + fn is_closed(self) -> bool { + self.header.claims.load(Ordering::Relaxed) & CLOSED != 0 + } + + /// Atomically reserves one descriptor slot and one payload span, + /// returning the slot index and the payload's byte offset in the + /// mapping. + /// + /// Wait-free: two `fetch_add`s, no retry loop (rule 1). A claim that + /// does not fit fails after setting the loss flag, which is what tells + /// the receiver a record was lost. + fn try_claim(self, payload_len: usize) -> Result<(usize, usize), ClaimError> { + // No descriptor can describe a payload this long; refuse it before + // touching the counters, so the channel keeps working for every + // record after it. + if payload_len > layout::MAX_PAYLOAD_LEN { + return Err(self.report_loss()); + } + let reserved_len = layout::reserved_payload_len(payload_len); + + // Payload bytes first, so a payload-capacity failure does not burn a + // slot. A failed reservation stays counted — overshoot is harmless + // because the counter is not what locates payloads (descriptors are) + // and a `u64` cannot realistically wrap. + let payload_start = + self.header.payload_reserved.fetch_add(reserved_len as u64, Ordering::Relaxed); + // Checked: a foreign scribble of the counter must fail the claim, + // not wrap the bound into an out-of-bounds reservation. + let payload_end = payload_start.checked_add(reserved_len as u64); + if payload_end.is_none_or(|end| end > self.payloads.len() as u64) { + return Err(self.report_loss()); + } + + let claims = self.header.claims.fetch_add(1, Ordering::Relaxed); + if claims & CLOSED != 0 { + // Not a loss: a record refused after close describes an + // operation performed outside the channel's boundary. + return Err(ClaimError::Closed); + } + let slot_index = usize::try_from(claims).expect("claim count exceeds usize"); + if slot_index >= self.table.len() { + return Err(self.report_loss()); + } + + // In bounds: `payload_start + reserved_len` fits the payload + // region, which ends within the mapping (`layout`). + let payload_offset = self.payload_base() + + usize::try_from(payload_start).expect("bounded by the payload region"); + Ok((slot_index, payload_offset)) + } + + /// Records that a claim failed and its record was lost, before the + /// caller moves on (rule 1). Returns the error the failed claim + /// reports. + fn report_loss(self) -> ClaimError { + self.header.lost.store(1, Ordering::Relaxed); + ClaimError::Capacity + } + + /// Snapshots the claim counter and the loss flag; the counter load is + /// the receiver's close boundary (rule 1). Returns the admitted slot + /// count, clamped to the table capacity, and whether every record made + /// it: false once any claim failed and set the flag. + fn snapshot(self) -> (usize, bool) { + let claims = self.header.claims.load(Ordering::Relaxed) & !CLOSED; + let complete = self.header.lost.load(Ordering::Relaxed) == 0; + (usize::try_from(claims).unwrap_or(usize::MAX).min(self.table.len()), complete) + } + + /// Sets the CLOSED gate so stragglers stop claiming. + fn close_claims(self) { + self.header.claims.fetch_or(CLOSED, Ordering::Relaxed); + } + + /// Forces the page backing the header (and the table's first slots) to + /// be materialized by the operating system before anyone touches it on + /// a latency-sensitive path. + /// + /// A compare-exchange of zero with zero on the claim counter: on an + /// untouched region it performs a real write — allocating the first + /// block of a sparse backing file, which can cost milliseconds on + /// journalling filesystems — without changing protocol state. If a + /// claim got there first, the page is already backed and the failed + /// exchange changes nothing. (An `or` of zero would not do: the + /// compiler may lower it to a plain load, which materializes only a + /// hole page without allocating the block.) + /// + /// Only Linux channels use this: elsewhere the first touch is cheap. + #[cfg(target_os = "linux")] + fn pre_fault(self) { + let _ = self.header.claims.compare_exchange(0, 0, Ordering::Relaxed, Ordering::Relaxed); + } + + /// Publishes a committed descriptor into an unfinished slot. + /// + /// Returns false when the receiver aborted the slot first; the payload is + /// then permanently unreachable and the writer must not touch it again + /// either way. + /// + /// # Panics + /// + /// Panics when `slot_index` lies outside the table; callers only pass + /// indices of admitted reservations. + fn commit(self, slot_index: usize, descriptor: u64) -> bool { + // Rule 2: `Release` orders every payload write before the descriptor. + self.table[slot_index] + .compare_exchange(layout::UNFINISHED, descriptor, Ordering::Release, Ordering::Relaxed) + .is_ok() + } + + /// Freezes one slot during close and returns its terminal value: `ABORTED` + /// when the receiver won the race, the committed descriptor otherwise. + /// + /// # Panics + /// + /// Panics when `slot_index` lies outside the table; the receiver only + /// passes indices below its clamped snapshot. + fn freeze(self, slot_index: usize) -> u64 { + // Rule 3: `Acquire` on failure makes a committed payload visible. + match self.table[slot_index].compare_exchange( + layout::UNFINISHED, + layout::ABORTED, + Ordering::AcqRel, + Ordering::Acquire, + ) { + Ok(_) => layout::ABORTED, + Err(terminal) => terminal, + } + } + + /// Pointer to a reserved payload span. The caller owns the span's + /// exclusivity argument. + fn payload_ptr(self, offset: usize) -> *mut u8 { + debug_assert!((self.payload_base()..=self.len).contains(&offset)); + // SAFETY: callers pass offsets of admitted reservations, which + // `layout` keeps inside the payload area. + unsafe { self.payloads.cast::().add(offset - self.payload_base()) } + } +} + +// --- The writer side: claim, fill, finish ---------------------------------- + +/// A concurrent shared-memory frame writer. +/// +/// Safe to use across threads and processes at the same time: frames are +/// reserved with atomic operations, filled in uniquely owned payload spans, +/// and published with an atomic commit (see the ordering contract above). +pub struct ShmWriter { + mem: M, +} + +/// Why a frame could not be claimed. +#[derive(thiserror::Error, Clone, Copy, PartialEq, Eq, Debug)] +pub enum ClaimError { + /// The receiver closed the channel; anything after this point is + /// outside the channel's boundary. + #[error("the channel has been closed by the receiver")] + Closed, + /// The claim was refused for space: the region was full, or the frame + /// was larger than the `i32::MAX`-byte frame limit. The loss is + /// already recorded, so the channel will report itself incomplete. + #[error("no space left in the shared-memory region")] + Capacity, +} + +impl ShmWriter { + /// Creates a writer backed by a shared-memory region. + /// + /// # Safety + /// + /// - `mem.as_raw_slice()` must return a stable, valid pointer to the + /// whole region for the writer's lifetime. + /// - The region must have been zero-initialized when it was created and + /// accessed only through this protocol since. + /// + /// # Panics + /// + /// Panics when the region is not `u64`-aligned or its size is outside the + /// supported range (see [`SharedState::borrow`]). + pub unsafe fn new(mem: M) -> Self { + // Validate the region geometry eagerly so misuse fails at + // construction, not at the first claim. + // SAFETY: forwarded from this function's contract. + let _ = unsafe { SharedState::borrow(mem.as_raw_slice()) }; + Self { mem } + } + + fn state(&self) -> SharedState<'_> { + // SAFETY: `new` requires the region to stay valid and + // protocol-governed for the writer's lifetime, and it validated the + // geometry. + unsafe { SharedState::borrow(self.mem.as_raw_slice()) } + } + + /// Whether the receiver has closed the channel. + pub fn is_closed(&self) -> bool { + self.state().is_closed() + } + + /// Claims a frame of exactly `frame_size` bytes. + /// + /// The frame is invisible to the receiver until [`FrameMut::finish`] + /// commits it. Dropping the frame without finishing abandons the claim: + /// the receiver ignores the slot, exactly as if the writer had died. + /// Frames larger than `i32::MAX` bytes are refused as + /// [`ClaimError::Capacity`]. + pub fn claim_frame(&self, frame_size: NonZeroUsize) -> Result, ClaimError> { + let state = self.state(); + let (slot_index, payload_offset) = state.try_claim(frame_size.get())?; + + let content_ptr = state.payload_ptr(payload_offset); + // SAFETY: the claim reserved + // `[payload_offset, payload_offset + frame_size)` exclusively for + // this frame: other writers reserve disjoint spans, and the receiver + // never reads a payload before observing its committed descriptor — + // which `finish` publishes only when it consumes this borrow. + let content = unsafe { slice::from_raw_parts_mut(content_ptr, frame_size.get()) }; + Ok(FrameMut { + state, + slot_index, + descriptor: layout::committed(payload_offset, frame_size.get()), + content, + }) + } + + // Unwrap `self` and return the underlying memory. + #[cfg(all(test, not(miri)))] + pub fn into_memory(self) -> M { + self.mem + } + + #[cfg(test)] + pub fn try_write_frame(&self, frame: &[u8]) -> bool { + let Some(frame_size) = NonZeroUsize::new(frame.len()) else { + return false; + }; + let Ok(mut frame_mut) = self.claim_frame(frame_size) else { + return false; + }; + frame_mut.copy_from_slice(frame); + frame_mut.finish(); + true + } +} + +/// An exclusively owned, claimed-but-unpublished frame. +/// +/// [`FrameMut::finish`] commits the frame; it is the only way to make the +/// payload visible to the receiver. Dropping the frame instead abandons +/// the claim: the slot stays unfinished and the receiver ignores it, +/// exactly as if the writer had died there. A writer that abandons a frame +/// and still performs the operation it described steps outside the usage +/// contract — records are published before the recorded operation. +pub struct FrameMut<'a> { + state: SharedState<'a>, + slot_index: usize, + descriptor: u64, + content: &'a mut [u8], +} + +impl fmt::Debug for FrameMut<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("FrameMut") + .field("slot_index", &self.slot_index) + .field("len", &self.content.len()) + .finish_non_exhaustive() + } +} + +impl Deref for FrameMut<'_> { + type Target = [u8]; + + fn deref(&self) -> &Self::Target { + self.content + } +} + +impl DerefMut for FrameMut<'_> { + fn deref_mut(&mut self) -> &mut Self::Target { + self.content + } +} + +impl FrameMut<'_> { + /// Commits the frame, making it visible to the receiver. + /// + /// If the receiver closed the channel and aborted this frame's slot + /// first, the frame is silently discarded: the record belongs to the + /// close race and is intentionally excluded either way. + pub fn finish(self) { + self.state.commit(self.slot_index, self.descriptor); + } +} + +// --- The receiver side: close and Frames ------------------------------------ +// +// Closing never waits for writers, and no payload byte is read or copied: +// `Frames` keeps the mapping alive and hands out borrows of the validated +// committed spans on demand. Those borrows are sound because of the +// protocol, not despite it: a committed span is never written again +// (committing consumes the writer's frame), every borrow covers exactly one +// validated committed span, and everything a live writer may still touch — +// counters, slots, its own claimed or abandoned spans — is disjoint from +// every committed span. This rests on the constructor contract that the +// region is accessed only through this protocol; a process scribbling +// outside the protocol is outside the trust model. + +/// The committed frames of a closed channel: validated spans borrowed from +/// the mapping, which stays alive inside this value. Dropping it releases +/// the mapping. +pub struct Frames { + mem: M, + spans: Vec, + complete: bool, +} + +impl Frames { + /// Iterates over the committed frames in claim order. + pub fn iter(&self) -> impl Iterator { + let base = self.mem.as_raw_slice().cast::().cast_const(); + self.spans.iter().map(move |span| { + // SAFETY: `close` validated the span against this mapping's + // layout, and a committed span is immutable for the mapping's + // lifetime (see the section comment above), so the shared borrow + // is valid for as long as `self` lives. + unsafe { slice::from_raw_parts(base.add(span.offset), span.len) } + }) + } + + /// Whether every record a writer published made it in. + /// + /// False when a claim failed before the channel closed — the region + /// was out of space, or a frame exceeded the frame limit: its record + /// was lost, and the frames under-report what writers went on to do. + /// Consumers that need completeness must reject them. + #[must_use] + pub const fn is_complete(&self) -> bool { + self.complete + } +} + +impl fmt::Debug for Frames { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Frames") + .field("frames", &self.spans.len()) + .field("complete", &self.complete) + .finish_non_exhaustive() + } +} + +/// Shared-memory metadata that could not have been produced by this +/// protocol. The region was corrupted; its frames are unusable. +#[derive(thiserror::Error, Clone, Copy, PartialEq, Eq, Debug)] +pub enum ProtocolError { + #[error("corrupt shared-memory frame descriptor at slot {slot_index}")] + CorruptDescriptor { slot_index: usize }, +} + +/// The receiver end of a channel. +/// +/// Attaching to the region is the one unsafe step, sharing +/// [`ShmWriter::new`]'s contract; closing is safe. +pub struct ShmReceiver { + mem: M, +} + +impl ShmReceiver { + /// Creates the receiver backed by a shared-memory region. + /// + /// # Safety + /// + /// Same contract as [`ShmWriter::new`]: + /// + /// - `mem.as_raw_slice()` must return a stable, valid pointer to the + /// whole region for the lifetime of the receiver and of the + /// [`Frames`] it closes into. + /// - The region must have been zero-initialized when it was created and + /// accessed only through this protocol since. + /// + /// # Panics + /// + /// Panics when the region is not `u64`-aligned or its size is outside + /// the supported range (see [`SharedState::borrow`]). + pub unsafe fn new(mem: M) -> Self { + // Validate the region geometry eagerly so misuse fails at + // construction, not at close. + // SAFETY: forwarded from this function's contract. + let _ = unsafe { SharedState::borrow(mem.as_raw_slice()) }; + Self { mem } + } + + /// Closes the channel and returns the committed frames as borrows of + /// the mapping, which moves into the returned [`Frames`]. + /// + /// Never blocks on writers: writers admitted before the snapshot race + /// per slot, and each raced slot independently ends up committed + /// (included) or aborted (excluded). Claims after the snapshot land in + /// slots this pass never visits until the CLOSED gate — set before + /// this returns — stops them. See the protocol docs at + /// the top of this module. + /// + /// # Errors + /// + /// [`ProtocolError`] when the shared-memory metadata could not have + /// been produced by a correct writer; the region was corrupted and its + /// frames are unusable. + pub fn close(self) -> Result, ProtocolError> { + let spans; + let complete; + { + // SAFETY: `new` requires the region to stay valid and + // protocol-governed, and it validated the geometry; the raw + // slice stays valid while `self.mem` is borrowed here and + // beyond, since it moves into the returned `Frames`. + let state = unsafe { SharedState::borrow(self.mem.as_raw_slice()) }; + + // The close boundary: claims at or before this snapshot are + // inside it, later ones land in slots this pass never visits. + // The count is clamped to the table capacity, so a counter + // inflated by failed claims (or by a foreign scribble) degrades + // to a full-table sweep, not an error. The snapshot also reads + // the loss flag: its one read either sees a loss, or the loss + // belongs to an operation performed after this boundary (rule 1 + // in the ordering contract above). + let (slot_count, is_complete) = state.snapshot(); + + // Gate further claims. Cheap: the creator pre-faulted this page + // where first touches are expensive. Claims racing between the + // snapshot and this gate are dropped soundly (see the module + // docs above). + state.close_claims(); + + // Freeze pass: drive every admitted slot to a terminal state + // and collect the committed spans. After this loop the + // snapshot's slice of the descriptor table can no longer change + // — late writers lose their commit race against `ABORTED`. + spans = freeze_committed_spans(state, slot_count)?; + complete = is_complete; + } + + Ok(Frames { mem: self.mem, spans, complete }) + } +} + +fn freeze_committed_spans( + state: SharedState<'_>, + slot_count: usize, +) -> Result, ProtocolError> { + let mut spans = Vec::new(); + for slot_index in 0..slot_count { + let bits = state.freeze(slot_index); + if bits == layout::ABORTED { + continue; + } + // Freeze only returns terminal values, so anything else must be a + // committed descriptor with a valid span; a foreign scribble fails + // the decode. + let span = layout::decode(state.len, bits) + .ok_or(ProtocolError::CorruptDescriptor { slot_index })?; + spans.push(span); + } + Ok(spans) +} + +/// Materializes the page backing the protocol header without changing +/// protocol state, so that neither a writer's first claim nor +/// [`ShmReceiver::close`]'s +/// snapshot pays for the backing file's first block allocation — a +/// millisecond-scale cost on some journalling filesystems, for reads of +/// holes as well as writes. Run it off any latency-sensitive path. See +/// [`SharedState::pre_fault`] for the mechanism. +/// +/// # Safety +/// +/// Same contract as [`ShmWriter::new`]. +#[cfg(target_os = "linux")] +pub unsafe fn pre_fault(mem: &impl AsRawSlice) { + // SAFETY: forwarded from this function's contract. + unsafe { SharedState::borrow(mem.as_raw_slice()) }.pre_fault(); +} + #[cfg(test)] mod tests { use std::{ diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/shared.rs b/crates/fspy_shared/src/ipc/channel/shm_io/shared.rs deleted file mode 100644 index 9797deb13..000000000 --- a/crates/fspy_shared/src/ipc/channel/shm_io/shared.rs +++ /dev/null @@ -1,617 +0,0 @@ -//! Everything that touches shared-memory bytes, in reading order: the -//! typed views of the region, the writer side (claim, fill, finish), and -//! the receiver side (close and [`Frames`]). -//! -//! One unsafe borrow in [`SharedState::borrow`] constructs three typed -//! views of the region — the `repr(C)` [`Header`], the descriptor table as -//! a slice of atomics sized by the mapping length, and the untyped payload -//! area as a raw slice. Every access after that is a plain field access or -//! a bounds-checked index. The payload area stays raw because writers hold -//! exclusive `&mut` borrows into it, which must not alias any shared -//! reference. -//! -//! # Shared atomics -//! -//! The header holds two independent monotonic `AtomicU64` counters and a -//! loss flag: -//! -//! - the **claim counter**: bit 63 is the CLOSED gate, the low bits count -//! claims ever attempted. Claiming is one wait-free `fetch_add`; the -//! returned old value carries the claim's slot index, the gate, and — by -//! comparison against the fixed table capacity — the capacity verdict. -//! - the **payload counter**: payload bytes ever reserved, bumped by another -//! wait-free `fetch_add`. -//! - the **loss flag**: set by every failed claim before the writer moves -//! on. The receiver derives completeness from it alone. -//! -//! Failed claims leave the counters bumped; that is harmless, because the -//! receiver clamps instead of trusting the counts, and committed -//! descriptors carry their own offset and length, so the counters never -//! locate data. -//! -//! # Memory-ordering contract -//! -//! Three synchronization rules cover the whole protocol: -//! -//! 1. **Claim versus close** — the receiver's close boundary is a plain -//! snapshot load of the claim counter: claims ordered at or before the -//! value it reads (in the counter's modification order) are in the -//! snapshot; later ones receive slot indices the receiver never visits. -//! Claims publish no payload data, so `Relaxed` suffices throughout. -//! The CLOSED gate only stops stragglers from claiming (and allocating -//! pages) forever; any claim admitted between the snapshot and the gate -//! lands beyond the snapshot and is never observed. Completeness rides -//! the same style of argument: a failed claim sets the loss flag before -//! the writer performs the operation whose record was lost — so the -//! receiver's one read of the flag either sees the loss, or the loss -//! belongs to an operation performed after the boundary. A writer that -//! dies before setting the flag never performed its operation, so -//! nothing was actually lost. -//! 2. **Writer commit** — the slot compare-and-swap uses `Release` -//! ([`SharedState::commit`]): every payload write happens-before the -//! committed descriptor becomes visible. -//! 3. **Receiver observation** — the freeze compare-and-swap uses `Acquire` -//! on failure ([`SharedState::freeze`]): observing a committed descriptor -//! also makes the payload writes it published visible, so the borrows -//! [`Frames`] later hands out read settled bytes. - -use std::{ - fmt, - num::NonZeroUsize, - ops::{Deref, DerefMut}, - slice, - sync::atomic::{AtomicU64, Ordering}, -}; - -use super::{ - AsRawSlice, - layout::{self, CLOSED, Header, PayloadSpan}, -}; - -/// A borrowed view of the shared mapping with protocol-level operations: -/// the typed header, the descriptor table sized from the mapping length, -/// and the raw payload area. -#[derive(Clone, Copy)] -struct SharedState<'m> { - header: &'m Header, - table: &'m [AtomicU64], - payloads: *mut [u8], - /// The real mapping length. Not derivable from the parts above: the - /// payload region rounds down to whole `u64`s, and re-deriving the - /// layout from a shortened length could shift the table boundary. - len: usize, -} - -impl SharedState<'_> { - /// Borrows a shared mapping. - /// - /// # Safety - /// - /// - `mem` must be valid for reads and writes for the lifetime `'m` and - /// its address must be stable. - /// - The memory must have been zero-initialized when the region was - /// created, and accessed only through this protocol since. - /// - /// # Panics - /// - /// Panics when the mapping cannot host the protocol at all: base not - /// `u64`-aligned, smaller than the header, or larger than - /// [`layout::MAX_MAPPING_LEN`]. These indicate a broken caller, not - /// runtime data; senders guard untrusted mappings with - /// [`super::is_supported_region_len`] first. - #[expect(clippy::cast_ptr_alignment, reason = "the base is asserted `u64`-aligned below")] - unsafe fn borrow(mem: *mut [u8]) -> Self { - let base = mem.cast::(); - let len = mem.len(); - assert!(base.addr().is_multiple_of(align_of::
())); - assert!((layout::HEADER_LEN..=layout::MAX_MAPPING_LEN).contains(&len)); - // SAFETY: the header and the table lie inside the mapping (the - // header by the assert above, the table by `layout::max_slots`), - // are `u64`-aligned (aligned base, `u64`-multiple offsets), and - // consist entirely of atomics zero-initialized at creation — so - // shared borrows for `'m` are valid even while other threads and - // processes access the same memory through these same atomics. The - // payload area keeps the rest of the mapping as a raw slice; - // `layout` bounds every span carved from it. - unsafe { - Self { - header: &*base.cast::
(), - table: slice::from_raw_parts( - base.add(layout::HEADER_LEN).cast::(), - layout::max_slots(len), - ), - payloads: std::ptr::slice_from_raw_parts_mut( - base.add(layout::payload_base(len)), - layout::payload_region_len(len), - ), - len, - } - } - } - - /// Byte offset where the payload region starts. - const fn payload_base(self) -> usize { - layout::payload_base(self.len) - } - - /// Whether the CLOSED gate has been set. - fn is_closed(self) -> bool { - self.header.claims.load(Ordering::Relaxed) & CLOSED != 0 - } - - /// Atomically reserves one descriptor slot and one payload span, - /// returning the slot index and the payload's byte offset in the - /// mapping. - /// - /// Wait-free: two `fetch_add`s, no retry loop (rule 1). A claim that - /// does not fit fails after setting the loss flag, which is what tells - /// the receiver a record was lost. - fn try_claim(self, payload_len: usize) -> Result<(usize, usize), ClaimError> { - // No descriptor can describe a payload this long; refuse it before - // touching the counters, so the channel keeps working for every - // record after it. - if payload_len > layout::MAX_PAYLOAD_LEN { - return Err(self.report_loss()); - } - let reserved_len = layout::reserved_payload_len(payload_len); - - // Payload bytes first, so a payload-capacity failure does not burn a - // slot. A failed reservation stays counted — overshoot is harmless - // because the counter is not what locates payloads (descriptors are) - // and a `u64` cannot realistically wrap. - let payload_start = - self.header.payload_reserved.fetch_add(reserved_len as u64, Ordering::Relaxed); - // Checked: a foreign scribble of the counter must fail the claim, - // not wrap the bound into an out-of-bounds reservation. - let payload_end = payload_start.checked_add(reserved_len as u64); - if payload_end.is_none_or(|end| end > self.payloads.len() as u64) { - return Err(self.report_loss()); - } - - let claims = self.header.claims.fetch_add(1, Ordering::Relaxed); - if claims & CLOSED != 0 { - // Not a loss: a record refused after close describes an - // operation performed outside the channel's boundary. - return Err(ClaimError::Closed); - } - let slot_index = usize::try_from(claims).expect("claim count exceeds usize"); - if slot_index >= self.table.len() { - return Err(self.report_loss()); - } - - // In bounds: `payload_start + reserved_len` fits the payload - // region, which ends within the mapping (`layout`). - let payload_offset = self.payload_base() - + usize::try_from(payload_start).expect("bounded by the payload region"); - Ok((slot_index, payload_offset)) - } - - /// Records that a claim failed and its record was lost, before the - /// caller moves on (rule 1). Returns the error the failed claim - /// reports. - fn report_loss(self) -> ClaimError { - self.header.lost.store(1, Ordering::Relaxed); - ClaimError::Capacity - } - - /// Snapshots the claim counter and the loss flag; the counter load is - /// the receiver's close boundary (rule 1). Returns the admitted slot - /// count, clamped to the table capacity, and whether every record made - /// it: false once any claim failed and set the flag. - fn snapshot(self) -> (usize, bool) { - let claims = self.header.claims.load(Ordering::Relaxed) & !CLOSED; - let complete = self.header.lost.load(Ordering::Relaxed) == 0; - (usize::try_from(claims).unwrap_or(usize::MAX).min(self.table.len()), complete) - } - - /// Sets the CLOSED gate so stragglers stop claiming. - fn close_claims(self) { - self.header.claims.fetch_or(CLOSED, Ordering::Relaxed); - } - - /// Forces the page backing the header (and the table's first slots) to - /// be materialized by the operating system before anyone touches it on - /// a latency-sensitive path. - /// - /// A compare-exchange of zero with zero on the claim counter: on an - /// untouched region it performs a real write — allocating the first - /// block of a sparse backing file, which can cost milliseconds on - /// journalling filesystems — without changing protocol state. If a - /// claim got there first, the page is already backed and the failed - /// exchange changes nothing. (An `or` of zero would not do: the - /// compiler may lower it to a plain load, which materializes only a - /// hole page without allocating the block.) - /// - /// Only Linux channels use this: elsewhere the first touch is cheap. - #[cfg(target_os = "linux")] - fn pre_fault(self) { - let _ = self.header.claims.compare_exchange(0, 0, Ordering::Relaxed, Ordering::Relaxed); - } - - /// Publishes a committed descriptor into an unfinished slot. - /// - /// Returns false when the receiver aborted the slot first; the payload is - /// then permanently unreachable and the writer must not touch it again - /// either way. - /// - /// # Panics - /// - /// Panics when `slot_index` lies outside the table; callers only pass - /// indices of admitted reservations. - fn commit(self, slot_index: usize, descriptor: u64) -> bool { - // Rule 2: `Release` orders every payload write before the descriptor. - self.table[slot_index] - .compare_exchange(layout::UNFINISHED, descriptor, Ordering::Release, Ordering::Relaxed) - .is_ok() - } - - /// Freezes one slot during close and returns its terminal value: `ABORTED` - /// when the receiver won the race, the committed descriptor otherwise. - /// - /// # Panics - /// - /// Panics when `slot_index` lies outside the table; the receiver only - /// passes indices below its clamped snapshot. - fn freeze(self, slot_index: usize) -> u64 { - // Rule 3: `Acquire` on failure makes a committed payload visible. - match self.table[slot_index].compare_exchange( - layout::UNFINISHED, - layout::ABORTED, - Ordering::AcqRel, - Ordering::Acquire, - ) { - Ok(_) => layout::ABORTED, - Err(terminal) => terminal, - } - } - - /// Pointer to a reserved payload span. The caller owns the span's - /// exclusivity argument. - fn payload_ptr(self, offset: usize) -> *mut u8 { - debug_assert!((self.payload_base()..=self.len).contains(&offset)); - // SAFETY: callers pass offsets of admitted reservations, which - // `layout` keeps inside the payload area. - unsafe { self.payloads.cast::().add(offset - self.payload_base()) } - } -} - -// --- The writer side: claim, fill, finish ---------------------------------- - -/// A concurrent shared-memory frame writer. -/// -/// Safe to use across threads and processes at the same time: frames are -/// reserved with atomic operations, filled in uniquely owned payload spans, -/// and published with an atomic commit (see the ordering contract above). -pub struct ShmWriter { - mem: M, -} - -/// Why a frame could not be claimed. -#[derive(thiserror::Error, Clone, Copy, PartialEq, Eq, Debug)] -pub enum ClaimError { - /// The receiver closed the channel; anything after this point is - /// outside the channel's boundary. - #[error("the channel has been closed by the receiver")] - Closed, - /// The claim was refused for space: the region was full, or the frame - /// was larger than the `i32::MAX`-byte frame limit. The loss is - /// already recorded, so the channel will report itself incomplete. - #[error("no space left in the shared-memory region")] - Capacity, -} - -impl ShmWriter { - /// Creates a writer backed by a shared-memory region. - /// - /// # Safety - /// - /// - `mem.as_raw_slice()` must return a stable, valid pointer to the - /// whole region for the writer's lifetime. - /// - The region must have been zero-initialized when it was created and - /// accessed only through this protocol since. - /// - /// # Panics - /// - /// Panics when the region is not `u64`-aligned or its size is outside the - /// supported range (see [`SharedState::borrow`]). - pub unsafe fn new(mem: M) -> Self { - // Validate the region geometry eagerly so misuse fails at - // construction, not at the first claim. - // SAFETY: forwarded from this function's contract. - let _ = unsafe { SharedState::borrow(mem.as_raw_slice()) }; - Self { mem } - } - - fn state(&self) -> SharedState<'_> { - // SAFETY: `new` requires the region to stay valid and - // protocol-governed for the writer's lifetime, and it validated the - // geometry. - unsafe { SharedState::borrow(self.mem.as_raw_slice()) } - } - - /// Whether the receiver has closed the channel. - pub fn is_closed(&self) -> bool { - self.state().is_closed() - } - - /// Claims a frame of exactly `frame_size` bytes. - /// - /// The frame is invisible to the receiver until [`FrameMut::finish`] - /// commits it. Dropping the frame without finishing abandons the claim: - /// the receiver ignores the slot, exactly as if the writer had died. - /// Frames larger than `i32::MAX` bytes are refused as - /// [`ClaimError::Capacity`]. - pub fn claim_frame(&self, frame_size: NonZeroUsize) -> Result, ClaimError> { - let state = self.state(); - let (slot_index, payload_offset) = state.try_claim(frame_size.get())?; - - let content_ptr = state.payload_ptr(payload_offset); - // SAFETY: the claim reserved - // `[payload_offset, payload_offset + frame_size)` exclusively for - // this frame: other writers reserve disjoint spans, and the receiver - // never reads a payload before observing its committed descriptor — - // which `finish` publishes only when it consumes this borrow. - let content = unsafe { slice::from_raw_parts_mut(content_ptr, frame_size.get()) }; - Ok(FrameMut { - state, - slot_index, - descriptor: layout::committed(payload_offset, frame_size.get()), - content, - }) - } - - // Unwrap `self` and return the underlying memory. - #[cfg(all(test, not(miri)))] - pub fn into_memory(self) -> M { - self.mem - } - - #[cfg(test)] - pub fn try_write_frame(&self, frame: &[u8]) -> bool { - let Some(frame_size) = NonZeroUsize::new(frame.len()) else { - return false; - }; - let Ok(mut frame_mut) = self.claim_frame(frame_size) else { - return false; - }; - frame_mut.copy_from_slice(frame); - frame_mut.finish(); - true - } -} - -/// An exclusively owned, claimed-but-unpublished frame. -/// -/// [`FrameMut::finish`] commits the frame; it is the only way to make the -/// payload visible to the receiver. Dropping the frame instead abandons -/// the claim: the slot stays unfinished and the receiver ignores it, -/// exactly as if the writer had died there. A writer that abandons a frame -/// and still performs the operation it described steps outside the usage -/// contract — records are published before the recorded operation. -pub struct FrameMut<'a> { - state: SharedState<'a>, - slot_index: usize, - descriptor: u64, - content: &'a mut [u8], -} - -impl fmt::Debug for FrameMut<'_> { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("FrameMut") - .field("slot_index", &self.slot_index) - .field("len", &self.content.len()) - .finish_non_exhaustive() - } -} - -impl Deref for FrameMut<'_> { - type Target = [u8]; - - fn deref(&self) -> &Self::Target { - self.content - } -} - -impl DerefMut for FrameMut<'_> { - fn deref_mut(&mut self) -> &mut Self::Target { - self.content - } -} - -impl FrameMut<'_> { - /// Commits the frame, making it visible to the receiver. - /// - /// If the receiver closed the channel and aborted this frame's slot - /// first, the frame is silently discarded: the record belongs to the - /// close race and is intentionally excluded either way. - pub fn finish(self) { - self.state.commit(self.slot_index, self.descriptor); - } -} - -// --- The receiver side: close and Frames ------------------------------------ -// -// Closing never waits for writers, and no payload byte is read or copied: -// `Frames` keeps the mapping alive and hands out borrows of the validated -// committed spans on demand. Those borrows are sound because of the -// protocol, not despite it: a committed span is never written again -// (committing consumes the writer's frame), every borrow covers exactly one -// validated committed span, and everything a live writer may still touch — -// counters, slots, its own claimed or abandoned spans — is disjoint from -// every committed span. This rests on the constructor contract that the -// region is accessed only through this protocol; a process scribbling -// outside the protocol is outside the trust model. - -/// The committed frames of a closed channel: validated spans borrowed from -/// the mapping, which stays alive inside this value. Dropping it releases -/// the mapping. -pub struct Frames { - mem: M, - spans: Vec, - complete: bool, -} - -impl Frames { - /// Iterates over the committed frames in claim order. - pub fn iter(&self) -> impl Iterator { - let base = self.mem.as_raw_slice().cast::().cast_const(); - self.spans.iter().map(move |span| { - // SAFETY: `close` validated the span against this mapping's - // layout, and a committed span is immutable for the mapping's - // lifetime (see the section comment above), so the shared borrow - // is valid for as long as `self` lives. - unsafe { slice::from_raw_parts(base.add(span.offset), span.len) } - }) - } - - /// Whether every record a writer published made it in. - /// - /// False when a claim failed before the channel closed — the region - /// was out of space, or a frame exceeded the frame limit: its record - /// was lost, and the frames under-report what writers went on to do. - /// Consumers that need completeness must reject them. - #[must_use] - pub const fn is_complete(&self) -> bool { - self.complete - } -} - -impl fmt::Debug for Frames { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("Frames") - .field("frames", &self.spans.len()) - .field("complete", &self.complete) - .finish_non_exhaustive() - } -} - -/// Shared-memory metadata that could not have been produced by this -/// protocol. The region was corrupted; its frames are unusable. -#[derive(thiserror::Error, Clone, Copy, PartialEq, Eq, Debug)] -pub enum ProtocolError { - #[error("corrupt shared-memory frame descriptor at slot {slot_index}")] - CorruptDescriptor { slot_index: usize }, -} - -/// The receiver end of a channel. -/// -/// Attaching to the region is the one unsafe step, sharing -/// [`ShmWriter::new`]'s contract; closing is safe. -pub struct ShmReceiver { - mem: M, -} - -impl ShmReceiver { - /// Creates the receiver backed by a shared-memory region. - /// - /// # Safety - /// - /// Same contract as [`ShmWriter::new`]: - /// - /// - `mem.as_raw_slice()` must return a stable, valid pointer to the - /// whole region for the lifetime of the receiver and of the - /// [`Frames`] it closes into. - /// - The region must have been zero-initialized when it was created and - /// accessed only through this protocol since. - /// - /// # Panics - /// - /// Panics when the region is not `u64`-aligned or its size is outside - /// the supported range (see [`SharedState::borrow`]). - pub unsafe fn new(mem: M) -> Self { - // Validate the region geometry eagerly so misuse fails at - // construction, not at close. - // SAFETY: forwarded from this function's contract. - let _ = unsafe { SharedState::borrow(mem.as_raw_slice()) }; - Self { mem } - } - - /// Closes the channel and returns the committed frames as borrows of - /// the mapping, which moves into the returned [`Frames`]. - /// - /// Never blocks on writers: writers admitted before the snapshot race - /// per slot, and each raced slot independently ends up committed - /// (included) or aborted (excluded). Claims after the snapshot land in - /// slots this pass never visits until the CLOSED gate — set before - /// this returns — stops them. See the crate-level protocol docs in - /// [`super`]. - /// - /// # Errors - /// - /// [`ProtocolError`] when the shared-memory metadata could not have - /// been produced by a correct writer; the region was corrupted and its - /// frames are unusable. - pub fn close(self) -> Result, ProtocolError> { - let spans; - let complete; - { - // SAFETY: `new` requires the region to stay valid and - // protocol-governed, and it validated the geometry; the raw - // slice stays valid while `self.mem` is borrowed here and - // beyond, since it moves into the returned `Frames`. - let state = unsafe { SharedState::borrow(self.mem.as_raw_slice()) }; - - // The close boundary: claims at or before this snapshot are - // inside it, later ones land in slots this pass never visits. - // The count is clamped to the table capacity, so a counter - // inflated by failed claims (or by a foreign scribble) degrades - // to a full-table sweep, not an error. The snapshot also reads - // the loss flag: its one read either sees a loss, or the loss - // belongs to an operation performed after this boundary (rule 1 - // in the ordering contract above). - let (slot_count, is_complete) = state.snapshot(); - - // Gate further claims. Cheap: the creator pre-faulted this page - // where first touches are expensive. Claims racing between the - // snapshot and this gate are dropped soundly (see the module - // docs in `super`). - state.close_claims(); - - // Freeze pass: drive every admitted slot to a terminal state - // and collect the committed spans. After this loop the - // snapshot's slice of the descriptor table can no longer change - // — late writers lose their commit race against `ABORTED`. - spans = freeze_committed_spans(state, slot_count)?; - complete = is_complete; - } - - Ok(Frames { mem: self.mem, spans, complete }) - } -} - -fn freeze_committed_spans( - state: SharedState<'_>, - slot_count: usize, -) -> Result, ProtocolError> { - let mut spans = Vec::new(); - for slot_index in 0..slot_count { - let bits = state.freeze(slot_index); - if bits == layout::ABORTED { - continue; - } - // Freeze only returns terminal values, so anything else must be a - // committed descriptor with a valid span; a foreign scribble fails - // the decode. - let span = layout::decode(state.len, bits) - .ok_or(ProtocolError::CorruptDescriptor { slot_index })?; - spans.push(span); - } - Ok(spans) -} - -/// Materializes the page backing the protocol header without changing -/// protocol state, so that neither a writer's first claim nor -/// [`ShmReceiver::close`]'s -/// snapshot pays for the backing file's first block allocation — a -/// millisecond-scale cost on some journalling filesystems, for reads of -/// holes as well as writes. Run it off any latency-sensitive path. See -/// [`SharedState::pre_fault`] for the mechanism. -/// -/// # Safety -/// -/// Same contract as [`ShmWriter::new`]. -#[cfg(target_os = "linux")] -pub unsafe fn pre_fault(mem: &impl AsRawSlice) { - // SAFETY: forwarded from this function's contract. - unsafe { SharedState::borrow(mem.as_raw_slice()) }.pre_fault(); -} From b17bad9a5ca5b14586f277103e89381e80cf089c Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sat, 15 Aug 2026 15:14:12 +0800 Subject: [PATCH 30/92] refactor(fspy-shm): move each side's operations into that side MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SharedState accumulated every protocol operation while the writer and receiver types just forwarded to it — but no operation was actually shared between the sides. Each now lives with its only caller: claim_frame absorbs the claim sequence, the loss report, and the payload pointer math (whose base-offset round-trip cancels out once merged); finish holds the commit CAS; close holds the snapshot, the gate, and the freeze pass; pre_fault holds its warming CAS. SharedState is reduced to what the sides genuinely share: the borrowed views and their one unsafe constructor. The inlining surfaced one behavior the old freeze helper hid: a second close over the same region finds slots already aborted in the CAS failure path, so the ABORTED skip stays (commit_after_abort test). Co-Authored-By: Claude Fable 5 --- .../fspy_shared/src/ipc/channel/shm_io/mod.rs | 346 +++++++----------- 1 file changed, 135 insertions(+), 211 deletions(-) diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs index 164c0812c..499087f1c 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs @@ -158,17 +158,17 @@ pub fn is_supported_region_len(len: usize) -> bool { // belongs to an operation performed after the boundary. A writer that // dies before setting the flag never performed its operation, so // nothing was actually lost. -// 2. **Writer commit** — the slot compare-and-swap uses `Release` -// (`SharedState::commit`): every payload write happens-before the -// committed descriptor becomes visible. -// 3. **Receiver observation** — the freeze compare-and-swap uses `Acquire` -// on failure (`SharedState::freeze`): observing a committed descriptor -// also makes the payload writes it published visible, so the borrows -// `Frames` later hands out read settled bytes. - -/// A borrowed view of the shared mapping with protocol-level operations: -/// the typed header, the descriptor table sized from the mapping length, -/// and the raw payload area. +// 2. **Writer commit** — the slot compare-and-swap in `FrameMut::finish` +// uses `Release`: every payload write happens-before the committed +// descriptor becomes visible. +// 3. **Receiver observation** — the freeze compare-and-swap in +// `ShmReceiver::close` uses `Acquire` on failure: observing a committed +// descriptor also makes the payload writes it published visible, so the +// borrows `Frames` later hands out read settled bytes. + +/// A borrowed view of the shared mapping: the typed header, the +/// descriptor table sized from the mapping length, and the raw payload +/// area. #[derive(Clone, Copy)] struct SharedState<'m> { header: &'m Header, @@ -226,151 +226,6 @@ impl SharedState<'_> { } } } - - /// Byte offset where the payload region starts. - const fn payload_base(self) -> usize { - layout::payload_base(self.len) - } - - /// Whether the CLOSED gate has been set. - fn is_closed(self) -> bool { - self.header.claims.load(Ordering::Relaxed) & CLOSED != 0 - } - - /// Atomically reserves one descriptor slot and one payload span, - /// returning the slot index and the payload's byte offset in the - /// mapping. - /// - /// Wait-free: two `fetch_add`s, no retry loop (rule 1). A claim that - /// does not fit fails after setting the loss flag, which is what tells - /// the receiver a record was lost. - fn try_claim(self, payload_len: usize) -> Result<(usize, usize), ClaimError> { - // No descriptor can describe a payload this long; refuse it before - // touching the counters, so the channel keeps working for every - // record after it. - if payload_len > layout::MAX_PAYLOAD_LEN { - return Err(self.report_loss()); - } - let reserved_len = layout::reserved_payload_len(payload_len); - - // Payload bytes first, so a payload-capacity failure does not burn a - // slot. A failed reservation stays counted — overshoot is harmless - // because the counter is not what locates payloads (descriptors are) - // and a `u64` cannot realistically wrap. - let payload_start = - self.header.payload_reserved.fetch_add(reserved_len as u64, Ordering::Relaxed); - // Checked: a foreign scribble of the counter must fail the claim, - // not wrap the bound into an out-of-bounds reservation. - let payload_end = payload_start.checked_add(reserved_len as u64); - if payload_end.is_none_or(|end| end > self.payloads.len() as u64) { - return Err(self.report_loss()); - } - - let claims = self.header.claims.fetch_add(1, Ordering::Relaxed); - if claims & CLOSED != 0 { - // Not a loss: a record refused after close describes an - // operation performed outside the channel's boundary. - return Err(ClaimError::Closed); - } - let slot_index = usize::try_from(claims).expect("claim count exceeds usize"); - if slot_index >= self.table.len() { - return Err(self.report_loss()); - } - - // In bounds: `payload_start + reserved_len` fits the payload - // region, which ends within the mapping (`layout`). - let payload_offset = self.payload_base() - + usize::try_from(payload_start).expect("bounded by the payload region"); - Ok((slot_index, payload_offset)) - } - - /// Records that a claim failed and its record was lost, before the - /// caller moves on (rule 1). Returns the error the failed claim - /// reports. - fn report_loss(self) -> ClaimError { - self.header.lost.store(1, Ordering::Relaxed); - ClaimError::Capacity - } - - /// Snapshots the claim counter and the loss flag; the counter load is - /// the receiver's close boundary (rule 1). Returns the admitted slot - /// count, clamped to the table capacity, and whether every record made - /// it: false once any claim failed and set the flag. - fn snapshot(self) -> (usize, bool) { - let claims = self.header.claims.load(Ordering::Relaxed) & !CLOSED; - let complete = self.header.lost.load(Ordering::Relaxed) == 0; - (usize::try_from(claims).unwrap_or(usize::MAX).min(self.table.len()), complete) - } - - /// Sets the CLOSED gate so stragglers stop claiming. - fn close_claims(self) { - self.header.claims.fetch_or(CLOSED, Ordering::Relaxed); - } - - /// Forces the page backing the header (and the table's first slots) to - /// be materialized by the operating system before anyone touches it on - /// a latency-sensitive path. - /// - /// A compare-exchange of zero with zero on the claim counter: on an - /// untouched region it performs a real write — allocating the first - /// block of a sparse backing file, which can cost milliseconds on - /// journalling filesystems — without changing protocol state. If a - /// claim got there first, the page is already backed and the failed - /// exchange changes nothing. (An `or` of zero would not do: the - /// compiler may lower it to a plain load, which materializes only a - /// hole page without allocating the block.) - /// - /// Only Linux channels use this: elsewhere the first touch is cheap. - #[cfg(target_os = "linux")] - fn pre_fault(self) { - let _ = self.header.claims.compare_exchange(0, 0, Ordering::Relaxed, Ordering::Relaxed); - } - - /// Publishes a committed descriptor into an unfinished slot. - /// - /// Returns false when the receiver aborted the slot first; the payload is - /// then permanently unreachable and the writer must not touch it again - /// either way. - /// - /// # Panics - /// - /// Panics when `slot_index` lies outside the table; callers only pass - /// indices of admitted reservations. - fn commit(self, slot_index: usize, descriptor: u64) -> bool { - // Rule 2: `Release` orders every payload write before the descriptor. - self.table[slot_index] - .compare_exchange(layout::UNFINISHED, descriptor, Ordering::Release, Ordering::Relaxed) - .is_ok() - } - - /// Freezes one slot during close and returns its terminal value: `ABORTED` - /// when the receiver won the race, the committed descriptor otherwise. - /// - /// # Panics - /// - /// Panics when `slot_index` lies outside the table; the receiver only - /// passes indices below its clamped snapshot. - fn freeze(self, slot_index: usize) -> u64 { - // Rule 3: `Acquire` on failure makes a committed payload visible. - match self.table[slot_index].compare_exchange( - layout::UNFINISHED, - layout::ABORTED, - Ordering::AcqRel, - Ordering::Acquire, - ) { - Ok(_) => layout::ABORTED, - Err(terminal) => terminal, - } - } - - /// Pointer to a reserved payload span. The caller owns the span's - /// exclusivity argument. - fn payload_ptr(self, offset: usize) -> *mut u8 { - debug_assert!((self.payload_base()..=self.len).contains(&offset)); - // SAFETY: callers pass offsets of admitted reservations, which - // `layout` keeps inside the payload area. - unsafe { self.payloads.cast::().add(offset - self.payload_base()) } - } } // --- The writer side: claim, fill, finish ---------------------------------- @@ -429,7 +284,7 @@ impl ShmWriter { /// Whether the receiver has closed the channel. pub fn is_closed(&self) -> bool { - self.state().is_closed() + self.state().header.claims.load(Ordering::Relaxed) & CLOSED != 0 } /// Claims a frame of exactly `frame_size` bytes. @@ -439,21 +294,70 @@ impl ShmWriter { /// the receiver ignores the slot, exactly as if the writer had died. /// Frames larger than `i32::MAX` bytes are refused as /// [`ClaimError::Capacity`]. + /// + /// Wait-free: two `fetch_add`s, no retry loop (rule 1). A claim that + /// does not fit fails after setting the loss flag, which is what tells + /// the receiver a record was lost. pub fn claim_frame(&self, frame_size: NonZeroUsize) -> Result, ClaimError> { let state = self.state(); - let (slot_index, payload_offset) = state.try_claim(frame_size.get())?; + let payload_len = frame_size.get(); + + // Records that a claim failed and its record was lost, before the + // writer moves on (rule 1). + let report_loss = || { + state.header.lost.store(1, Ordering::Relaxed); + ClaimError::Capacity + }; + + // No descriptor can describe a payload this long; refuse it before + // touching the counters, so the channel keeps working for every + // record after it. + if payload_len > layout::MAX_PAYLOAD_LEN { + return Err(report_loss()); + } + let reserved_len = layout::reserved_payload_len(payload_len); + + // Payload bytes first, so a payload-capacity failure does not burn a + // slot. A failed reservation stays counted — overshoot is harmless + // because the counter is not what locates payloads (descriptors are) + // and a `u64` cannot realistically wrap. + let payload_start = + state.header.payload_reserved.fetch_add(reserved_len as u64, Ordering::Relaxed); + // Checked: a foreign scribble of the counter must fail the claim, + // not wrap the bound into an out-of-bounds reservation. + let payload_end = payload_start.checked_add(reserved_len as u64); + if payload_end.is_none_or(|end| end > state.payloads.len() as u64) { + return Err(report_loss()); + } + let payload_start = usize::try_from(payload_start).expect("bounded by the payload region"); + + let claims = state.header.claims.fetch_add(1, Ordering::Relaxed); + if claims & CLOSED != 0 { + // Not a loss: a record refused after close describes an + // operation performed outside the channel's boundary. + return Err(ClaimError::Closed); + } + let slot_index = usize::try_from(claims).expect("claim count exceeds usize"); + if slot_index >= state.table.len() { + return Err(report_loss()); + } - let content_ptr = state.payload_ptr(payload_offset); // SAFETY: the claim reserved - // `[payload_offset, payload_offset + frame_size)` exclusively for - // this frame: other writers reserve disjoint spans, and the receiver - // never reads a payload before observing its committed descriptor — + // `[payload_start, payload_start + reserved_len)` — inside the + // payload region by the capacity check above — exclusively for this + // frame: other writers reserve disjoint spans, and the receiver + // never reads a payload before observing its committed descriptor, // which `finish` publishes only when it consumes this borrow. - let content = unsafe { slice::from_raw_parts_mut(content_ptr, frame_size.get()) }; + let content = unsafe { + slice::from_raw_parts_mut(state.payloads.cast::().add(payload_start), payload_len) + }; Ok(FrameMut { state, slot_index, - descriptor: layout::committed(payload_offset, frame_size.get()), + descriptor: layout::committed( + layout::payload_base(state.len) + payload_start, + payload_len, + ), content, }) } @@ -520,10 +424,18 @@ impl FrameMut<'_> { /// Commits the frame, making it visible to the receiver. /// /// If the receiver closed the channel and aborted this frame's slot - /// first, the frame is silently discarded: the record belongs to the - /// close race and is intentionally excluded either way. + /// first, the swap fails and the frame is silently discarded: the + /// record belongs to the close race and is intentionally excluded + /// either way. pub fn finish(self) { - self.state.commit(self.slot_index, self.descriptor); + // Rule 2: `Release` orders every payload write before the + // descriptor. + let _ = self.state.table[self.slot_index].compare_exchange( + layout::UNFINISHED, + self.descriptor, + Ordering::Release, + Ordering::Relaxed, + ); } } @@ -640,7 +552,7 @@ impl ShmReceiver { /// been produced by a correct writer; the region was corrupted and its /// frames are unusable. pub fn close(self) -> Result, ProtocolError> { - let spans; + let mut spans = Vec::new(); let complete; { // SAFETY: `new` requires the region to stay valid and @@ -649,61 +561,65 @@ impl ShmReceiver { // beyond, since it moves into the returned `Frames`. let state = unsafe { SharedState::borrow(self.mem.as_raw_slice()) }; - // The close boundary: claims at or before this snapshot are - // inside it, later ones land in slots this pass never visits. - // The count is clamped to the table capacity, so a counter - // inflated by failed claims (or by a foreign scribble) degrades - // to a full-table sweep, not an error. The snapshot also reads - // the loss flag: its one read either sees a loss, or the loss - // belongs to an operation performed after this boundary (rule 1 - // in the ordering contract above). - let (slot_count, is_complete) = state.snapshot(); - - // Gate further claims. Cheap: the creator pre-faulted this page - // where first touches are expensive. Claims racing between the - // snapshot and this gate are dropped soundly (see the module - // docs above). - state.close_claims(); + // The close boundary (rule 1): claims at or before this + // snapshot are inside it, later ones land in slots this pass + // never visits. The count is clamped to the table capacity, so + // a counter inflated by failed claims (or by a foreign + // scribble) degrades to a full-table sweep, not an error. + let claims = state.header.claims.load(Ordering::Relaxed) & !CLOSED; + let slot_count = usize::try_from(claims).unwrap_or(usize::MAX).min(state.table.len()); + // One read of the loss flag: it either sees a loss, or the loss + // belongs to an operation performed after the boundary (rule 1). + complete = state.header.lost.load(Ordering::Relaxed) == 0; + + // Gate further claims, so stragglers stop claiming (and + // materializing pages) forever. Cheap: the creator pre-faulted + // this page where first touches are expensive. Claims racing + // between the snapshot and this gate are dropped soundly (see + // the module docs above). + state.header.claims.fetch_or(CLOSED, Ordering::Relaxed); // Freeze pass: drive every admitted slot to a terminal state // and collect the committed spans. After this loop the // snapshot's slice of the descriptor table can no longer change // — late writers lose their commit race against `ABORTED`. - spans = freeze_committed_spans(state, slot_count)?; - complete = is_complete; + for slot_index in 0..slot_count { + // Rule 3: `Acquire` on failure makes a committed payload + // visible. + let Err(bits) = state.table[slot_index].compare_exchange( + layout::UNFINISHED, + layout::ABORTED, + Ordering::AcqRel, + Ordering::Acquire, + ) else { + // The receiver won the race: the unfinished slot is now + // aborted and stays ignored. + continue; + }; + if bits == layout::ABORTED { + // Aborted by an earlier close over the same region; + // still ignored. + continue; + } + // Any other terminal value must be a committed descriptor + // with a valid span; a foreign scribble fails the decode. + let span = layout::decode(state.len, bits) + .ok_or(ProtocolError::CorruptDescriptor { slot_index })?; + spans.push(span); + } } Ok(Frames { mem: self.mem, spans, complete }) } } -fn freeze_committed_spans( - state: SharedState<'_>, - slot_count: usize, -) -> Result, ProtocolError> { - let mut spans = Vec::new(); - for slot_index in 0..slot_count { - let bits = state.freeze(slot_index); - if bits == layout::ABORTED { - continue; - } - // Freeze only returns terminal values, so anything else must be a - // committed descriptor with a valid span; a foreign scribble fails - // the decode. - let span = layout::decode(state.len, bits) - .ok_or(ProtocolError::CorruptDescriptor { slot_index })?; - spans.push(span); - } - Ok(spans) -} - /// Materializes the page backing the protocol header without changing /// protocol state, so that neither a writer's first claim nor -/// [`ShmReceiver::close`]'s -/// snapshot pays for the backing file's first block allocation — a -/// millisecond-scale cost on some journalling filesystems, for reads of -/// holes as well as writes. Run it off any latency-sensitive path. See -/// [`SharedState::pre_fault`] for the mechanism. +/// [`ShmReceiver::close`]'s snapshot pays for the backing file's first +/// block allocation — a millisecond-scale cost on some journalling +/// filesystems, for reads of holes as well as writes. Run it off any +/// latency-sensitive path. Only Linux channels use this: elsewhere the +/// first touch is cheap. /// /// # Safety /// @@ -711,7 +627,15 @@ fn freeze_committed_spans( #[cfg(target_os = "linux")] pub unsafe fn pre_fault(mem: &impl AsRawSlice) { // SAFETY: forwarded from this function's contract. - unsafe { SharedState::borrow(mem.as_raw_slice()) }.pre_fault(); + let state = unsafe { SharedState::borrow(mem.as_raw_slice()) }; + // A compare-exchange of zero with zero on the claim counter: on an + // untouched region it performs a real write — allocating the first + // block of a sparse backing file — without changing protocol state. If + // a claim got there first, the page is already backed and the failed + // exchange changes nothing. (An `or` of zero would not do: the + // compiler may lower it to a plain load, which materializes only a + // hole page without allocating the block.) + let _ = state.header.claims.compare_exchange(0, 0, Ordering::Relaxed, Ordering::Relaxed); } #[cfg(test)] From a6f4ddd90aa63613383b04fb782ce86ce8e93688 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sat, 15 Aug 2026 15:53:00 +0800 Subject: [PATCH 31/92] refactor(fspy-shm): merge the receiver and Frames into ShmReader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The receiver type existed only to be consumed by its one method, and Frames was that method's result — two names for the halves of a single transition. ShmReader is the whole thing: the unsafe close constructor attaches to the region (the writer contract), snapshots, gates, and freezes, and the value you get back is the reader you iterate. The name pairs with ShmWriter the way the original protocol's reader did. Iteration becomes a real iterator type: Iter walks the validated spans, and &ShmReader implements IntoIterator, so for-loops work directly — clippy immediately insisted the tests use them. Co-Authored-By: Claude Fable 5 --- crates/fspy_shared/src/ipc/channel/mod.rs | 29 ++- .../src/ipc/channel/shm_io/README.md | 12 +- .../fspy_shared/src/ipc/channel/shm_io/mod.rs | 201 +++++++++--------- 3 files changed, 123 insertions(+), 119 deletions(-) diff --git a/crates/fspy_shared/src/ipc/channel/mod.rs b/crates/fspy_shared/src/ipc/channel/mod.rs index a752aa2b1..a24f7776f 100644 --- a/crates/fspy_shared/src/ipc/channel/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/mod.rs @@ -13,11 +13,11 @@ use allocator_api2::alloc::Global; use fspy_nostd::Fat; use fspy_nostd_alloc::OsCString; use fspy_shm::Mapping; -use shm_io::{ShmReceiver, ShmWriter}; +use shm_io::{ShmReader, ShmWriter}; /// The committed frames of a closed channel; borrows the shared mapping, /// which stays alive (and mapped) until this value drops. -pub type Frames = shm_io::Frames; +pub type Frames = shm_io::ShmReader; use uuid::Uuid; use wincode::{SchemaRead, SchemaWrite, Serialize as _, config::DefaultConfig}; @@ -68,12 +68,7 @@ pub fn channel(capacity: usize) -> io::Result<(ChannelConf, Receiver)> { let conf = ChannelConf { shm_id: IpcStr::from_os_c_str(keeper.path.as_c_str()).to_boxed() }; - // SAFETY: the region was created zero-initialized just above, its mapping - // address is stable and independently owned, and every attached process - // accesses it only through the `shm_io` protocol. - let shm = unsafe { ShmReceiver::new(mapping) }; - - Ok((conf, Receiver { _keeper: keeper, shm })) + Ok((conf, Receiver { _keeper: keeper, mapping })) } /// Encodes `path` as an owned NUL-terminated platform C string. @@ -254,13 +249,13 @@ pub struct Receiver { /// Keeps the shared memory's backing file alive for as long as senders /// may attach. _keeper: ShmKeeper, - shm: ShmReceiver, + mapping: Mapping, } -// SAFETY: `Receiver` only holds the mapping (inside the protocol receiver); -// it accesses it exclusively through the `shm_io` protocol in `close`, which -// synchronizes with senders via atomic operations. The mapping's address is -// stable and independently owned. +// SAFETY: `Receiver` only holds the mapping; it accesses it exclusively +// through the `shm_io` protocol in `close`, which synchronizes with senders +// via atomic operations. The mapping's address is stable and independently +// owned. unsafe impl Send for Receiver {} // SAFETY: see the `Send` impl. @@ -282,11 +277,15 @@ impl Receiver { /// Fails only when the shared-memory metadata was corrupted (a protocol /// impossibility for correct senders); the trace is then unusable. pub fn close(self) -> io::Result { - let Self { _keeper: keeper, shm } = self; + let Self { _keeper: keeper, mapping } = self; // Remove the backing file first so no new process attaches while the // channel closes. drop(keeper); - shm.close().map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err)) + // SAFETY: `mapping` was created zero-initialized by `channel`, its + // address is stable and independently owned, and all attached + // processes access it only through the `shm_io` protocol. + unsafe { ShmReader::close(mapping) } + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err)) } } diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/README.md b/crates/fspy_shared/src/ipc/channel/shm_io/README.md index 033064f2d..328d9d4e6 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/README.md +++ b/crates/fspy_shared/src/ipc/channel/shm_io/README.md @@ -124,11 +124,11 @@ The receiver closes once: writer could produce fails the whole channel — never a panic, never an out-of-bounds read. -The result, `Frames`, owns the mapping and lends out one `&[u8]` per +The result, `ShmReader`, owns the mapping and lends out one `&[u8]` per committed span, straight from shared memory — no copy. The borrows are sound because a committed span is never written again and is disjoint from everything a live straggler may still touch. The mapping is released when -`Frames` is dropped. +the reader is dropped. ```text writer's commit CAS wins @@ -171,10 +171,10 @@ CLAIMED (slot 0) ---+ ## Files -| File | Role | -| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `mod.rs` | The protocol, in reading order: the overview docs, the typed views of the region and the ordering contract, the writer side (claim, fill, finish), the receiver side (close and `Frames`), and the integration tests — miri-clean (`cargo miri test -p fspy_shared shm_io`). | -| `layout.rs` | The region's shape: the header struct, the sizing rule that turns a mapping length into table and payload bounds, payload rounding, span validation, and the descriptor codec. Describes memory, never touches it. | +| File | Role | +| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `mod.rs` | The protocol, in reading order: the overview docs, the typed views of the region and the ordering contract, the writer side (claim, fill, finish), the reader side (close and iteration), and the integration tests — miri-clean (`cargo miri test -p fspy_shared shm_io`). | +| `layout.rs` | The region's shape: the header struct, the sizing rule that turns a mapping length into table and payload bounds, payload rounding, span validation, and the descriptor codec. Describes memory, never touches it. | `mod.rs` depends on `layout.rs`; read `layout.rs` first, then `mod.rs` top to bottom. diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs index 499087f1c..b3f6aeab0 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs @@ -46,14 +46,14 @@ //! A payload becomes reachable only through its committed descriptor, and a //! descriptor is committed only after the payload is fully written //! (the ordering contract below). The receiver never derives frame -//! locations from payload bytes, and the borrows [`Frames`] hands out cover +//! locations from payload bytes, and the borrows [`ShmReader`] hands out cover //! exactly the validated committed spans — immutable under the protocol, //! and disjoint from everything a live writer may still touch (see the //! receiver section's trust argument below). //! //! # Close boundary //! -//! [`ShmReceiver::close`]'s boundary is a snapshot of the claim counter. +//! [`ShmReader::close`]'s boundary is a snapshot of the claim counter. //! A writer admitted before the snapshot races the freeze pass per slot and //! its frame is either included (commit won) or ignored (abort won) — never //! torn; a claim after the snapshot lands in a slot the receiver never @@ -64,7 +64,7 @@ //! operation, and one that claimed or committed after the snapshot performs //! it outside the channel's boundary. A record refused *before* close — a //! full region, an oversized frame — sets the loss flag first, and the -//! channel reports itself incomplete ([`Frames::is_complete`]). +//! channel reports itself incomplete ([`ShmReader::is_complete`]). //! //! Correctness never depends on writer-side cleanup: no exit hooks, PID //! checks, heartbeats, or timeouts. @@ -162,9 +162,9 @@ pub fn is_supported_region_len(len: usize) -> bool { // uses `Release`: every payload write happens-before the committed // descriptor becomes visible. // 3. **Receiver observation** — the freeze compare-and-swap in -// `ShmReceiver::close` uses `Acquire` on failure: observing a committed +// `ShmReader::close` uses `Acquire` on failure: observing a committed // descriptor also makes the payload writes it published visible, so the -// borrows `Frames` later hands out read settled bytes. +// borrows `ShmReader` later hands out read settled bytes. /// A borrowed view of the shared mapping: the typed header, the /// descriptor table sized from the mapping length, and the raw payload @@ -439,10 +439,10 @@ impl FrameMut<'_> { } } -// --- The receiver side: close and Frames ------------------------------------ +// --- The reader side: close and iterate ------------------------------------- // // Closing never waits for writers, and no payload byte is read or copied: -// `Frames` keeps the mapping alive and hands out borrows of the validated +// the reader keeps the mapping alive and hands out borrows of the validated // committed spans on demand. Those borrows are sound because of the // protocol, not despite it: a committed span is never written again // (committing consumes the writer's frame), every borrow covers exactly one @@ -452,49 +452,6 @@ impl FrameMut<'_> { // region is accessed only through this protocol; a process scribbling // outside the protocol is outside the trust model. -/// The committed frames of a closed channel: validated spans borrowed from -/// the mapping, which stays alive inside this value. Dropping it releases -/// the mapping. -pub struct Frames { - mem: M, - spans: Vec, - complete: bool, -} - -impl Frames { - /// Iterates over the committed frames in claim order. - pub fn iter(&self) -> impl Iterator { - let base = self.mem.as_raw_slice().cast::().cast_const(); - self.spans.iter().map(move |span| { - // SAFETY: `close` validated the span against this mapping's - // layout, and a committed span is immutable for the mapping's - // lifetime (see the section comment above), so the shared borrow - // is valid for as long as `self` lives. - unsafe { slice::from_raw_parts(base.add(span.offset), span.len) } - }) - } - - /// Whether every record a writer published made it in. - /// - /// False when a claim failed before the channel closed — the region - /// was out of space, or a frame exceeded the frame limit: its record - /// was lost, and the frames under-report what writers went on to do. - /// Consumers that need completeness must reject them. - #[must_use] - pub const fn is_complete(&self) -> bool { - self.complete - } -} - -impl fmt::Debug for Frames { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("Frames") - .field("frames", &self.spans.len()) - .field("complete", &self.complete) - .finish_non_exhaustive() - } -} - /// Shared-memory metadata that could not have been produced by this /// protocol. The region was corrupted; its frames are unusable. #[derive(thiserror::Error, Clone, Copy, PartialEq, Eq, Debug)] @@ -503,63 +460,53 @@ pub enum ProtocolError { CorruptDescriptor { slot_index: usize }, } -/// The receiver end of a channel. -/// -/// Attaching to the region is the one unsafe step, sharing -/// [`ShmWriter::new`]'s contract; closing is safe. -pub struct ShmReceiver { +/// A reader over the committed frames of a closed channel: validated spans +/// borrowed from the mapping, which stays alive inside this value and is +/// released when the reader drops. +pub struct ShmReader { mem: M, + spans: Vec, + complete: bool, } -impl ShmReceiver { - /// Creates the receiver backed by a shared-memory region. +impl ShmReader { + /// Closes the channel over a shared-memory region and returns the + /// reader of its committed frames. + /// + /// Never blocks on writers: writers admitted before the snapshot race + /// per slot, and each raced slot independently ends up committed + /// (included) or aborted (excluded). Claims after the snapshot land in + /// slots this pass never visits until the CLOSED gate — set before + /// this returns — stops them. See the protocol docs at the top of this + /// module. /// /// # Safety /// /// Same contract as [`ShmWriter::new`]: /// /// - `mem.as_raw_slice()` must return a stable, valid pointer to the - /// whole region for the lifetime of the receiver and of the - /// [`Frames`] it closes into. + /// whole region for the reader's lifetime. /// - The region must have been zero-initialized when it was created and /// accessed only through this protocol since. /// - /// # Panics - /// - /// Panics when the region is not `u64`-aligned or its size is outside - /// the supported range (see [`SharedState::borrow`]). - pub unsafe fn new(mem: M) -> Self { - // Validate the region geometry eagerly so misuse fails at - // construction, not at close. - // SAFETY: forwarded from this function's contract. - let _ = unsafe { SharedState::borrow(mem.as_raw_slice()) }; - Self { mem } - } - - /// Closes the channel and returns the committed frames as borrows of - /// the mapping, which moves into the returned [`Frames`]. - /// - /// Never blocks on writers: writers admitted before the snapshot race - /// per slot, and each raced slot independently ends up committed - /// (included) or aborted (excluded). Claims after the snapshot land in - /// slots this pass never visits until the CLOSED gate — set before - /// this returns — stops them. See the protocol docs at - /// the top of this module. - /// /// # Errors /// /// [`ProtocolError`] when the shared-memory metadata could not have /// been produced by a correct writer; the region was corrupted and its /// frames are unusable. - pub fn close(self) -> Result, ProtocolError> { + /// + /// # Panics + /// + /// Panics when the region is not `u64`-aligned or its size is outside + /// the supported range (see [`SharedState::borrow`]). + pub unsafe fn close(mem: M) -> Result { let mut spans = Vec::new(); let complete; { - // SAFETY: `new` requires the region to stay valid and - // protocol-governed, and it validated the geometry; the raw - // slice stays valid while `self.mem` is borrowed here and - // beyond, since it moves into the returned `Frames`. - let state = unsafe { SharedState::borrow(self.mem.as_raw_slice()) }; + // SAFETY: forwarded from this function's contract; the raw + // slice stays valid while `mem` is borrowed here and beyond, + // since it moves into the returned reader. + let state = unsafe { SharedState::borrow(mem.as_raw_slice()) }; // The close boundary (rule 1): claims at or before this // snapshot are inside it, later ones land in slots this pass @@ -609,13 +556,71 @@ impl ShmReceiver { } } - Ok(Frames { mem: self.mem, spans, complete }) + Ok(Self { mem, spans, complete }) + } + + /// Iterates over the committed frames in claim order. + pub fn iter(&self) -> Iter<'_> { + self.into_iter() + } + + /// Whether every record a writer published made it in. + /// + /// False when a claim failed before the channel closed — the region + /// was out of space, or a frame exceeded the frame limit: its record + /// was lost, and the frames under-report what writers went on to do. + /// Consumers that need completeness must reject them. + #[must_use] + pub const fn is_complete(&self) -> bool { + self.complete + } +} + +impl fmt::Debug for ShmReader { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ShmReader") + .field("frames", &self.spans.len()) + .field("complete", &self.complete) + .finish_non_exhaustive() + } +} + +/// Iterator over a [`ShmReader`]'s committed frames, in claim order. +pub struct Iter<'a> { + /// Base address of the mapping the spans point into. + base: *const u8, + spans: slice::Iter<'a, PayloadSpan>, +} + +impl<'a> Iterator for Iter<'a> { + type Item = &'a [u8]; + + fn next(&mut self) -> Option { + let span = self.spans.next()?; + // SAFETY: `ShmReader::close` validated the span against the + // mapping's layout, and a committed span is immutable for the + // mapping's lifetime (see the section comment above); the reader + // borrowed for `'a` keeps the mapping alive and mapped. + Some(unsafe { slice::from_raw_parts(self.base.add(span.offset), span.len) }) + } + + fn size_hint(&self) -> (usize, Option) { + self.spans.size_hint() + } +} + +impl<'a, M: AsRawSlice> IntoIterator for &'a ShmReader { + type IntoIter = Iter<'a>; + type Item = &'a [u8]; + + fn into_iter(self) -> Iter<'a> { + Iter { base: self.mem.as_raw_slice().cast::().cast_const(), spans: self.spans.iter() } } } /// Materializes the page backing the protocol header without changing /// protocol state, so that neither a writer's first claim nor -/// [`ShmReceiver::close`]'s snapshot pays for the backing file's first +/// [`ShmReader::close`]'s snapshot pays for the backing file's first /// block allocation — a millisecond-scale cost on some journalling /// filesystems, for reads of holes as well as writes. Run it off any /// latency-sensitive path. Only Linux channels use this: elsewhere the @@ -709,10 +714,10 @@ mod tests { } } - fn collect_frames(shm: &MockedShm) -> Frames { + fn collect_frames(shm: &MockedShm) -> ShmReader { // SAFETY: `MockedShm` provides a stable, zero-initialized allocation // accessed only through the protocol. - unsafe { ShmReceiver::new(shm.clone()) }.close().unwrap() + unsafe { ShmReader::close(shm.clone()) }.unwrap() } #[test] @@ -1003,7 +1008,7 @@ mod tests { let frames = collect_frames(&shm); let mut count = 0; - for frame in frames.iter() { + for frame in &frames { count += 1; let frame = BStr::new(frame); assert!(frame == b"hello" || frame == b"foo" || frame == b"this is a test"); @@ -1031,7 +1036,7 @@ mod tests { let frames = collect_frames(&shm); let mut count = 0; - for frame in frames.iter() { + for frame in &frames { count += 1; let frame = BStr::new(frame); assert!(frame == b"hello" || frame == b"foo" || frame == b"this is a test"); @@ -1081,7 +1086,7 @@ mod tests { // Every admitted slot resolved to a whole frame or was aborted: // the receiver observed only complete payloads. let mut count = 0; - for frame in frames.iter() { + for frame in &frames { count += 1; assert!(frame == b"hello"); } @@ -1107,7 +1112,7 @@ mod tests { shm.poke_u64(64, (bogus_len << 32) | bogus_offset); // SAFETY: see `collect_frames`. - let result = unsafe { ShmReceiver::new(shm) }.close(); + let result = unsafe { ShmReader::close(shm) }; assert!(result.unwrap_err() == ProtocolError::CorruptDescriptor { slot_index: 0 }); } @@ -1123,7 +1128,7 @@ mod tests { shm.poke_u64(64, (1 << 63) | (8u64 << 32) | 8); // SAFETY: see `collect_frames`. - let result = unsafe { ShmReceiver::new(shm) }.close(); + let result = unsafe { ShmReader::close(shm) }; assert!(result.unwrap_err() == ProtocolError::CorruptDescriptor { slot_index: 0 }); } @@ -1227,7 +1232,7 @@ mod tests { // SAFETY: the mapping is a valid shared-memory region created zeroed // and accessed only through the protocol. - let frames = unsafe { ShmReceiver::new(mapping) }.close().unwrap(); + let frames = unsafe { ShmReader::close(mapping) }.unwrap(); assert!(frames.is_complete()); let collected = frames.iter().map(BStr::new).collect::>(); assert!(collected.len() == CHILD_COUNT * FRAME_COUNT_EACH_CHILD); @@ -1295,7 +1300,7 @@ mod tests { assert!(writer.try_write_frame(b"alive")); // SAFETY: see `real_shm_across_processes`. - let frames = unsafe { ShmReceiver::new(writer.into_memory()) }.close().unwrap(); + let frames = unsafe { ShmReader::close(writer.into_memory()) }.unwrap(); let mut iter = frames.iter(); assert!(iter.next().unwrap() == b"alive"); assert!(iter.next() == None); From fa17277d62ddf4314eb99dbd30d019bcaf6975c4 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sat, 15 Aug 2026 16:27:24 +0800 Subject: [PATCH 32/92] refactor(fspy-shm): make the CLOSED gate the loss report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The loss flag and the CLOSED gate answered the same question from two sides — is this channel still worth writing to? — so a failed claim now sets the gate itself. One bit is both the loss report completeness derives from and the valve that stops writers: the boundary, the gate, and every loss report sequence in a single word's modification order, which tightens rule 1 from a two-location argument to one. Consequences, all deliberate: - The first lost record condemns the channel. Later records would ride a result the receiver must already reject, so refusing them is the same economy the gate always bought. - This also closes the payload-counter wrap hole: wrapping requires billions of failed claims, any failure sets the gate, and the gate refuses every claim before a span is built — no rollback needed. - A second close over the same region now reports incomplete, which is the truthful verdict: a re-close cannot vouch for records refused since the first gate. - The header returns to two counters, and the receiver's completeness verdict rides the snapshot load it already performs. Co-Authored-By: Claude Fable 5 --- crates/fspy_shared/src/ipc/channel/mod.rs | 4 +- .../src/ipc/channel/shm_io/README.md | 44 ++++--- .../src/ipc/channel/shm_io/layout.rs | 19 ++- .../fspy_shared/src/ipc/channel/shm_io/mod.rs | 111 +++++++++++------- 4 files changed, 102 insertions(+), 76 deletions(-) diff --git a/crates/fspy_shared/src/ipc/channel/mod.rs b/crates/fspy_shared/src/ipc/channel/mod.rs index a24f7776f..42fa3b2b8 100644 --- a/crates/fspy_shared/src/ipc/channel/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/mod.rs @@ -211,8 +211,8 @@ impl Sender { /// A record that cannot be sent is skipped, because that is all a /// sender inside an intercepted call can do: the channel may have /// closed (the record belongs past its boundary), or the region may be - /// full (a loss the failed claim flags, so the receiver reports the - /// frames incomplete). + /// full (a loss the failed claim reports by setting the CLOSED gate, + /// so the receiver reports the frames incomplete). pub fn send>(&self, value: &T) { let Ok(serialized_size) = T::serialized_size(value) else { return; diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/README.md b/crates/fspy_shared/src/ipc/channel/shm_io/README.md index 328d9d4e6..cece055b0 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/README.md +++ b/crates/fspy_shared/src/ipc/channel/shm_io/README.md @@ -31,13 +31,13 @@ space, not memory: only pages that are actually written get backed. ``` The header is a `repr(C)` struct of two `AtomicU64` counters, which only -ever count up, and one flag: +ever count up: -- the **claim counter** — how many frames were ever claimed. Bit 63 is the - CLOSED gate. +- the **claim counter** — how many frames were ever claimed. Bit 63 is + the CLOSED gate, set by the receiver when it closes the channel — and + by any writer whose claim failed, which is how the receiver learns + that a record was lost. - the **payload counter** — how many payload bytes were ever reserved. -- the **loss flag** — set by a writer whose claim failed, so the receiver - knows a record was lost and the frames are incomplete. The table has one 8-byte slot per frame — an eighth of the region. Every bound is derived from the mapping length alone, so the region is @@ -58,7 +58,7 @@ Three steps: 1. **Claim.** Two `fetch_add`s — one reserves payload bytes, one reserves a slot. No retry loop, no lock. A claim that does not fit fails after the - fact and sets the loss flag before the writer moves on. + fact and sets the CLOSED gate before the writer moves on. 2. **Fill.** The writer serializes into its payload span. The span is exclusively its own; nobody else knows it exists yet. 3. **Commit.** One compare-and-swap flips the frame's slot from zero to a @@ -92,22 +92,27 @@ room than is left, in the payload area or in the table, the claim fails. The writer skips that one record and carries on: recording must never stop or crash the program doing the work. -The loss is not silent. Before moving on, the failed claim sets the loss -flag in the header. When the receiver closes the channel it reads the -flag once; if it is set, `is_complete` returns false, and a reader that -needs the full picture knows to throw the result away. +The loss is not silent. Before moving on, the failed claim sets the +CLOSED gate — the same bit the receiver sets when it closes. When the +receiver closes the channel it reads the bit once; if it was already +set, `is_complete` returns false, and a reader that needs the full +picture knows to throw the result away. -Setting the flag before moving on matters for the same reason committing -a record before acting does. If the receiver's read misses the flag, the -flag was set after close — so the skipped record describes an action +Setting the bit before moving on matters for the same reason committing +a record before acting does. If the receiver's read misses the bit, the +bit was set after close — so the skipped record describes an action performed after the channel closed, which the receiver never promised to -include. And a writer that dies before setting the flag never performed +include. And a writer that dies before setting the bit never performed its action, so nothing was actually lost. +Because the bit is also the gate, the first lost record closes the +channel: every later claim is refused. That refusal costs nothing — +`is_complete` is already false, so the result must be thrown away, and +any further records would ride a result nobody can use. + One more limit: a single frame holds at most 2 GiB, because a descriptor -cannot describe more. Such a claim is refused the same way — the record -is skipped and the flag is set — and the channel stays usable for every -record after it. +cannot describe more. Such a claim is refused — and reported — the same +way. ## Closing and reading @@ -148,8 +153,9 @@ CLAIMED (slot 0) ---+ - Once a slot is committed or aborted, nothing ever changes it again. - Counters only grow. The receiver clamps them to the fixed capacities — an inflated counter degrades into extra aborted slots, not corruption. - Loss is reported separately: a failed claim sets the loss flag before - the writer carries on. + Loss is reported through the CLOSED gate: a failed claim sets it before + the writer carries on, which both marks the result incomplete and + refuses every later claim. - The bounds checks on descriptors are what make the `unsafe` reference construction correct: whether the receiver stays memory-safe never depends on another process behaving. diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs index a8469d68e..f95c6f3bc 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs @@ -19,24 +19,23 @@ use std::sync::atomic::AtomicU64; -/// The CLOSED gate bit of the claim counter. The low 63 bits count claims, -/// so no realistic claim volume can carry into the gate. +/// The CLOSED gate bit of the claim counter, set by the receiver when it +/// closes the channel and by any writer whose claim failed — the loss +/// report that also condemns the channel (the parent module's rule 1). +/// The low 63 bits count claims, so no realistic claim volume can carry +/// into the gate. pub(super) const CLOSED: u64 = 1 << 63; -/// The region header: two protocol counters and the loss flag, padded so -/// the descriptor table starts off their cache line and there is room for -/// future header fields, which must start zeroed. +/// The region header: two protocol counters, padded so the descriptor +/// table starts off their cache line and there is room for future header +/// fields, which must start zeroed. #[repr(C)] pub(super) struct Header { /// Bit 63 is the CLOSED gate; the low bits count claims ever attempted. pub(super) claims: AtomicU64, /// Payload bytes ever reserved, including by failed claims. pub(super) payload_reserved: AtomicU64, - /// Nonzero once a claim failed: a record was lost and the channel is - /// incomplete. Semantically a flag; a whole `u64` keeps the header a - /// plain row of `u64` words. - pub(super) lost: AtomicU64, - _reserved: [u64; 5], + _reserved: [u64; 6], } // One cache line: shrink `_reserved` when adding a field. The alignment is diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs index b3f6aeab0..618289a2f 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs @@ -23,14 +23,14 @@ //! and payload bounds from the mapped size. One borrow constructs typed //! views of the header (a `repr(C)` struct of two monotonic `AtomicU64` //! counters — claims, carrying the CLOSED gate bit, and payload bytes -//! reserved — plus a loss flag) and of the descriptor table (a slice of -//! atomics); the payload area stays untyped bytes. +//! reserved) and of the descriptor table (a slice of atomics); the +//! payload area stays untyped bytes. //! A claim is two wait-free `fetch_add`s — one reserves payload bytes, one //! reserves a descriptor slot — validated against the fixed region bounds -//! from the returned old values. A failed claim sets the loss flag and -//! leaves the counters bumped, harmlessly: readers clamp to the region -//! capacities, and committed descriptors are self-describing ([`layout`]), -//! so the counters never locate data. Every slot has a fixed location, so +//! from the returned old values. A failed claim sets the CLOSED gate as +//! its loss report and leaves the counters bumped, harmlessly: readers +//! clamp to the region capacities, and committed descriptors are +//! self-describing ([`layout`]), so the counters never locate data. Every slot has a fixed location, so //! an unfinished frame can never hide a later one. //! //! # Frame lifecycle @@ -63,8 +63,10 @@ //! recorded operation: a process that died mid-frame never performed the //! operation, and one that claimed or committed after the snapshot performs //! it outside the channel's boundary. A record refused *before* close — a -//! full region, an oversized frame — sets the loss flag first, and the -//! channel reports itself incomplete ([`ShmReader::is_complete`]). +//! full region, an oversized frame — sets the CLOSED gate first, so the +//! channel reports itself incomplete ([`ShmReader::is_complete`]) and +//! refuses every later claim: once one record is lost the receiver must +//! reject the result, and further records would be wasted work. //! //! Correctness never depends on writer-side cleanup: no exit hooks, PID //! checks, heartbeats, or timeouts. @@ -123,22 +125,25 @@ pub fn is_supported_region_len(len: usize) -> bool { // // # Shared atomics // -// The header holds two independent monotonic `AtomicU64` counters and a -// loss flag: +// The header holds two independent monotonic `AtomicU64` counters: // // - the **claim counter**: bit 63 is the CLOSED gate, the low bits count // claims ever attempted. Claiming is one wait-free `fetch_add`; the // returned old value carries the claim's slot index, the gate, and — by // comparison against the fixed table capacity — the capacity verdict. +// The gate is set by the receiver at close and by every failed claim: +// one bit is both the loss report completeness derives from and the +// valve that stops writers spending work on a channel whose result the +// receiver must already reject. // - the **payload counter**: payload bytes ever reserved, bumped by another // wait-free `fetch_add`. -// - the **loss flag**: set by every failed claim before the writer moves -// on. The receiver derives completeness from it alone. // // Failed claims leave the counters bumped; that is harmless, because the // receiver clamps instead of trusting the counts, and committed // descriptors carry their own offset and length, so the counters never -// locate data. +// locate data. The payload counter can even wrap on a long-condemned +// channel — still harmless: wrapping requires prior failures, failures +// set the gate, and the gate refuses every claim before a span is built. // // # Memory-ordering contract // @@ -149,15 +154,18 @@ pub fn is_supported_region_len(len: usize) -> bool { // value it reads (in the counter's modification order) are in the // snapshot; later ones receive slot indices the receiver never visits. // Claims publish no payload data, so `Relaxed` suffices throughout. -// The CLOSED gate only stops stragglers from claiming (and allocating -// pages) forever; any claim admitted between the snapshot and the gate -// lands beyond the snapshot and is never observed. Completeness rides -// the same style of argument: a failed claim sets the loss flag before -// the writer performs the operation whose record was lost — so the -// receiver's one read of the flag either sees the loss, or the loss -// belongs to an operation performed after the boundary. A writer that -// dies before setting the flag never performed its operation, so -// nothing was actually lost. +// The CLOSED gate is not itself the boundary — it stops stragglers +// from claiming (and allocating pages) forever; any claim admitted +// between the snapshot and the gate lands beyond the snapshot and is +// never observed. Completeness rides the same modification order: a +// failed claim sets the gate as its loss report before the writer +// performs the operation whose record was lost — so the snapshot +// either sees the bit, or the loss belongs to an operation performed +// after the boundary. A writer that skipped because it saw the bit is +// covered the same way: the bit that made it skip either reaches the +// snapshot or postdates the boundary. A writer that dies before +// setting the bit never performed its operation, so nothing was +// actually lost. // 2. **Writer commit** — the slot compare-and-swap in `FrameMut::finish` // uses `Release`: every payload write happens-before the committed // descriptor becomes visible. @@ -242,13 +250,16 @@ pub struct ShmWriter { /// Why a frame could not be claimed. #[derive(thiserror::Error, Clone, Copy, PartialEq, Eq, Debug)] pub enum ClaimError { - /// The receiver closed the channel; anything after this point is - /// outside the channel's boundary. - #[error("the channel has been closed by the receiver")] + /// The CLOSED gate was set: the receiver closed the channel, or an + /// earlier failed claim condemned it. Skipping the record is sound + /// either way — it is outside the receiver's boundary, or the same + /// bit already makes the receiver report the channel incomplete. + #[error("the channel has been closed")] Closed, /// The claim was refused for space: the region was full, or the frame /// was larger than the `i32::MAX`-byte frame limit. The loss is - /// already recorded, so the channel will report itself incomplete. + /// already recorded — this claim set the CLOSED gate — so the channel + /// will report itself incomplete and refuse further claims. #[error("no space left in the shared-memory region")] Capacity, } @@ -282,7 +293,8 @@ impl ShmWriter { unsafe { SharedState::borrow(self.mem.as_raw_slice()) } } - /// Whether the receiver has closed the channel. + /// Whether the CLOSED gate is set: the receiver closed the channel, + /// or an earlier failed claim condemned it. pub fn is_closed(&self) -> bool { self.state().header.claims.load(Ordering::Relaxed) & CLOSED != 0 } @@ -296,16 +308,18 @@ impl ShmWriter { /// [`ClaimError::Capacity`]. /// /// Wait-free: two `fetch_add`s, no retry loop (rule 1). A claim that - /// does not fit fails after setting the loss flag, which is what tells - /// the receiver a record was lost. + /// does not fit fails after setting the CLOSED gate: the receiver + /// learns a record was lost, and later claims are refused — their + /// records would ride a result the receiver must already reject. pub fn claim_frame(&self, frame_size: NonZeroUsize) -> Result, ClaimError> { let state = self.state(); let payload_len = frame_size.get(); - // Records that a claim failed and its record was lost, before the - // writer moves on (rule 1). + // Reports that this claim's record was lost, before the writer + // moves on (rule 1): the gate makes the receiver report the + // channel incomplete, and condemns further claims. let report_loss = || { - state.header.lost.store(1, Ordering::Relaxed); + state.header.claims.fetch_or(CLOSED, Ordering::Relaxed); ClaimError::Capacity }; @@ -513,11 +527,14 @@ impl ShmReader { // never visits. The count is clamped to the table capacity, so // a counter inflated by failed claims (or by a foreign // scribble) degrades to a full-table sweep, not an error. - let claims = state.header.claims.load(Ordering::Relaxed) & !CLOSED; - let slot_count = usize::try_from(claims).unwrap_or(usize::MAX).min(state.table.len()); - // One read of the loss flag: it either sees a loss, or the loss - // belongs to an operation performed after the boundary (rule 1). - complete = state.header.lost.load(Ordering::Relaxed) == 0; + let claims = state.header.claims.load(Ordering::Relaxed); + let slot_count = + usize::try_from(claims & !CLOSED).unwrap_or(usize::MAX).min(state.table.len()); + // The same load carries the completeness verdict: a gate set + // before this boundary is a failed claim's loss report — or an + // earlier close, and a re-close cannot vouch for records + // refused since then (rule 1). + complete = claims & CLOSED == 0; // Gate further claims, so stragglers stop claiming (and // materializing pages) forever. Cheap: the creator pre-faulted @@ -776,7 +793,7 @@ mod tests { assert!(writer.try_write_frame(b"test")); // Larger than the payload region: the claim fails and sets the - // loss flag, which is what tells the receiver a record was lost. + // gate, which is what tells the receiver a record was lost. assert!(!writer.try_write_frame(&vec![0u8; 2048])); let frames = collect_frames(&shm); @@ -791,17 +808,19 @@ mod tests { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. let writer = unsafe { ShmWriter::new(shm.clone()) }; + assert!(writer.try_write_frame(b"kept")); // No descriptor can describe a frame this long: the claim is - // refused without touching the counters, so later records still - // flow — but the loss flag marks the channel incomplete. + // refused and sets the gate, condemning later claims — their + // records would ride a result the receiver must already reject. let oversized = ((i32::MAX as usize) + 1).try_into().unwrap(); assert!(matches!(writer.claim_frame(oversized), Err(ClaimError::Capacity))); - assert!(writer.try_write_frame(b"still open")); + assert!(writer.is_closed()); + assert!(!writer.try_write_frame(b"refused")); let frames = collect_frames(&shm); let mut iter = frames.iter(); - assert!(iter.next().unwrap() == b"still open"); + assert!(iter.next().unwrap() == b"kept"); assert!(iter.next() == None); assert!(!frames.is_complete()); } @@ -976,13 +995,15 @@ mod tests { assert!(frames.iter().count() == 0); assert!(frames.is_complete()); - // The late commit loses the race silently; the abandoned-frame flag - // must not fire either, because the frame *was* explicitly finished. + // The late commit loses the race silently. frame.finish(); + // A second close still reads no frames — and reports incomplete: + // the gate was set by the first close, and a re-close cannot vouch + // for records refused since then. let frames = collect_frames(&shm); assert!(frames.iter().count() == 0); - assert!(frames.is_complete()); + assert!(!frames.is_complete()); } #[test] From 268ffe9a2fe76b3dd383313563b211b8e460452f Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sat, 15 Aug 2026 16:42:54 +0800 Subject: [PATCH 33/92] refactor(fspy-shm): iterate the frozen table instead of snapshotting spans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closing allocated a Vec of every committed span — the one allocation in the module, and O(frames) memory that a multi-million-record trace turns into real heap. It was never necessary: the freeze pass already makes the snapshot's slice of the descriptor table immutable, so the reader only needs the mapping, the frozen prefix length, and the frame count. Iteration re-reads the terminal slots and re-decodes each committed descriptor — pure math on bits close already validated, so the iterator stays infallible. Payload visibility travels with the reader: whatever transfer carries it across threads carries the freeze pass's Acquire along (rule 3). shm_io is now allocation-free end to end. Co-Authored-By: Claude Fable 5 --- .../src/ipc/channel/shm_io/README.md | 4 +- .../fspy_shared/src/ipc/channel/shm_io/mod.rs | 85 +++++++++++++------ 2 files changed, 64 insertions(+), 25 deletions(-) diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/README.md b/crates/fspy_shared/src/ipc/channel/shm_io/README.md index cece055b0..5702fa952 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/README.md +++ b/crates/fspy_shared/src/ipc/channel/shm_io/README.md @@ -167,7 +167,9 @@ CLAIMED (slot 0) ---+ ## Performance notes - Claiming is two atomic adds; committing is one CAS. Nothing retries. -- Closing costs one pass over the claimed slots. No payload is copied. +- Closing costs one pass over the claimed slots. Nothing is copied and + nothing is allocated — the whole module is allocation-free; the reader + re-reads the frozen table to iterate. - On Linux, the first touch of the sparse backing file can cost milliseconds on journalling filesystems (it is the fault path, not block allocation — `fallocate` does not help). Creators should run `pre_fault` diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs index 618289a2f..e86730a34 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs @@ -83,7 +83,7 @@ use std::{ }; use fspy_shm::Mapping; -use layout::{CLOSED, Header, PayloadSpan}; +use layout::{CLOSED, Header}; // The region arithmetic in `layout` relies on `usize` accommodating sums of // 32-bit-bounded quantities, and the descriptor protocol on native 64-bit @@ -474,12 +474,16 @@ pub enum ProtocolError { CorruptDescriptor { slot_index: usize }, } -/// A reader over the committed frames of a closed channel: validated spans -/// borrowed from the mapping, which stays alive inside this value and is -/// released when the reader drops. +/// A reader over the committed frames of a closed channel, serving them +/// straight out of the mapping, which stays alive inside this value and +/// is released when the reader drops. It holds no buffer: iteration +/// re-reads the frozen descriptor table, so closing allocates nothing. pub struct ShmReader { mem: M, - spans: Vec, + /// Length of the frozen prefix of the descriptor table. + slot_count: usize, + /// Committed frames in that prefix. + frames: usize, complete: bool, } @@ -514,7 +518,8 @@ impl ShmReader { /// Panics when the region is not `u64`-aligned or its size is outside /// the supported range (see [`SharedState::borrow`]). pub unsafe fn close(mem: M) -> Result { - let mut spans = Vec::new(); + let slot_count; + let mut frames = 0; let complete; { // SAFETY: forwarded from this function's contract; the raw @@ -528,7 +533,7 @@ impl ShmReader { // a counter inflated by failed claims (or by a foreign // scribble) degrades to a full-table sweep, not an error. let claims = state.header.claims.load(Ordering::Relaxed); - let slot_count = + slot_count = usize::try_from(claims & !CLOSED).unwrap_or(usize::MAX).min(state.table.len()); // The same load carries the completeness verdict: a gate set // before this boundary is a failed claim's loss report — or an @@ -544,9 +549,11 @@ impl ShmReader { state.header.claims.fetch_or(CLOSED, Ordering::Relaxed); // Freeze pass: drive every admitted slot to a terminal state - // and collect the committed spans. After this loop the + // and validate the committed descriptors. After this loop the // snapshot's slice of the descriptor table can no longer change - // — late writers lose their commit race against `ABORTED`. + // — late writers lose their commit race against `ABORTED` — so + // iteration re-reads the table instead of snapshotting it: + // nothing is copied or allocated. for slot_index in 0..slot_count { // Rule 3: `Acquire` on failure makes a committed payload // visible. @@ -567,13 +574,14 @@ impl ShmReader { } // Any other terminal value must be a committed descriptor // with a valid span; a foreign scribble fails the decode. - let span = layout::decode(state.len, bits) - .ok_or(ProtocolError::CorruptDescriptor { slot_index })?; - spans.push(span); + if layout::decode(state.len, bits).is_none() { + return Err(ProtocolError::CorruptDescriptor { slot_index }); + } + frames += 1; } } - Ok(Self { mem, spans, complete }) + Ok(Self { mem, slot_count, frames, complete }) } /// Iterates over the committed frames in claim order. @@ -596,7 +604,7 @@ impl ShmReader { impl fmt::Debug for ShmReader { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("ShmReader") - .field("frames", &self.spans.len()) + .field("frames", &self.frames) .field("complete", &self.complete) .finish_non_exhaustive() } @@ -604,25 +612,45 @@ impl fmt::Debug for ShmReader { /// Iterator over a [`ShmReader`]'s committed frames, in claim order. pub struct Iter<'a> { - /// Base address of the mapping the spans point into. + /// Base address of the mapping the descriptors' spans point into. base: *const u8, - spans: slice::Iter<'a, PayloadSpan>, + /// The not-yet-visited part of the table's frozen prefix. + table: &'a [AtomicU64], + /// Mapping length, which decoding validates descriptors against. + mapping_len: usize, + /// Committed frames not yet yielded. + remaining: usize, } impl<'a> Iterator for Iter<'a> { type Item = &'a [u8]; fn next(&mut self) -> Option { - let span = self.spans.next()?; - // SAFETY: `ShmReader::close` validated the span against the - // mapping's layout, and a committed span is immutable for the - // mapping's lifetime (see the section comment above); the reader - // borrowed for `'a` keeps the mapping alive and mapped. - Some(unsafe { slice::from_raw_parts(self.base.add(span.offset), span.len) }) + while let Some((slot, rest)) = self.table.split_first() { + self.table = rest; + // The slot is terminal (`close` froze it), so this plain load + // reads the same value the freeze pass saw; the transfer that + // carried the reader to this thread carried the freeze pass's + // `Acquire` payload visibility with it (rule 3). + let bits = slot.load(Ordering::Relaxed); + // `None` is an aborted slot: nothing was published. Corrupt + // values cannot appear — `close` already failed the channel on + // them — so every decoded span is one `close` validated. + let Some(span) = layout::decode(self.mapping_len, bits) else { + continue; + }; + self.remaining -= 1; + // SAFETY: `close` validated the span against the mapping's + // layout, and a committed span is immutable for the mapping's + // lifetime (see the section comment above); the reader + // borrowed for `'a` keeps the mapping alive and mapped. + return Some(unsafe { slice::from_raw_parts(self.base.add(span.offset), span.len) }); + } + None } fn size_hint(&self) -> (usize, Option) { - self.spans.size_hint() + (self.remaining, Some(self.remaining)) } } @@ -631,7 +659,16 @@ impl<'a, M: AsRawSlice> IntoIterator for &'a ShmReader { type Item = &'a [u8]; fn into_iter(self) -> Iter<'a> { - Iter { base: self.mem.as_raw_slice().cast::().cast_const(), spans: self.spans.iter() } + // SAFETY: `close` requires the region to stay valid and + // protocol-governed for the reader's lifetime, and it validated + // the geometry. + let state = unsafe { SharedState::borrow(self.mem.as_raw_slice()) }; + Iter { + base: self.mem.as_raw_slice().cast::().cast_const(), + table: &state.table[..self.slot_count], + mapping_len: state.len, + remaining: self.frames, + } } } From 080f40f93114e3ebf94d6eabb4655a37cb04e658 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sat, 15 Aug 2026 17:26:18 +0800 Subject: [PATCH 34/92] refactor(fspy-shm): build the region views once, at attach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SharedState was re-derived from the mapping on every operation because a struct cannot store references borrowed from a field it owns. But this was never true self-reference: the views point into the mapping's target, which the attach contract already requires to be address-stable, not into the endpoint value that moves. NonNull fields express exactly that, so SharedState loses its lifetime parameter and both endpoints construct it once and store it — the geometry asserts run once per attach, and state()/re-borrows disappear. Unsafe relocates rather than grows: three one-line accessors carry the pointer-to-reference step, Iter construction loses its unsafe block, and close loses its borrow-scoping braces. Raw fields cost the endpoints their auto Send/Sync, now stated manually with the justification that was implicit all along: the protocol synchronizes every access, and the views point at stable, independently owned memory. Co-Authored-By: Claude Fable 5 --- .../fspy_shared/src/ipc/channel/shm_io/mod.rs | 170 +++++++++++------- 1 file changed, 102 insertions(+), 68 deletions(-) diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs index e86730a34..92e206595 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs @@ -20,7 +20,7 @@ //! //! The layout is derived from the mapping length alone ([`layout`]), so //! the region is self-describing: every process computes the same table -//! and payload bounds from the mapped size. One borrow constructs typed +//! and payload bounds from the mapped size. Attaching constructs typed //! views of the header (a `repr(C)` struct of two monotonic `AtomicU64` //! counters — claims, carrying the CLOSED gate bit, and payload bytes //! reserved) and of the descriptor table (a slice of atomics); the @@ -77,7 +77,7 @@ use std::{ fmt, num::NonZeroUsize, ops::{Deref, DerefMut}, - ptr::slice_from_raw_parts_mut, + ptr::{NonNull, slice_from_raw_parts_mut}, slice, sync::atomic::{AtomicU64, Ordering}, }; @@ -115,11 +115,12 @@ pub fn is_supported_region_len(len: usize) -> bool { } // --- The region views and the ordering contract ---------------------------- -// One unsafe borrow in `SharedState::borrow` constructs three typed -// views of the region — the `repr(C)` `Header`, the descriptor table as -// a slice of atomics sized by the mapping length, and the untyped payload -// area as a raw slice. Every access after that is a plain field access or -// a bounds-checked index. The payload area stays raw because writers hold +// `SharedState::new` builds three typed views of the region — the +// `repr(C)` `Header`, the descriptor table as a slice of atomics sized by +// the mapping length, and the untyped payload area as a raw slice — once, +// when an endpoint attaches; the endpoint stores them beside the mapping +// they point into. Every access after that is a plain field access or a +// bounds-checked index. The payload area stays raw because writers hold // exclusive `&mut` borrows into it, which must not alias any shared // reference. // @@ -174,13 +175,15 @@ pub fn is_supported_region_len(len: usize) -> bool { // descriptor also makes the payload writes it published visible, so the // borrows `ShmReader` later hands out read settled bytes. -/// A borrowed view of the shared mapping: the typed header, the -/// descriptor table sized from the mapping length, and the raw payload -/// area. +/// The typed views of the region: the header, the descriptor table +/// sized from the mapping length, and the raw payload area. Built once +/// when an endpoint attaches and stored in it; the views stay valid +/// because they point into the mapping's stable target, not into the +/// endpoint value. #[derive(Clone, Copy)] -struct SharedState<'m> { - header: &'m Header, - table: &'m [AtomicU64], +struct SharedState { + header: NonNull
, + table: NonNull<[AtomicU64]>, payloads: *mut [u8], /// The real mapping length. Not derivable from the parts above: the /// payload region rounds down to whole `u64`s, and re-deriving the @@ -188,13 +191,14 @@ struct SharedState<'m> { len: usize, } -impl SharedState<'_> { - /// Borrows a shared mapping. +impl SharedState { + /// Builds the typed views of a shared mapping. /// /// # Safety /// - /// - `mem` must be valid for reads and writes for the lifetime `'m` and - /// its address must be stable. + /// - `mem` must be valid for reads and writes, and its address stable, + /// for as long as the returned views (and any copy of them) are + /// used. /// - The memory must have been zero-initialized when the region was /// created, and accessed only through this protocol since. /// @@ -206,24 +210,22 @@ impl SharedState<'_> { /// runtime data; senders guard untrusted mappings with /// [`is_supported_region_len`] first. #[expect(clippy::cast_ptr_alignment, reason = "the base is asserted `u64`-aligned below")] - unsafe fn borrow(mem: *mut [u8]) -> Self { + unsafe fn new(mem: *mut [u8]) -> Self { let base = mem.cast::(); let len = mem.len(); + assert!(!base.is_null()); assert!(base.addr().is_multiple_of(align_of::
())); assert!((layout::HEADER_LEN..=layout::MAX_MAPPING_LEN).contains(&len)); - // SAFETY: the header and the table lie inside the mapping (the - // header by the assert above, the table by `layout::max_slots`), - // are `u64`-aligned (aligned base, `u64`-multiple offsets), and - // consist entirely of atomics zero-initialized at creation — so - // shared borrows for `'m` are valid even while other threads and - // processes access the same memory through these same atomics. The + // SAFETY: the base is non-null (asserted), and the header and the + // table lie inside the mapping — the header by the asserts above, + // the table by `layout::max_slots` — at `u64`-aligned offsets. The // payload area keeps the rest of the mapping as a raw slice; // `layout` bounds every span carved from it. unsafe { Self { - header: &*base.cast::
(), - table: slice::from_raw_parts( - base.add(layout::HEADER_LEN).cast::(), + header: NonNull::new_unchecked(base.cast::
()), + table: NonNull::slice_from_raw_parts( + NonNull::new_unchecked(base.add(layout::HEADER_LEN).cast::()), layout::max_slots(len), ), payloads: std::ptr::slice_from_raw_parts_mut( @@ -234,6 +236,26 @@ impl SharedState<'_> { } } } + + /// The protocol header. + const fn header(&self) -> &Header { + // SAFETY: `new`'s contract keeps the target valid while any view + // is used, and the header consists of atomics, so the shared + // borrow is valid even while other threads and processes access + // the same memory through them. + unsafe { self.header.as_ref() } + } + + /// The descriptor table. + const fn table(&self) -> &[AtomicU64] { + // SAFETY: as for `header`. + unsafe { self.table.as_ref() } + } + + /// Base address of the region: the header sits at offset zero. + const fn base(&self) -> *const u8 { + self.header.as_ptr().cast() + } } // --- The writer side: claim, fill, finish ---------------------------------- @@ -244,9 +266,21 @@ impl SharedState<'_> { /// reserved with atomic operations, filled in uniquely owned payload spans, /// and published with an atomic commit (see the ordering contract above). pub struct ShmWriter { + /// Owns the region the views point into; dropped with the writer. + #[cfg_attr(any(not(test), miri), expect(dead_code, reason = "held to keep the region alive"))] mem: M, + state: SharedState, } +// SAFETY: the writer touches the region only through the protocol's +// atomics, which synchronize access from any thread; the stored views +// point into the mapping's stable, independently owned target, not into +// the writer value itself. +unsafe impl Send for ShmWriter {} +// SAFETY: see the `Send` impl; the writer's shared-reference API is +// internally synchronized by the protocol. +unsafe impl Sync for ShmWriter {} + /// Why a frame could not be claimed. #[derive(thiserror::Error, Clone, Copy, PartialEq, Eq, Debug)] pub enum ClaimError { @@ -277,26 +311,20 @@ impl ShmWriter { /// # Panics /// /// Panics when the region is not `u64`-aligned or its size is outside the - /// supported range (see [`SharedState::borrow`]). + /// supported range (see [`SharedState::new`]). pub unsafe fn new(mem: M) -> Self { - // Validate the region geometry eagerly so misuse fails at - // construction, not at the first claim. - // SAFETY: forwarded from this function's contract. - let _ = unsafe { SharedState::borrow(mem.as_raw_slice()) }; - Self { mem } - } - - fn state(&self) -> SharedState<'_> { - // SAFETY: `new` requires the region to stay valid and - // protocol-governed for the writer's lifetime, and it validated the - // geometry. - unsafe { SharedState::borrow(self.mem.as_raw_slice()) } + // SAFETY: forwarded from this function's contract, which keeps the + // region valid and protocol-governed for the writer's lifetime — + // and so for every use of the views, which are stored in and + // dropped with the writer. + let state = unsafe { SharedState::new(mem.as_raw_slice()) }; + Self { mem, state } } /// Whether the CLOSED gate is set: the receiver closed the channel, /// or an earlier failed claim condemned it. pub fn is_closed(&self) -> bool { - self.state().header.claims.load(Ordering::Relaxed) & CLOSED != 0 + self.state.header().claims.load(Ordering::Relaxed) & CLOSED != 0 } /// Claims a frame of exactly `frame_size` bytes. @@ -312,14 +340,14 @@ impl ShmWriter { /// learns a record was lost, and later claims are refused — their /// records would ride a result the receiver must already reject. pub fn claim_frame(&self, frame_size: NonZeroUsize) -> Result, ClaimError> { - let state = self.state(); + let state = self.state; let payload_len = frame_size.get(); // Reports that this claim's record was lost, before the writer // moves on (rule 1): the gate makes the receiver report the // channel incomplete, and condemns further claims. let report_loss = || { - state.header.claims.fetch_or(CLOSED, Ordering::Relaxed); + state.header().claims.fetch_or(CLOSED, Ordering::Relaxed); ClaimError::Capacity }; @@ -336,7 +364,7 @@ impl ShmWriter { // because the counter is not what locates payloads (descriptors are) // and a `u64` cannot realistically wrap. let payload_start = - state.header.payload_reserved.fetch_add(reserved_len as u64, Ordering::Relaxed); + state.header().payload_reserved.fetch_add(reserved_len as u64, Ordering::Relaxed); // Checked: a foreign scribble of the counter must fail the claim, // not wrap the bound into an out-of-bounds reservation. let payload_end = payload_start.checked_add(reserved_len as u64); @@ -345,14 +373,14 @@ impl ShmWriter { } let payload_start = usize::try_from(payload_start).expect("bounded by the payload region"); - let claims = state.header.claims.fetch_add(1, Ordering::Relaxed); + let claims = state.header().claims.fetch_add(1, Ordering::Relaxed); if claims & CLOSED != 0 { // Not a loss: a record refused after close describes an // operation performed outside the channel's boundary. return Err(ClaimError::Closed); } let slot_index = usize::try_from(claims).expect("claim count exceeds usize"); - if slot_index >= state.table.len() { + if slot_index >= state.table().len() { return Err(report_loss()); } @@ -405,7 +433,7 @@ impl ShmWriter { /// and still performs the operation it described steps outside the usage /// contract — records are published before the recorded operation. pub struct FrameMut<'a> { - state: SharedState<'a>, + state: SharedState, slot_index: usize, descriptor: u64, content: &'a mut [u8], @@ -444,7 +472,7 @@ impl FrameMut<'_> { pub fn finish(self) { // Rule 2: `Release` orders every payload write before the // descriptor. - let _ = self.state.table[self.slot_index].compare_exchange( + let _ = self.state.table()[self.slot_index].compare_exchange( layout::UNFINISHED, self.descriptor, Ordering::Release, @@ -479,7 +507,11 @@ pub enum ProtocolError { /// is released when the reader drops. It holds no buffer: iteration /// re-reads the frozen descriptor table, so closing allocates nothing. pub struct ShmReader { + /// Owns the region the views point into; dropped with the reader. + #[expect(dead_code, reason = "held to keep the region alive")] mem: M, + /// The region views, pointing into the owned region's stable target. + state: SharedState, /// Length of the frozen prefix of the descriptor table. slot_count: usize, /// Committed frames in that prefix. @@ -487,6 +519,13 @@ pub struct ShmReader { complete: bool, } +// SAFETY: the reader reads only the header atomics, frozen slots, and +// immutable committed spans; the stored views point into the mapping's +// stable, independently owned target, not into the reader value itself. +unsafe impl Send for ShmReader {} +// SAFETY: see the `Send` impl. +unsafe impl Sync for ShmReader {} + impl ShmReader { /// Closes the channel over a shared-memory region and returns the /// reader of its committed frames. @@ -516,25 +555,24 @@ impl ShmReader { /// # Panics /// /// Panics when the region is not `u64`-aligned or its size is outside - /// the supported range (see [`SharedState::borrow`]). + /// the supported range (see [`SharedState::new`]). pub unsafe fn close(mem: M) -> Result { + // SAFETY: forwarded from this function's contract, which keeps the + // region valid for the reader's lifetime — and so for every use of + // the views, which are stored in and dropped with the reader. + let state = unsafe { SharedState::new(mem.as_raw_slice()) }; let slot_count; let mut frames = 0; let complete; { - // SAFETY: forwarded from this function's contract; the raw - // slice stays valid while `mem` is borrowed here and beyond, - // since it moves into the returned reader. - let state = unsafe { SharedState::borrow(mem.as_raw_slice()) }; - // The close boundary (rule 1): claims at or before this // snapshot are inside it, later ones land in slots this pass // never visits. The count is clamped to the table capacity, so // a counter inflated by failed claims (or by a foreign // scribble) degrades to a full-table sweep, not an error. - let claims = state.header.claims.load(Ordering::Relaxed); + let claims = state.header().claims.load(Ordering::Relaxed); slot_count = - usize::try_from(claims & !CLOSED).unwrap_or(usize::MAX).min(state.table.len()); + usize::try_from(claims & !CLOSED).unwrap_or(usize::MAX).min(state.table().len()); // The same load carries the completeness verdict: a gate set // before this boundary is a failed claim's loss report — or an // earlier close, and a re-close cannot vouch for records @@ -546,7 +584,7 @@ impl ShmReader { // this page where first touches are expensive. Claims racing // between the snapshot and this gate are dropped soundly (see // the module docs above). - state.header.claims.fetch_or(CLOSED, Ordering::Relaxed); + state.header().claims.fetch_or(CLOSED, Ordering::Relaxed); // Freeze pass: drive every admitted slot to a terminal state // and validate the committed descriptors. After this loop the @@ -557,7 +595,7 @@ impl ShmReader { for slot_index in 0..slot_count { // Rule 3: `Acquire` on failure makes a committed payload // visible. - let Err(bits) = state.table[slot_index].compare_exchange( + let Err(bits) = state.table()[slot_index].compare_exchange( layout::UNFINISHED, layout::ABORTED, Ordering::AcqRel, @@ -581,7 +619,7 @@ impl ShmReader { } } - Ok(Self { mem, slot_count, frames, complete }) + Ok(Self { mem, state, slot_count, frames, complete }) } /// Iterates over the committed frames in claim order. @@ -659,14 +697,10 @@ impl<'a, M: AsRawSlice> IntoIterator for &'a ShmReader { type Item = &'a [u8]; fn into_iter(self) -> Iter<'a> { - // SAFETY: `close` requires the region to stay valid and - // protocol-governed for the reader's lifetime, and it validated - // the geometry. - let state = unsafe { SharedState::borrow(self.mem.as_raw_slice()) }; Iter { - base: self.mem.as_raw_slice().cast::().cast_const(), - table: &state.table[..self.slot_count], - mapping_len: state.len, + base: self.state.base(), + table: &self.state.table()[..self.slot_count], + mapping_len: self.state.len, remaining: self.frames, } } @@ -686,7 +720,7 @@ impl<'a, M: AsRawSlice> IntoIterator for &'a ShmReader { #[cfg(target_os = "linux")] pub unsafe fn pre_fault(mem: &impl AsRawSlice) { // SAFETY: forwarded from this function's contract. - let state = unsafe { SharedState::borrow(mem.as_raw_slice()) }; + let state = unsafe { SharedState::new(mem.as_raw_slice()) }; // A compare-exchange of zero with zero on the claim counter: on an // untouched region it performs a real write — allocating the first // block of a sparse backing file — without changing protocol state. If @@ -694,7 +728,7 @@ pub unsafe fn pre_fault(mem: &impl AsRawSlice) { // exchange changes nothing. (An `or` of zero would not do: the // compiler may lower it to a plain load, which materializes only a // hole page without allocating the block.) - let _ = state.header.claims.compare_exchange(0, 0, Ordering::Relaxed, Ordering::Relaxed); + let _ = state.header().claims.compare_exchange(0, 0, Ordering::Relaxed, Ordering::Relaxed); } #[cfg(test)] From effa74e793ee05d51c3b9d329862f8c51c5c44b4 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sat, 15 Aug 2026 17:40:12 +0800 Subject: [PATCH 35/92] refactor(fspy-shm): split the sides apart over a shared layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single protocol module mixed three audiences: writer code, reader code, and the definitions both must agree on. Split along that line: - writer.rs — claim, fill, finish - reader.rs — close, completeness, iteration, and the borrow argument - layout.rs — only what both sides share: the shape math, the header, the slot codec, the ordering contract, and the views struct, renamed SharedState -> MappedLayout: it holds no state — it is the layout bound to one concrete mapping. Endpoints store it as 'mapped'. - mod.rs — the surface, overview docs, and integration tests Co-Authored-By: Claude Fable 5 --- .../src/ipc/channel/shm_io/README.md | 24 +- .../src/ipc/channel/shm_io/layout.rs | 161 ++++- .../fspy_shared/src/ipc/channel/shm_io/mod.rs | 624 +----------------- .../src/ipc/channel/shm_io/reader.rs | 235 +++++++ .../src/ipc/channel/shm_io/writer.rs | 235 +++++++ 5 files changed, 660 insertions(+), 619 deletions(-) create mode 100644 crates/fspy_shared/src/ipc/channel/shm_io/reader.rs create mode 100644 crates/fspy_shared/src/ipc/channel/shm_io/writer.rs diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/README.md b/crates/fspy_shared/src/ipc/channel/shm_io/README.md index 5702fa952..d28895442 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/README.md +++ b/crates/fspy_shared/src/ipc/channel/shm_io/README.md @@ -179,10 +179,22 @@ CLAIMED (slot 0) ---+ ## Files -| File | Role | -| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `mod.rs` | The protocol, in reading order: the overview docs, the typed views of the region and the ordering contract, the writer side (claim, fill, finish), the reader side (close and iteration), and the integration tests — miri-clean (`cargo miri test -p fspy_shared shm_io`). | -| `layout.rs` | The region's shape: the header struct, the sizing rule that turns a mapping length into table and payload bounds, payload rounding, span validation, and the descriptor codec. Describes memory, never touches it. | +| File | Role | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `mod.rs` | Public surface (`ShmWriter`, `ShmReader`), the protocol overview docs, and the integration tests — they run against a mocked region and are miri-clean (`cargo miri test -p fspy_shared shm_io`). | +| `writer.rs` | The writer side: claim a frame, fill it, finish it. | +| `reader.rs` | The reader side: close the channel, then iterate the committed frames — with the argument for why its borrows are sound. | +| `layout.rs` | Everything both sides share: the region's shape and sizing math, the header, the descriptor codec, and `MappedLayout` — the shape bound to one concrete mapping. | + +Arrows point at what a file depends on: + +```mermaid +graph TD + mod["mod.rs
public surface"] --> writer["writer.rs
claim, fill, finish"] + mod --> reader["reader.rs
close and iterate"] + writer --> layout["layout.rs
what both sides share"] + reader --> layout +``` -`mod.rs` depends on `layout.rs`; read `layout.rs` first, then `mod.rs` top -to bottom. +Read from the bottom up — `layout.rs`, then `writer.rs` and `reader.rs`, +then `mod.rs` — and each file only needs the ones below it. diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs index f95c6f3bc..3ee34bdbd 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs @@ -1,23 +1,24 @@ -//! Pure geometry of the shared-memory region. +//! Everything the writer and the reader sides share. //! //! The region is divided into three fixed areas: //! //! ```text -//! | header | descriptor table (SLOTS slots) | payloads (grow up) | +//! | header | descriptor table | payloads (grow up) | //! ``` //! -//! This module holds the region's shape, with no memory access: the -//! header struct, the sizing rule that turns a mapping length into table -//! and payload bounds, payload rounding, payload-span validation, and the -//! descriptor-slot codec. The operations on the region live in -//! [`super`]. +//! This module holds the region's shape — the sizing rule that turns a +//! mapping length into table and payload bounds, payload rounding, and +//! payload-span validation — the header type, the descriptor-slot codec +//! both sides encode and decode, and [`MappedLayout`]: the shape bound to +//! one concrete mapping, built once when an endpoint attaches. The sides +//! themselves live in [`super::writer`] and [`super::reader`]. //! //! Overflow safety follows from one bound enforced at construction time: //! the mapping length never exceeds [`MAX_MAPPING_LEN`], so all offsets fit //! the 32-bit descriptor fields and all sums fit `usize` on the 64-bit //! targets the parent module asserts. -use std::sync::atomic::AtomicU64; +use std::{ptr::NonNull, sync::atomic::AtomicU64}; /// The CLOSED gate bit of the claim counter, set by the receiver when it /// closes the channel and by any writer whose claim failed — the loss @@ -195,6 +196,150 @@ pub(super) const fn decode(mapping_len: usize, bits: u64) -> Option PayloadSpan::validate(mapping_len, (bits & OFFSET_MAX) as usize, (bits >> LEN_SHIFT) as usize) } +// --- The mapped layout and the ordering contract --------------------------- +// `MappedLayout::new` builds three typed views of the region — the +// `repr(C)` `Header`, the descriptor table as a slice of atomics sized by +// the mapping length, and the untyped payload area as a raw slice — once, +// when an endpoint attaches; the endpoint stores them beside the mapping +// they point into. Every access after that is a plain field access or a +// bounds-checked index. The payload area stays raw because writers hold +// exclusive `&mut` borrows into it, which must not alias any shared +// reference. +// +// # Shared atomics +// +// The header holds two independent monotonic `AtomicU64` counters: +// +// - the **claim counter**: bit 63 is the CLOSED gate, the low bits count +// claims ever attempted. Claiming is one wait-free `fetch_add`; the +// returned old value carries the claim's slot index, the gate, and — by +// comparison against the fixed table capacity — the capacity verdict. +// The gate is set by the receiver at close and by every failed claim: +// one bit is both the loss report completeness derives from and the +// valve that stops writers spending work on a channel whose result the +// receiver must already reject. +// - the **payload counter**: payload bytes ever reserved, bumped by another +// wait-free `fetch_add`. +// +// Failed claims leave the counters bumped; that is harmless, because the +// receiver clamps instead of trusting the counts, and committed +// descriptors carry their own offset and length, so the counters never +// locate data. The payload counter can even wrap on a long-condemned +// channel — still harmless: wrapping requires prior failures, failures +// set the gate, and the gate refuses every claim before a span is built. +// +// # Memory-ordering contract +// +// Three synchronization rules cover the whole protocol: +// +// 1. **Claim versus close** — the receiver's close boundary is a plain +// snapshot load of the claim counter: claims ordered at or before the +// value it reads (in the counter's modification order) are in the +// snapshot; later ones receive slot indices the receiver never visits. +// Claims publish no payload data, so `Relaxed` suffices throughout. +// The CLOSED gate is not itself the boundary — it stops stragglers +// from claiming (and allocating pages) forever; any claim admitted +// between the snapshot and the gate lands beyond the snapshot and is +// never observed. Completeness rides the same modification order: a +// failed claim sets the gate as its loss report before the writer +// performs the operation whose record was lost — so the snapshot +// either sees the bit, or the loss belongs to an operation performed +// after the boundary. A writer that skipped because it saw the bit is +// covered the same way: the bit that made it skip either reaches the +// snapshot or postdates the boundary. A writer that dies before +// setting the bit never performed its operation, so nothing was +// actually lost. +// 2. **Writer commit** — the slot compare-and-swap in `FrameMut::finish` +// uses `Release`: every payload write happens-before the committed +// descriptor becomes visible. +// 3. **Receiver observation** — the freeze compare-and-swap in +// `ShmReader::close` uses `Acquire` on failure: observing a committed +// descriptor also makes the payload writes it published visible, so the +// borrows `ShmReader` later hands out read settled bytes. + +/// The typed views of the region: the header, the descriptor table +/// sized from the mapping length, and the raw payload area. Built once +/// when an endpoint attaches and stored in it; the views stay valid +/// because they point into the mapping's stable target, not into the +/// endpoint value. +#[derive(Clone, Copy)] +pub(super) struct MappedLayout { + header: NonNull
, + table: NonNull<[AtomicU64]>, + pub(super) payloads: *mut [u8], + /// The real mapping length. Not derivable from the parts above: the + /// payload region rounds down to whole `u64`s, and re-deriving the + /// layout from a shortened length could shift the table boundary. + pub(super) len: usize, +} + +impl MappedLayout { + /// Builds the typed views of a shared mapping. + /// + /// # Safety + /// + /// - `mem` must be valid for reads and writes, and its address stable, + /// for as long as the returned views (and any copy of them) are + /// used. + /// - The memory must have been zero-initialized when the region was + /// created, and accessed only through this protocol since. + /// + /// # Panics + /// + /// Panics when the mapping cannot host the protocol at all: base not + /// `u64`-aligned, smaller than the header, or larger than + /// [`MAX_MAPPING_LEN`]. These indicate a broken caller, not + /// runtime data; senders guard untrusted mappings with + /// [`super::is_supported_region_len`] first. + #[expect(clippy::cast_ptr_alignment, reason = "the base is asserted `u64`-aligned below")] + pub(super) unsafe fn new(mem: *mut [u8]) -> Self { + let base = mem.cast::(); + let len = mem.len(); + assert!(!base.is_null()); + assert!(base.addr().is_multiple_of(align_of::
())); + assert!((HEADER_LEN..=MAX_MAPPING_LEN).contains(&len)); + // SAFETY: the base is non-null (asserted), and the header and the + // table lie inside the mapping — the header by the asserts above, + // the table by `max_slots` — at `u64`-aligned offsets. The + // payload area keeps the rest of the mapping as a raw slice; + // `layout` bounds every span carved from it. + unsafe { + Self { + header: NonNull::new_unchecked(base.cast::
()), + table: NonNull::slice_from_raw_parts( + NonNull::new_unchecked(base.add(HEADER_LEN).cast::()), + max_slots(len), + ), + payloads: std::ptr::slice_from_raw_parts_mut( + base.add(payload_base(len)), + payload_region_len(len), + ), + len, + } + } + } + + /// The protocol header. + pub(super) const fn header(&self) -> &Header { + // SAFETY: `new`'s contract keeps the target valid while any view + // is used, and the header consists of atomics, so the shared + // borrow is valid even while other threads and processes access + // the same memory through them. + unsafe { self.header.as_ref() } + } + + /// The descriptor table. + pub(super) const fn table(&self) -> &[AtomicU64] { + // SAFETY: as for `header`. + unsafe { self.table.as_ref() } + } + + /// Base address of the region: the header sits at offset zero. + pub(super) const fn base(&self) -> *const u8 { + self.header.as_ptr().cast() + } +} + #[cfg(test)] mod tests { use assert2::assert; diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs index 92e206595..79b926b26 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs @@ -30,8 +30,9 @@ //! from the returned old values. A failed claim sets the CLOSED gate as //! its loss report and leaves the counters bumped, harmlessly: readers //! clamp to the region capacities, and committed descriptors are -//! self-describing ([`layout`]), so the counters never locate data. Every slot has a fixed location, so -//! an unfinished frame can never hide a later one. +//! self-describing ([`layout`]), so the counters never locate data. Every +//! slot has a fixed location, so an unfinished frame can never hide a +//! later one. //! //! # Frame lifecycle //! @@ -72,18 +73,23 @@ //! checks, heartbeats, or timeouts. mod layout; +mod reader; +mod writer; -use std::{ - fmt, - num::NonZeroUsize, - ops::{Deref, DerefMut}, - ptr::{NonNull, slice_from_raw_parts_mut}, - slice, - sync::atomic::{AtomicU64, Ordering}, -}; +use std::ptr::slice_from_raw_parts_mut; +#[cfg(target_os = "linux")] +use std::sync::atomic::Ordering; use fspy_shm::Mapping; -use layout::{CLOSED, Header}; +#[cfg(target_os = "linux")] +use layout::MappedLayout; +// Only tests name the error types; production matches on `Ok`/`Err` alone. +#[cfg(test)] +pub use reader::ProtocolError; +pub use reader::ShmReader; +#[cfg(test)] +pub use writer::ClaimError; +pub use writer::ShmWriter; // The region arithmetic in `layout` relies on `usize` accommodating sums of // 32-bit-bounded quantities, and the descriptor protocol on native 64-bit @@ -114,598 +120,6 @@ pub fn is_supported_region_len(len: usize) -> bool { (layout::HEADER_LEN..=layout::MAX_MAPPING_LEN).contains(&len) } -// --- The region views and the ordering contract ---------------------------- -// `SharedState::new` builds three typed views of the region — the -// `repr(C)` `Header`, the descriptor table as a slice of atomics sized by -// the mapping length, and the untyped payload area as a raw slice — once, -// when an endpoint attaches; the endpoint stores them beside the mapping -// they point into. Every access after that is a plain field access or a -// bounds-checked index. The payload area stays raw because writers hold -// exclusive `&mut` borrows into it, which must not alias any shared -// reference. -// -// # Shared atomics -// -// The header holds two independent monotonic `AtomicU64` counters: -// -// - the **claim counter**: bit 63 is the CLOSED gate, the low bits count -// claims ever attempted. Claiming is one wait-free `fetch_add`; the -// returned old value carries the claim's slot index, the gate, and — by -// comparison against the fixed table capacity — the capacity verdict. -// The gate is set by the receiver at close and by every failed claim: -// one bit is both the loss report completeness derives from and the -// valve that stops writers spending work on a channel whose result the -// receiver must already reject. -// - the **payload counter**: payload bytes ever reserved, bumped by another -// wait-free `fetch_add`. -// -// Failed claims leave the counters bumped; that is harmless, because the -// receiver clamps instead of trusting the counts, and committed -// descriptors carry their own offset and length, so the counters never -// locate data. The payload counter can even wrap on a long-condemned -// channel — still harmless: wrapping requires prior failures, failures -// set the gate, and the gate refuses every claim before a span is built. -// -// # Memory-ordering contract -// -// Three synchronization rules cover the whole protocol: -// -// 1. **Claim versus close** — the receiver's close boundary is a plain -// snapshot load of the claim counter: claims ordered at or before the -// value it reads (in the counter's modification order) are in the -// snapshot; later ones receive slot indices the receiver never visits. -// Claims publish no payload data, so `Relaxed` suffices throughout. -// The CLOSED gate is not itself the boundary — it stops stragglers -// from claiming (and allocating pages) forever; any claim admitted -// between the snapshot and the gate lands beyond the snapshot and is -// never observed. Completeness rides the same modification order: a -// failed claim sets the gate as its loss report before the writer -// performs the operation whose record was lost — so the snapshot -// either sees the bit, or the loss belongs to an operation performed -// after the boundary. A writer that skipped because it saw the bit is -// covered the same way: the bit that made it skip either reaches the -// snapshot or postdates the boundary. A writer that dies before -// setting the bit never performed its operation, so nothing was -// actually lost. -// 2. **Writer commit** — the slot compare-and-swap in `FrameMut::finish` -// uses `Release`: every payload write happens-before the committed -// descriptor becomes visible. -// 3. **Receiver observation** — the freeze compare-and-swap in -// `ShmReader::close` uses `Acquire` on failure: observing a committed -// descriptor also makes the payload writes it published visible, so the -// borrows `ShmReader` later hands out read settled bytes. - -/// The typed views of the region: the header, the descriptor table -/// sized from the mapping length, and the raw payload area. Built once -/// when an endpoint attaches and stored in it; the views stay valid -/// because they point into the mapping's stable target, not into the -/// endpoint value. -#[derive(Clone, Copy)] -struct SharedState { - header: NonNull
, - table: NonNull<[AtomicU64]>, - payloads: *mut [u8], - /// The real mapping length. Not derivable from the parts above: the - /// payload region rounds down to whole `u64`s, and re-deriving the - /// layout from a shortened length could shift the table boundary. - len: usize, -} - -impl SharedState { - /// Builds the typed views of a shared mapping. - /// - /// # Safety - /// - /// - `mem` must be valid for reads and writes, and its address stable, - /// for as long as the returned views (and any copy of them) are - /// used. - /// - The memory must have been zero-initialized when the region was - /// created, and accessed only through this protocol since. - /// - /// # Panics - /// - /// Panics when the mapping cannot host the protocol at all: base not - /// `u64`-aligned, smaller than the header, or larger than - /// [`layout::MAX_MAPPING_LEN`]. These indicate a broken caller, not - /// runtime data; senders guard untrusted mappings with - /// [`is_supported_region_len`] first. - #[expect(clippy::cast_ptr_alignment, reason = "the base is asserted `u64`-aligned below")] - unsafe fn new(mem: *mut [u8]) -> Self { - let base = mem.cast::(); - let len = mem.len(); - assert!(!base.is_null()); - assert!(base.addr().is_multiple_of(align_of::
())); - assert!((layout::HEADER_LEN..=layout::MAX_MAPPING_LEN).contains(&len)); - // SAFETY: the base is non-null (asserted), and the header and the - // table lie inside the mapping — the header by the asserts above, - // the table by `layout::max_slots` — at `u64`-aligned offsets. The - // payload area keeps the rest of the mapping as a raw slice; - // `layout` bounds every span carved from it. - unsafe { - Self { - header: NonNull::new_unchecked(base.cast::
()), - table: NonNull::slice_from_raw_parts( - NonNull::new_unchecked(base.add(layout::HEADER_LEN).cast::()), - layout::max_slots(len), - ), - payloads: std::ptr::slice_from_raw_parts_mut( - base.add(layout::payload_base(len)), - layout::payload_region_len(len), - ), - len, - } - } - } - - /// The protocol header. - const fn header(&self) -> &Header { - // SAFETY: `new`'s contract keeps the target valid while any view - // is used, and the header consists of atomics, so the shared - // borrow is valid even while other threads and processes access - // the same memory through them. - unsafe { self.header.as_ref() } - } - - /// The descriptor table. - const fn table(&self) -> &[AtomicU64] { - // SAFETY: as for `header`. - unsafe { self.table.as_ref() } - } - - /// Base address of the region: the header sits at offset zero. - const fn base(&self) -> *const u8 { - self.header.as_ptr().cast() - } -} - -// --- The writer side: claim, fill, finish ---------------------------------- - -/// A concurrent shared-memory frame writer. -/// -/// Safe to use across threads and processes at the same time: frames are -/// reserved with atomic operations, filled in uniquely owned payload spans, -/// and published with an atomic commit (see the ordering contract above). -pub struct ShmWriter { - /// Owns the region the views point into; dropped with the writer. - #[cfg_attr(any(not(test), miri), expect(dead_code, reason = "held to keep the region alive"))] - mem: M, - state: SharedState, -} - -// SAFETY: the writer touches the region only through the protocol's -// atomics, which synchronize access from any thread; the stored views -// point into the mapping's stable, independently owned target, not into -// the writer value itself. -unsafe impl Send for ShmWriter {} -// SAFETY: see the `Send` impl; the writer's shared-reference API is -// internally synchronized by the protocol. -unsafe impl Sync for ShmWriter {} - -/// Why a frame could not be claimed. -#[derive(thiserror::Error, Clone, Copy, PartialEq, Eq, Debug)] -pub enum ClaimError { - /// The CLOSED gate was set: the receiver closed the channel, or an - /// earlier failed claim condemned it. Skipping the record is sound - /// either way — it is outside the receiver's boundary, or the same - /// bit already makes the receiver report the channel incomplete. - #[error("the channel has been closed")] - Closed, - /// The claim was refused for space: the region was full, or the frame - /// was larger than the `i32::MAX`-byte frame limit. The loss is - /// already recorded — this claim set the CLOSED gate — so the channel - /// will report itself incomplete and refuse further claims. - #[error("no space left in the shared-memory region")] - Capacity, -} - -impl ShmWriter { - /// Creates a writer backed by a shared-memory region. - /// - /// # Safety - /// - /// - `mem.as_raw_slice()` must return a stable, valid pointer to the - /// whole region for the writer's lifetime. - /// - The region must have been zero-initialized when it was created and - /// accessed only through this protocol since. - /// - /// # Panics - /// - /// Panics when the region is not `u64`-aligned or its size is outside the - /// supported range (see [`SharedState::new`]). - pub unsafe fn new(mem: M) -> Self { - // SAFETY: forwarded from this function's contract, which keeps the - // region valid and protocol-governed for the writer's lifetime — - // and so for every use of the views, which are stored in and - // dropped with the writer. - let state = unsafe { SharedState::new(mem.as_raw_slice()) }; - Self { mem, state } - } - - /// Whether the CLOSED gate is set: the receiver closed the channel, - /// or an earlier failed claim condemned it. - pub fn is_closed(&self) -> bool { - self.state.header().claims.load(Ordering::Relaxed) & CLOSED != 0 - } - - /// Claims a frame of exactly `frame_size` bytes. - /// - /// The frame is invisible to the receiver until [`FrameMut::finish`] - /// commits it. Dropping the frame without finishing abandons the claim: - /// the receiver ignores the slot, exactly as if the writer had died. - /// Frames larger than `i32::MAX` bytes are refused as - /// [`ClaimError::Capacity`]. - /// - /// Wait-free: two `fetch_add`s, no retry loop (rule 1). A claim that - /// does not fit fails after setting the CLOSED gate: the receiver - /// learns a record was lost, and later claims are refused — their - /// records would ride a result the receiver must already reject. - pub fn claim_frame(&self, frame_size: NonZeroUsize) -> Result, ClaimError> { - let state = self.state; - let payload_len = frame_size.get(); - - // Reports that this claim's record was lost, before the writer - // moves on (rule 1): the gate makes the receiver report the - // channel incomplete, and condemns further claims. - let report_loss = || { - state.header().claims.fetch_or(CLOSED, Ordering::Relaxed); - ClaimError::Capacity - }; - - // No descriptor can describe a payload this long; refuse it before - // touching the counters, so the channel keeps working for every - // record after it. - if payload_len > layout::MAX_PAYLOAD_LEN { - return Err(report_loss()); - } - let reserved_len = layout::reserved_payload_len(payload_len); - - // Payload bytes first, so a payload-capacity failure does not burn a - // slot. A failed reservation stays counted — overshoot is harmless - // because the counter is not what locates payloads (descriptors are) - // and a `u64` cannot realistically wrap. - let payload_start = - state.header().payload_reserved.fetch_add(reserved_len as u64, Ordering::Relaxed); - // Checked: a foreign scribble of the counter must fail the claim, - // not wrap the bound into an out-of-bounds reservation. - let payload_end = payload_start.checked_add(reserved_len as u64); - if payload_end.is_none_or(|end| end > state.payloads.len() as u64) { - return Err(report_loss()); - } - let payload_start = usize::try_from(payload_start).expect("bounded by the payload region"); - - let claims = state.header().claims.fetch_add(1, Ordering::Relaxed); - if claims & CLOSED != 0 { - // Not a loss: a record refused after close describes an - // operation performed outside the channel's boundary. - return Err(ClaimError::Closed); - } - let slot_index = usize::try_from(claims).expect("claim count exceeds usize"); - if slot_index >= state.table().len() { - return Err(report_loss()); - } - - // SAFETY: the claim reserved - // `[payload_start, payload_start + reserved_len)` — inside the - // payload region by the capacity check above — exclusively for this - // frame: other writers reserve disjoint spans, and the receiver - // never reads a payload before observing its committed descriptor, - // which `finish` publishes only when it consumes this borrow. - let content = unsafe { - slice::from_raw_parts_mut(state.payloads.cast::().add(payload_start), payload_len) - }; - Ok(FrameMut { - state, - slot_index, - descriptor: layout::committed( - layout::payload_base(state.len) + payload_start, - payload_len, - ), - content, - }) - } - - // Unwrap `self` and return the underlying memory. - #[cfg(all(test, not(miri)))] - pub fn into_memory(self) -> M { - self.mem - } - - #[cfg(test)] - pub fn try_write_frame(&self, frame: &[u8]) -> bool { - let Some(frame_size) = NonZeroUsize::new(frame.len()) else { - return false; - }; - let Ok(mut frame_mut) = self.claim_frame(frame_size) else { - return false; - }; - frame_mut.copy_from_slice(frame); - frame_mut.finish(); - true - } -} - -/// An exclusively owned, claimed-but-unpublished frame. -/// -/// [`FrameMut::finish`] commits the frame; it is the only way to make the -/// payload visible to the receiver. Dropping the frame instead abandons -/// the claim: the slot stays unfinished and the receiver ignores it, -/// exactly as if the writer had died there. A writer that abandons a frame -/// and still performs the operation it described steps outside the usage -/// contract — records are published before the recorded operation. -pub struct FrameMut<'a> { - state: SharedState, - slot_index: usize, - descriptor: u64, - content: &'a mut [u8], -} - -impl fmt::Debug for FrameMut<'_> { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("FrameMut") - .field("slot_index", &self.slot_index) - .field("len", &self.content.len()) - .finish_non_exhaustive() - } -} - -impl Deref for FrameMut<'_> { - type Target = [u8]; - - fn deref(&self) -> &Self::Target { - self.content - } -} - -impl DerefMut for FrameMut<'_> { - fn deref_mut(&mut self) -> &mut Self::Target { - self.content - } -} - -impl FrameMut<'_> { - /// Commits the frame, making it visible to the receiver. - /// - /// If the receiver closed the channel and aborted this frame's slot - /// first, the swap fails and the frame is silently discarded: the - /// record belongs to the close race and is intentionally excluded - /// either way. - pub fn finish(self) { - // Rule 2: `Release` orders every payload write before the - // descriptor. - let _ = self.state.table()[self.slot_index].compare_exchange( - layout::UNFINISHED, - self.descriptor, - Ordering::Release, - Ordering::Relaxed, - ); - } -} - -// --- The reader side: close and iterate ------------------------------------- -// -// Closing never waits for writers, and no payload byte is read or copied: -// the reader keeps the mapping alive and hands out borrows of the validated -// committed spans on demand. Those borrows are sound because of the -// protocol, not despite it: a committed span is never written again -// (committing consumes the writer's frame), every borrow covers exactly one -// validated committed span, and everything a live writer may still touch — -// counters, slots, its own claimed or abandoned spans — is disjoint from -// every committed span. This rests on the constructor contract that the -// region is accessed only through this protocol; a process scribbling -// outside the protocol is outside the trust model. - -/// Shared-memory metadata that could not have been produced by this -/// protocol. The region was corrupted; its frames are unusable. -#[derive(thiserror::Error, Clone, Copy, PartialEq, Eq, Debug)] -pub enum ProtocolError { - #[error("corrupt shared-memory frame descriptor at slot {slot_index}")] - CorruptDescriptor { slot_index: usize }, -} - -/// A reader over the committed frames of a closed channel, serving them -/// straight out of the mapping, which stays alive inside this value and -/// is released when the reader drops. It holds no buffer: iteration -/// re-reads the frozen descriptor table, so closing allocates nothing. -pub struct ShmReader { - /// Owns the region the views point into; dropped with the reader. - #[expect(dead_code, reason = "held to keep the region alive")] - mem: M, - /// The region views, pointing into the owned region's stable target. - state: SharedState, - /// Length of the frozen prefix of the descriptor table. - slot_count: usize, - /// Committed frames in that prefix. - frames: usize, - complete: bool, -} - -// SAFETY: the reader reads only the header atomics, frozen slots, and -// immutable committed spans; the stored views point into the mapping's -// stable, independently owned target, not into the reader value itself. -unsafe impl Send for ShmReader {} -// SAFETY: see the `Send` impl. -unsafe impl Sync for ShmReader {} - -impl ShmReader { - /// Closes the channel over a shared-memory region and returns the - /// reader of its committed frames. - /// - /// Never blocks on writers: writers admitted before the snapshot race - /// per slot, and each raced slot independently ends up committed - /// (included) or aborted (excluded). Claims after the snapshot land in - /// slots this pass never visits until the CLOSED gate — set before - /// this returns — stops them. See the protocol docs at the top of this - /// module. - /// - /// # Safety - /// - /// Same contract as [`ShmWriter::new`]: - /// - /// - `mem.as_raw_slice()` must return a stable, valid pointer to the - /// whole region for the reader's lifetime. - /// - The region must have been zero-initialized when it was created and - /// accessed only through this protocol since. - /// - /// # Errors - /// - /// [`ProtocolError`] when the shared-memory metadata could not have - /// been produced by a correct writer; the region was corrupted and its - /// frames are unusable. - /// - /// # Panics - /// - /// Panics when the region is not `u64`-aligned or its size is outside - /// the supported range (see [`SharedState::new`]). - pub unsafe fn close(mem: M) -> Result { - // SAFETY: forwarded from this function's contract, which keeps the - // region valid for the reader's lifetime — and so for every use of - // the views, which are stored in and dropped with the reader. - let state = unsafe { SharedState::new(mem.as_raw_slice()) }; - let slot_count; - let mut frames = 0; - let complete; - { - // The close boundary (rule 1): claims at or before this - // snapshot are inside it, later ones land in slots this pass - // never visits. The count is clamped to the table capacity, so - // a counter inflated by failed claims (or by a foreign - // scribble) degrades to a full-table sweep, not an error. - let claims = state.header().claims.load(Ordering::Relaxed); - slot_count = - usize::try_from(claims & !CLOSED).unwrap_or(usize::MAX).min(state.table().len()); - // The same load carries the completeness verdict: a gate set - // before this boundary is a failed claim's loss report — or an - // earlier close, and a re-close cannot vouch for records - // refused since then (rule 1). - complete = claims & CLOSED == 0; - - // Gate further claims, so stragglers stop claiming (and - // materializing pages) forever. Cheap: the creator pre-faulted - // this page where first touches are expensive. Claims racing - // between the snapshot and this gate are dropped soundly (see - // the module docs above). - state.header().claims.fetch_or(CLOSED, Ordering::Relaxed); - - // Freeze pass: drive every admitted slot to a terminal state - // and validate the committed descriptors. After this loop the - // snapshot's slice of the descriptor table can no longer change - // — late writers lose their commit race against `ABORTED` — so - // iteration re-reads the table instead of snapshotting it: - // nothing is copied or allocated. - for slot_index in 0..slot_count { - // Rule 3: `Acquire` on failure makes a committed payload - // visible. - let Err(bits) = state.table()[slot_index].compare_exchange( - layout::UNFINISHED, - layout::ABORTED, - Ordering::AcqRel, - Ordering::Acquire, - ) else { - // The receiver won the race: the unfinished slot is now - // aborted and stays ignored. - continue; - }; - if bits == layout::ABORTED { - // Aborted by an earlier close over the same region; - // still ignored. - continue; - } - // Any other terminal value must be a committed descriptor - // with a valid span; a foreign scribble fails the decode. - if layout::decode(state.len, bits).is_none() { - return Err(ProtocolError::CorruptDescriptor { slot_index }); - } - frames += 1; - } - } - - Ok(Self { mem, state, slot_count, frames, complete }) - } - - /// Iterates over the committed frames in claim order. - pub fn iter(&self) -> Iter<'_> { - self.into_iter() - } - - /// Whether every record a writer published made it in. - /// - /// False when a claim failed before the channel closed — the region - /// was out of space, or a frame exceeded the frame limit: its record - /// was lost, and the frames under-report what writers went on to do. - /// Consumers that need completeness must reject them. - #[must_use] - pub const fn is_complete(&self) -> bool { - self.complete - } -} - -impl fmt::Debug for ShmReader { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("ShmReader") - .field("frames", &self.frames) - .field("complete", &self.complete) - .finish_non_exhaustive() - } -} - -/// Iterator over a [`ShmReader`]'s committed frames, in claim order. -pub struct Iter<'a> { - /// Base address of the mapping the descriptors' spans point into. - base: *const u8, - /// The not-yet-visited part of the table's frozen prefix. - table: &'a [AtomicU64], - /// Mapping length, which decoding validates descriptors against. - mapping_len: usize, - /// Committed frames not yet yielded. - remaining: usize, -} - -impl<'a> Iterator for Iter<'a> { - type Item = &'a [u8]; - - fn next(&mut self) -> Option { - while let Some((slot, rest)) = self.table.split_first() { - self.table = rest; - // The slot is terminal (`close` froze it), so this plain load - // reads the same value the freeze pass saw; the transfer that - // carried the reader to this thread carried the freeze pass's - // `Acquire` payload visibility with it (rule 3). - let bits = slot.load(Ordering::Relaxed); - // `None` is an aborted slot: nothing was published. Corrupt - // values cannot appear — `close` already failed the channel on - // them — so every decoded span is one `close` validated. - let Some(span) = layout::decode(self.mapping_len, bits) else { - continue; - }; - self.remaining -= 1; - // SAFETY: `close` validated the span against the mapping's - // layout, and a committed span is immutable for the mapping's - // lifetime (see the section comment above); the reader - // borrowed for `'a` keeps the mapping alive and mapped. - return Some(unsafe { slice::from_raw_parts(self.base.add(span.offset), span.len) }); - } - None - } - - fn size_hint(&self) -> (usize, Option) { - (self.remaining, Some(self.remaining)) - } -} - -impl<'a, M: AsRawSlice> IntoIterator for &'a ShmReader { - type IntoIter = Iter<'a>; - type Item = &'a [u8]; - - fn into_iter(self) -> Iter<'a> { - Iter { - base: self.state.base(), - table: &self.state.table()[..self.slot_count], - mapping_len: self.state.len, - remaining: self.frames, - } - } -} - /// Materializes the page backing the protocol header without changing /// protocol state, so that neither a writer's first claim nor /// [`ShmReader::close`]'s snapshot pays for the backing file's first @@ -720,7 +134,7 @@ impl<'a, M: AsRawSlice> IntoIterator for &'a ShmReader { #[cfg(target_os = "linux")] pub unsafe fn pre_fault(mem: &impl AsRawSlice) { // SAFETY: forwarded from this function's contract. - let state = unsafe { SharedState::new(mem.as_raw_slice()) }; + let mapped = unsafe { MappedLayout::new(mem.as_raw_slice()) }; // A compare-exchange of zero with zero on the claim counter: on an // untouched region it performs a real write — allocating the first // block of a sparse backing file — without changing protocol state. If @@ -728,7 +142,7 @@ pub unsafe fn pre_fault(mem: &impl AsRawSlice) { // exchange changes nothing. (An `or` of zero would not do: the // compiler may lower it to a plain load, which materializes only a // hole page without allocating the block.) - let _ = state.header().claims.compare_exchange(0, 0, Ordering::Relaxed, Ordering::Relaxed); + let _ = mapped.header().claims.compare_exchange(0, 0, Ordering::Relaxed, Ordering::Relaxed); } #[cfg(test)] diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs new file mode 100644 index 000000000..32ce43e77 --- /dev/null +++ b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs @@ -0,0 +1,235 @@ +//! The reader side: close the channel, then iterate the committed frames. +//! +//! +//! Closing never waits for writers, and no payload byte is read or copied: +//! the reader keeps the mapping alive and hands out borrows of the validated +//! committed spans on demand. Those borrows are sound because of the +//! protocol, not despite it: a committed span is never written again +//! (committing consumes the writer's frame), every borrow covers exactly one +//! validated committed span, and everything a live writer may still touch — +//! counters, slots, its own claimed or abandoned spans — is disjoint from +//! every committed span. This rests on the constructor contract that the +//! region is accessed only through this protocol; a process scribbling +//! outside the protocol is outside the trust model. + +use std::{ + fmt, slice, + sync::atomic::{AtomicU64, Ordering}, +}; + +use super::{ + AsRawSlice, + layout::{self, CLOSED, MappedLayout}, +}; + +/// Shared-memory metadata that could not have been produced by this +/// protocol. The region was corrupted; its frames are unusable. +#[derive(thiserror::Error, Clone, Copy, PartialEq, Eq, Debug)] +pub enum ProtocolError { + #[error("corrupt shared-memory frame descriptor at slot {slot_index}")] + CorruptDescriptor { slot_index: usize }, +} + +/// A reader over the committed frames of a closed channel, serving them +/// straight out of the mapping, which stays alive inside this value and +/// is released when the reader drops. It holds no buffer: iteration +/// re-reads the frozen descriptor table, so closing allocates nothing. +pub struct ShmReader { + /// Owns the region the views point into; dropped with the reader. + #[expect(dead_code, reason = "held to keep the region alive")] + mem: M, + /// The layout mapped onto the owned region. + mapped: MappedLayout, + /// Length of the frozen prefix of the descriptor table. + slot_count: usize, + /// Committed frames in that prefix. + frames: usize, + complete: bool, +} + +// SAFETY: the reader reads only the header atomics, frozen slots, and +// immutable committed spans; the stored views point into the mapping's +// stable, independently owned target, not into the reader value itself. +unsafe impl Send for ShmReader {} +// SAFETY: see the `Send` impl. +unsafe impl Sync for ShmReader {} + +impl ShmReader { + /// Closes the channel over a shared-memory region and returns the + /// reader of its committed frames. + /// + /// Never blocks on writers: writers admitted before the snapshot race + /// per slot, and each raced slot independently ends up committed + /// (included) or aborted (excluded). Claims after the snapshot land in + /// slots this pass never visits until the CLOSED gate — set before + /// this returns — stops them. See the protocol docs at the top of this + /// module. + /// + /// # Safety + /// + /// Same contract as [`ShmWriter::new`](super::ShmWriter::new): + /// + /// - `mem.as_raw_slice()` must return a stable, valid pointer to the + /// whole region for the reader's lifetime. + /// - The region must have been zero-initialized when it was created and + /// accessed only through this protocol since. + /// + /// # Errors + /// + /// [`ProtocolError`] when the shared-memory metadata could not have + /// been produced by a correct writer; the region was corrupted and its + /// frames are unusable. + /// + /// # Panics + /// + /// Panics when the region is not `u64`-aligned or its size is outside + /// the supported range (see [`MappedLayout::new`]). + pub unsafe fn close(mem: M) -> Result { + // SAFETY: forwarded from this function's contract, which keeps the + // region valid for the reader's lifetime — and so for every use of + // the views, which are stored in and dropped with the reader. + let mapped = unsafe { MappedLayout::new(mem.as_raw_slice()) }; + let slot_count; + let mut frames = 0; + let complete; + { + // The close boundary (rule 1): claims at or before this + // snapshot are inside it, later ones land in slots this pass + // never visits. The count is clamped to the table capacity, so + // a counter inflated by failed claims (or by a foreign + // scribble) degrades to a full-table sweep, not an error. + let claims = mapped.header().claims.load(Ordering::Relaxed); + slot_count = + usize::try_from(claims & !CLOSED).unwrap_or(usize::MAX).min(mapped.table().len()); + // The same load carries the completeness verdict: a gate set + // before this boundary is a failed claim's loss report — or an + // earlier close, and a re-close cannot vouch for records + // refused since then (rule 1). + complete = claims & CLOSED == 0; + + // Gate further claims, so stragglers stop claiming (and + // materializing pages) forever. Cheap: the creator pre-faulted + // this page where first touches are expensive. Claims racing + // between the snapshot and this gate are dropped soundly (see + // the module docs above). + mapped.header().claims.fetch_or(CLOSED, Ordering::Relaxed); + + // Freeze pass: drive every admitted slot to a terminal state + // and validate the committed descriptors. After this loop the + // snapshot's slice of the descriptor table can no longer change + // — late writers lose their commit race against `ABORTED` — so + // iteration re-reads the table instead of snapshotting it: + // nothing is copied or allocated. + for slot_index in 0..slot_count { + // Rule 3: `Acquire` on failure makes a committed payload + // visible. + let Err(bits) = mapped.table()[slot_index].compare_exchange( + layout::UNFINISHED, + layout::ABORTED, + Ordering::AcqRel, + Ordering::Acquire, + ) else { + // The receiver won the race: the unfinished slot is now + // aborted and stays ignored. + continue; + }; + if bits == layout::ABORTED { + // Aborted by an earlier close over the same region; + // still ignored. + continue; + } + // Any other terminal value must be a committed descriptor + // with a valid span; a foreign scribble fails the decode. + if layout::decode(mapped.len, bits).is_none() { + return Err(ProtocolError::CorruptDescriptor { slot_index }); + } + frames += 1; + } + } + + Ok(Self { mem, mapped, slot_count, frames, complete }) + } + + /// Iterates over the committed frames in claim order. + pub fn iter(&self) -> Iter<'_> { + self.into_iter() + } + + /// Whether every record a writer published made it in. + /// + /// False when a claim failed before the channel closed — the region + /// was out of space, or a frame exceeded the frame limit: its record + /// was lost, and the frames under-report what writers went on to do. + /// Consumers that need completeness must reject them. + #[must_use] + pub const fn is_complete(&self) -> bool { + self.complete + } +} + +impl fmt::Debug for ShmReader { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ShmReader") + .field("frames", &self.frames) + .field("complete", &self.complete) + .finish_non_exhaustive() + } +} + +/// Iterator over a [`ShmReader`]'s committed frames, in claim order. +pub struct Iter<'a> { + /// Base address of the mapping the descriptors' spans point into. + base: *const u8, + /// The not-yet-visited part of the table's frozen prefix. + table: &'a [AtomicU64], + /// Mapping length, which decoding validates descriptors against. + mapping_len: usize, + /// Committed frames not yet yielded. + remaining: usize, +} + +impl<'a> Iterator for Iter<'a> { + type Item = &'a [u8]; + + fn next(&mut self) -> Option { + while let Some((slot, rest)) = self.table.split_first() { + self.table = rest; + // The slot is terminal (`close` froze it), so this plain load + // reads the same value the freeze pass saw; the transfer that + // carried the reader to this thread carried the freeze pass's + // `Acquire` payload visibility with it (rule 3). + let bits = slot.load(Ordering::Relaxed); + // `None` is an aborted slot: nothing was published. Corrupt + // values cannot appear — `close` already failed the channel on + // them — so every decoded span is one `close` validated. + let Some(span) = layout::decode(self.mapping_len, bits) else { + continue; + }; + self.remaining -= 1; + // SAFETY: `close` validated the span against the mapping's + // layout, and a committed span is immutable for the mapping's + // lifetime (see the section comment above); the reader + // borrowed for `'a` keeps the mapping alive and mapped. + return Some(unsafe { slice::from_raw_parts(self.base.add(span.offset), span.len) }); + } + None + } + + fn size_hint(&self) -> (usize, Option) { + (self.remaining, Some(self.remaining)) + } +} + +impl<'a, M: AsRawSlice> IntoIterator for &'a ShmReader { + type IntoIter = Iter<'a>; + type Item = &'a [u8]; + + fn into_iter(self) -> Iter<'a> { + Iter { + base: self.mapped.base(), + table: &self.mapped.table()[..self.slot_count], + mapping_len: self.mapped.len, + remaining: self.frames, + } + } +} diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs new file mode 100644 index 000000000..4e60a4636 --- /dev/null +++ b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs @@ -0,0 +1,235 @@ +//! The writer side: claim a frame, fill it, finish it. + +use std::{ + fmt, + num::NonZeroUsize, + ops::{Deref, DerefMut}, + slice, + sync::atomic::Ordering, +}; + +use super::{ + AsRawSlice, + layout::{self, CLOSED, MappedLayout}, +}; + +/// A concurrent shared-memory frame writer. +/// +/// Safe to use across threads and processes at the same time: frames are +/// reserved with atomic operations, filled in uniquely owned payload spans, +/// and published with an atomic commit (see the ordering contract above). +pub struct ShmWriter { + /// Owns the region the views point into; dropped with the writer. + #[cfg_attr(any(not(test), miri), expect(dead_code, reason = "held to keep the region alive"))] + mem: M, + mapped: MappedLayout, +} + +// SAFETY: the writer touches the region only through the protocol's +// atomics, which synchronize access from any thread; the stored views +// point into the mapping's stable, independently owned target, not into +// the writer value itself. +unsafe impl Send for ShmWriter {} +// SAFETY: see the `Send` impl; the writer's shared-reference API is +// internally synchronized by the protocol. +unsafe impl Sync for ShmWriter {} + +/// Why a frame could not be claimed. +#[derive(thiserror::Error, Clone, Copy, PartialEq, Eq, Debug)] +pub enum ClaimError { + /// The CLOSED gate was set: the receiver closed the channel, or an + /// earlier failed claim condemned it. Skipping the record is sound + /// either way — it is outside the receiver's boundary, or the same + /// bit already makes the receiver report the channel incomplete. + #[error("the channel has been closed")] + Closed, + /// The claim was refused for space: the region was full, or the frame + /// was larger than the `i32::MAX`-byte frame limit. The loss is + /// already recorded — this claim set the CLOSED gate — so the channel + /// will report itself incomplete and refuse further claims. + #[error("no space left in the shared-memory region")] + Capacity, +} + +impl ShmWriter { + /// Creates a writer backed by a shared-memory region. + /// + /// # Safety + /// + /// - `mem.as_raw_slice()` must return a stable, valid pointer to the + /// whole region for the writer's lifetime. + /// - The region must have been zero-initialized when it was created and + /// accessed only through this protocol since. + /// + /// # Panics + /// + /// Panics when the region is not `u64`-aligned or its size is outside the + /// supported range (see [`MappedLayout::new`]). + pub unsafe fn new(mem: M) -> Self { + // SAFETY: forwarded from this function's contract, which keeps the + // region valid and protocol-governed for the writer's lifetime — + // and so for every use of the views, which are stored in and + // dropped with the writer. + let mapped = unsafe { MappedLayout::new(mem.as_raw_slice()) }; + Self { mem, mapped } + } + + /// Whether the CLOSED gate is set: the receiver closed the channel, + /// or an earlier failed claim condemned it. + pub fn is_closed(&self) -> bool { + self.mapped.header().claims.load(Ordering::Relaxed) & CLOSED != 0 + } + + /// Claims a frame of exactly `frame_size` bytes. + /// + /// The frame is invisible to the receiver until [`FrameMut::finish`] + /// commits it. Dropping the frame without finishing abandons the claim: + /// the receiver ignores the slot, exactly as if the writer had died. + /// Frames larger than `i32::MAX` bytes are refused as + /// [`ClaimError::Capacity`]. + /// + /// Wait-free: two `fetch_add`s, no retry loop (rule 1). A claim that + /// does not fit fails after setting the CLOSED gate: the receiver + /// learns a record was lost, and later claims are refused — their + /// records would ride a result the receiver must already reject. + pub fn claim_frame(&self, frame_size: NonZeroUsize) -> Result, ClaimError> { + let mapped = self.mapped; + let payload_len = frame_size.get(); + + // Reports that this claim's record was lost, before the writer + // moves on (rule 1): the gate makes the receiver report the + // channel incomplete, and condemns further claims. + let report_loss = || { + mapped.header().claims.fetch_or(CLOSED, Ordering::Relaxed); + ClaimError::Capacity + }; + + // No descriptor can describe a payload this long; refuse it before + // touching the counters, so the channel keeps working for every + // record after it. + if payload_len > layout::MAX_PAYLOAD_LEN { + return Err(report_loss()); + } + let reserved_len = layout::reserved_payload_len(payload_len); + + // Payload bytes first, so a payload-capacity failure does not burn a + // slot. A failed reservation stays counted — overshoot is harmless + // because the counter is not what locates payloads (descriptors are) + // and a `u64` cannot realistically wrap. + let payload_start = + mapped.header().payload_reserved.fetch_add(reserved_len as u64, Ordering::Relaxed); + // Checked: a foreign scribble of the counter must fail the claim, + // not wrap the bound into an out-of-bounds reservation. + let payload_end = payload_start.checked_add(reserved_len as u64); + if payload_end.is_none_or(|end| end > mapped.payloads.len() as u64) { + return Err(report_loss()); + } + let payload_start = usize::try_from(payload_start).expect("bounded by the payload region"); + + let claims = mapped.header().claims.fetch_add(1, Ordering::Relaxed); + if claims & CLOSED != 0 { + // Not a loss: a record refused after close describes an + // operation performed outside the channel's boundary. + return Err(ClaimError::Closed); + } + let slot_index = usize::try_from(claims).expect("claim count exceeds usize"); + if slot_index >= mapped.table().len() { + return Err(report_loss()); + } + + // SAFETY: the claim reserved + // `[payload_start, payload_start + reserved_len)` — inside the + // payload region by the capacity check above — exclusively for this + // frame: other writers reserve disjoint spans, and the receiver + // never reads a payload before observing its committed descriptor, + // which `finish` publishes only when it consumes this borrow. + let content = unsafe { + slice::from_raw_parts_mut(mapped.payloads.cast::().add(payload_start), payload_len) + }; + Ok(FrameMut { + mapped, + slot_index, + descriptor: layout::committed( + layout::payload_base(mapped.len) + payload_start, + payload_len, + ), + content, + }) + } + + // Unwrap `self` and return the underlying memory. + #[cfg(all(test, not(miri)))] + pub fn into_memory(self) -> M { + self.mem + } + + #[cfg(test)] + pub fn try_write_frame(&self, frame: &[u8]) -> bool { + let Some(frame_size) = NonZeroUsize::new(frame.len()) else { + return false; + }; + let Ok(mut frame_mut) = self.claim_frame(frame_size) else { + return false; + }; + frame_mut.copy_from_slice(frame); + frame_mut.finish(); + true + } +} + +/// An exclusively owned, claimed-but-unpublished frame. +/// +/// [`FrameMut::finish`] commits the frame; it is the only way to make the +/// payload visible to the receiver. Dropping the frame instead abandons +/// the claim: the slot stays unfinished and the receiver ignores it, +/// exactly as if the writer had died there. A writer that abandons a frame +/// and still performs the operation it described steps outside the usage +/// contract — records are published before the recorded operation. +pub struct FrameMut<'a> { + mapped: MappedLayout, + slot_index: usize, + descriptor: u64, + content: &'a mut [u8], +} + +impl fmt::Debug for FrameMut<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("FrameMut") + .field("slot_index", &self.slot_index) + .field("len", &self.content.len()) + .finish_non_exhaustive() + } +} + +impl Deref for FrameMut<'_> { + type Target = [u8]; + + fn deref(&self) -> &Self::Target { + self.content + } +} + +impl DerefMut for FrameMut<'_> { + fn deref_mut(&mut self) -> &mut Self::Target { + self.content + } +} + +impl FrameMut<'_> { + /// Commits the frame, making it visible to the receiver. + /// + /// If the receiver closed the channel and aborted this frame's slot + /// first, the swap fails and the frame is silently discarded: the + /// record belongs to the close race and is intentionally excluded + /// either way. + pub fn finish(self) { + // Rule 2: `Release` orders every payload write before the + // descriptor. + let _ = self.mapped.table()[self.slot_index].compare_exchange( + layout::UNFINISHED, + self.descriptor, + Ordering::Release, + Ordering::Relaxed, + ); + } +} From 8e18c62ea35c981f7d7454c8b3109ed8f52a96cd Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sat, 15 Aug 2026 17:53:24 +0800 Subject: [PATCH 36/92] refactor(fspy-shm): drop the stored mapping length MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MappedLayout kept the mapping length so decode and the writer's offset math could re-derive the payload bounds — a leftover from when the views were rebuilt from the mapping on every call and the length was the seed. The stored views already carry both bounds verbatim: the table's length is max_slots, so payload_base is one multiplication away, and the payload slice's length is payload_region_len itself. Re-sign validate and decode to take those bounds, let MappedLayout hand them over (mapped.decode(bits), mapped.payload_base()), and the third copy of the information disappears — along with Iter's mapping_len field and the 'not derivable from the parts above' caveat, which guarded a re-derivation nothing performs anymore. Co-Authored-By: Claude Fable 5 --- .../src/ipc/channel/shm_io/layout.rs | 89 ++++++++++++------- .../src/ipc/channel/shm_io/reader.rs | 17 ++-- .../src/ipc/channel/shm_io/writer.rs | 5 +- 3 files changed, 66 insertions(+), 45 deletions(-) diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs index 3ee34bdbd..9b8a34b16 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs @@ -124,10 +124,16 @@ pub(super) struct PayloadSpan { } impl PayloadSpan { - /// Validates a committed descriptor's payload range against the payload - /// region of a `mapping_len`-byte mapping. Returns `None` if the range - /// could not have been produced by a correct writer. - pub(super) const fn validate(mapping_len: usize, offset: usize, len: usize) -> Option { + /// Validates a committed descriptor's payload range against a payload + /// region starting at `payload_base` and `payload_region_len` bytes + /// long. Returns `None` if the range could not have been produced by a + /// correct writer. + pub(super) const fn validate( + payload_base: usize, + payload_region_len: usize, + offset: usize, + len: usize, + ) -> Option { if len == 0 || len > MAX_PAYLOAD_LEN { return None; } @@ -137,11 +143,10 @@ impl PayloadSpan { if !offset.is_multiple_of(SLOT_LEN) { return None; } - let base = payload_base(mapping_len); // `offset` and `len` come from 32-bit descriptor fields, so these // sums cannot overflow `usize`. - if offset < base - || offset + reserved_payload_len(len) > base + payload_region_len(mapping_len) + if offset < payload_base + || offset + reserved_payload_len(len) > payload_base + payload_region_len { return None; } @@ -191,9 +196,19 @@ pub(super) fn committed(payload_offset: usize, payload_len: usize) -> u64 { /// Decodes a slot value read back from shared memory as a committed /// descriptor. Returns `None` for any value that is not one a correct -/// writer could have committed for a `mapping_len`-byte mapping. -pub(super) const fn decode(mapping_len: usize, bits: u64) -> Option { - PayloadSpan::validate(mapping_len, (bits & OFFSET_MAX) as usize, (bits >> LEN_SHIFT) as usize) +/// writer could have committed for the payload region described by +/// `payload_base` and `payload_region_len`. +pub(super) const fn decode( + payload_base: usize, + payload_region_len: usize, + bits: u64, +) -> Option { + PayloadSpan::validate( + payload_base, + payload_region_len, + (bits & OFFSET_MAX) as usize, + (bits >> LEN_SHIFT) as usize, + ) } // --- The mapped layout and the ordering contract --------------------------- @@ -267,10 +282,6 @@ pub(super) struct MappedLayout { header: NonNull
, table: NonNull<[AtomicU64]>, pub(super) payloads: *mut [u8], - /// The real mapping length. Not derivable from the parts above: the - /// payload region rounds down to whole `u64`s, and re-deriving the - /// layout from a shortened length could shift the table boundary. - pub(super) len: usize, } impl MappedLayout { @@ -314,7 +325,6 @@ impl MappedLayout { base.add(payload_base(len)), payload_region_len(len), ), - len, } } } @@ -338,6 +348,17 @@ impl MappedLayout { pub(super) const fn base(&self) -> *const u8 { self.header.as_ptr().cast() } + + /// Byte offset where the payload region starts: right after the table. + pub(super) const fn payload_base(&self) -> usize { + HEADER_LEN + self.table().len() * SLOT_LEN + } + + /// Decodes a slot value from this mapping's descriptor table. See + /// [`decode`]. + pub(super) const fn decode(&self, bits: u64) -> Option { + decode(self.payload_base(), self.payloads.len(), bits) + } } #[cfg(test)] @@ -388,51 +409,55 @@ mod tests { let base = payload_base(mapping_len); let region = payload_region_len(mapping_len); // A `u64`-aligned span at the region start. - assert!(PayloadSpan::validate(mapping_len, base, 8).is_some()); + assert!(PayloadSpan::validate(base, region, base, 8).is_some()); // Exact end of the region, with padding inside it. - assert!(PayloadSpan::validate(mapping_len, base + region - 8, 5).is_some()); + assert!(PayloadSpan::validate(base, region, base + region - 8, 5).is_some()); // Zero length is never committed. - assert!(PayloadSpan::validate(mapping_len, base, 0).is_none()); + assert!(PayloadSpan::validate(base, region, base, 0).is_none()); // Padded length may not cross the end of the region. - assert!(PayloadSpan::validate(mapping_len, base + region - 8, 9).is_none()); + assert!(PayloadSpan::validate(base, region, base + region - 8, 9).is_none()); // Payloads may not reach into the descriptor table or header. - assert!(PayloadSpan::validate(mapping_len, base - 8, 8).is_none()); - assert!(PayloadSpan::validate(mapping_len, 0, 8).is_none()); + assert!(PayloadSpan::validate(base, region, base - 8, 8).is_none()); + assert!(PayloadSpan::validate(base, region, 0, 8).is_none()); // Unaligned offsets cannot come from a correct writer. - assert!(PayloadSpan::validate(mapping_len, base + 4, 4).is_none()); + assert!(PayloadSpan::validate(base, region, base + 4, 4).is_none()); // Oversized lengths are rejected before any arithmetic. - assert!(PayloadSpan::validate(mapping_len, base, MAX_PAYLOAD_LEN + 1).is_none()); + assert!(PayloadSpan::validate(base, region, base, MAX_PAYLOAD_LEN + 1).is_none()); } #[test] fn decode_roundtrips_committed_values() { let mapping_len = 1024; let base = payload_base(mapping_len); - let span = decode(mapping_len, committed(base, 5)).unwrap(); + let region = payload_region_len(mapping_len); + let span = decode(base, region, committed(base, 5)).unwrap(); assert!(span.offset == base && span.len == 5); // The extremes of the descriptor fields on the largest mapping: the // 31-bit length limit, and a span ending exactly at the region end. let mapping_len = MAX_MAPPING_LEN; let base = payload_base(mapping_len); - let span = decode(mapping_len, committed(base, MAX_PAYLOAD_LEN)).unwrap(); + let region = payload_region_len(mapping_len); + let span = decode(base, region, committed(base, MAX_PAYLOAD_LEN)).unwrap(); assert!(span.offset == base && span.len == MAX_PAYLOAD_LEN); - let last = base + payload_region_len(mapping_len) - 8; - let span = decode(mapping_len, committed(last, 8)).unwrap(); + let last = base + region - 8; + let span = decode(base, region, committed(last, 8)).unwrap(); assert!(span.offset == last && span.len == 8); } #[test] fn decode_rejects_values_no_writer_commits() { let mapping_len = 1024; + let base = payload_base(mapping_len); + let region = payload_region_len(mapping_len); // The non-committed slot states. - assert!(decode(mapping_len, UNFINISHED).is_none()); - assert!(decode(mapping_len, ABORTED).is_none()); + assert!(decode(base, region, UNFINISHED).is_none()); + assert!(decode(base, region, ABORTED).is_none()); // The aborted bit combined with other bits: the length field then // exceeds `MAX_PAYLOAD_LEN`. - assert!(decode(mapping_len, ABORTED | 1).is_none()); - assert!(decode(mapping_len, ABORTED | (1 << 62)).is_none()); + assert!(decode(base, region, ABORTED | 1).is_none()); + assert!(decode(base, region, ABORTED | (1 << 62)).is_none()); // A zero length with a nonzero offset. - assert!(decode(mapping_len, 42).is_none()); + assert!(decode(base, region, 42).is_none()); } } diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs index 32ce43e77..47b410f5d 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs @@ -140,7 +140,7 @@ impl ShmReader { } // Any other terminal value must be a committed descriptor // with a valid span; a foreign scribble fails the decode. - if layout::decode(mapped.len, bits).is_none() { + if mapped.decode(bits).is_none() { return Err(ProtocolError::CorruptDescriptor { slot_index }); } frames += 1; @@ -178,12 +178,10 @@ impl fmt::Debug for ShmReader { /// Iterator over a [`ShmReader`]'s committed frames, in claim order. pub struct Iter<'a> { - /// Base address of the mapping the descriptors' spans point into. - base: *const u8, + /// The layout the spans decode against and point into. + mapped: MappedLayout, /// The not-yet-visited part of the table's frozen prefix. table: &'a [AtomicU64], - /// Mapping length, which decoding validates descriptors against. - mapping_len: usize, /// Committed frames not yet yielded. remaining: usize, } @@ -202,7 +200,7 @@ impl<'a> Iterator for Iter<'a> { // `None` is an aborted slot: nothing was published. Corrupt // values cannot appear — `close` already failed the channel on // them — so every decoded span is one `close` validated. - let Some(span) = layout::decode(self.mapping_len, bits) else { + let Some(span) = self.mapped.decode(bits) else { continue; }; self.remaining -= 1; @@ -210,7 +208,9 @@ impl<'a> Iterator for Iter<'a> { // layout, and a committed span is immutable for the mapping's // lifetime (see the section comment above); the reader // borrowed for `'a` keeps the mapping alive and mapped. - return Some(unsafe { slice::from_raw_parts(self.base.add(span.offset), span.len) }); + return Some(unsafe { + slice::from_raw_parts(self.mapped.base().add(span.offset), span.len) + }); } None } @@ -226,9 +226,8 @@ impl<'a, M: AsRawSlice> IntoIterator for &'a ShmReader { fn into_iter(self) -> Iter<'a> { Iter { - base: self.mapped.base(), + mapped: self.mapped, table: &self.mapped.table()[..self.slot_count], - mapping_len: self.mapped.len, remaining: self.frames, } } diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs index 4e60a4636..4a9e69a4a 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs @@ -149,10 +149,7 @@ impl ShmWriter { Ok(FrameMut { mapped, slot_index, - descriptor: layout::committed( - layout::payload_base(mapped.len) + payload_start, - payload_len, - ), + descriptor: layout::committed(mapped.payload_base() + payload_start, payload_len), content, }) } From d449bc5c1591f1cf8e9e4d65122bc35eeb72d723 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sat, 15 Aug 2026 18:07:31 +0800 Subject: [PATCH 37/92] refactor(fspy-shm): support only regular region lengths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every branch in the sizing math served lengths nobody wants: the eight-slot floor and its cap exist for regions under ~576 bytes, the zero-payload case for under ~100, and the payload round-down for lengths that are not a multiple of 8 — degenerate shapes exercised only by the tests that verify the branches handling them. The protocol gets to choose its supported lengths, so choose regular ones: multiples of 8 between 1 KiB and 4 GiB, checked in one place (is_supported, asserted at attach, guarded by senders) and produced in one place (the channel rounds its requested capacity up via round_up_region_len). The sizing rules collapse to branchless arithmetic — the table is an eighth in one division, the payload region is one subtraction, and alignment holds by construction instead of by correction. Every length in real use produces a bit-identical layout; only sub-KiB and unaligned regions go from degraded to refused. Co-Authored-By: Claude Fable 5 --- crates/fspy_shared/src/ipc/channel/mod.rs | 3 + .../src/ipc/channel/shm_io/README.md | 5 +- .../src/ipc/channel/shm_io/layout.rs | 82 ++++++++++--------- .../fspy_shared/src/ipc/channel/shm_io/mod.rs | 19 ++++- 4 files changed, 67 insertions(+), 42 deletions(-) diff --git a/crates/fspy_shared/src/ipc/channel/mod.rs b/crates/fspy_shared/src/ipc/channel/mod.rs index 42fa3b2b8..2064cfbff 100644 --- a/crates/fspy_shared/src/ipc/channel/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/mod.rs @@ -43,6 +43,9 @@ pub struct ChannelConf { /// senders need no configuration beyond the `ChannelConf`. #[expect(clippy::missing_errors_doc, reason = "non-vt crate: cannot use vt_str/vt_path types")] pub fn channel(capacity: usize) -> io::Result<(ChannelConf, Receiver)> { + // The protocol supports multiple-of-8 region lengths from 1 KiB to + // 4 GiB; round the requested capacity up into the supported set. + let capacity = shm_io::round_up_region_len(capacity); let shm_c_path = os_c_string(shm_backing_path()?.as_os_str())?; let handle = fspy_shm::create(shm_c_path.as_c_str().as_thin(), capacity).map_err(shm_error_to_io)?; diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/README.md b/crates/fspy_shared/src/ipc/channel/shm_io/README.md index d28895442..0de7b0d49 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/README.md +++ b/crates/fspy_shared/src/ipc/channel/shm_io/README.md @@ -42,7 +42,10 @@ ever count up: The table has one 8-byte slot per frame — an eighth of the region. Every bound is derived from the mapping length alone, so the region is self-describing: writers and the receiver compute the same layout from the -size of the file they mapped, with nothing else to agree on. For a 4 GiB +size of the file they mapped, with nothing else to agree on. Supported +lengths are multiples of 8 bytes between 1 KiB and 4 GiB — the creator +rounds its requested size up into that set, so the layout rules never +meet a degenerate region. For a 4 GiB region that is ~67 million slots; the ~3.5 GiB payload region fits ~15–20 million records of a few hundred bytes, so payload space runs out first. diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs index 9b8a34b16..9948e9da0 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs @@ -62,21 +62,29 @@ pub(super) const MAX_PAYLOAD_LEN: usize = i32::MAX as usize; /// mapping must fit `u32` arithmetic. pub(super) const MAX_MAPPING_LEN: usize = 1 << 32; -/// The descriptor-table length of a `mapping_len`-byte region. +/// Minimum supported mapping size — a deliberate cutoff, not a derived +/// one: regions under a KiB are not worth a channel. It keeps every +/// supported region's shape regular (at least 15 slots and 840 payload +/// bytes), so the sizing rules below need no small-region special cases. +pub(super) const MIN_MAPPING_LEN: usize = 1024; + +/// Whether `len` is a supported mapping length: a multiple of the slot +/// size between [`MIN_MAPPING_LEN`] and [`MAX_MAPPING_LEN`]. Everything +/// below assumes a supported length. +pub(super) const fn is_supported(len: usize) -> bool { + len.is_multiple_of(SLOT_LEN) && len >= MIN_MAPPING_LEN && len <= MAX_MAPPING_LEN +} + +/// The descriptor-table length of a supported `mapping_len`-byte region. /// /// Both endpoints derive the layout from the mapping length alone, so the /// region is self-describing: no side channel has to agree on a table -/// size. An eighth of the space for descriptors (floored at eight slots so -/// tiny regions stay usable) is generous slack for typical record shapes — -/// one 8-byte descriptor per payload of a few hundred bytes — and the -/// region is sparse, so an oversized table costs address space, not -/// memory. +/// size. An eighth of the space beyond the header goes to descriptors — +/// generous slack for typical record shapes, one 8-byte descriptor per +/// payload of a few hundred bytes — and the region is sparse, so an +/// oversized table costs address space, not memory. pub(super) const fn max_slots(mapping_len: usize) -> usize { - let available = mapping_len - HEADER_LEN; - let len = available / 8; - let len = if len < 8 * SLOT_LEN { 8 * SLOT_LEN } else { len }; - let len = if len > available { available } else { len }; - len / SLOT_LEN + (mapping_len - HEADER_LEN) / (8 * SLOT_LEN) } /// Byte offset where the payload region starts: right after the table. @@ -96,16 +104,13 @@ pub(super) const fn reserved_payload_len(payload_len: usize) -> usize { payload_len.next_multiple_of(SLOT_LEN) } -/// Byte size of the payload region of a `mapping_len`-byte mapping. A -/// multiple of `size_of::()`, so a reservation of whole `u64`s inside -/// it never reaches past `mapping_len`. +/// Byte size of the payload region of a supported `mapping_len`-byte +/// mapping: everything after the table. A multiple of `size_of::()` +/// by construction — supported lengths, the header, and the table all +/// are — so a reservation of whole `u64`s inside it never reaches past +/// `mapping_len`. pub(super) const fn payload_region_len(mapping_len: usize) -> usize { - let base = payload_base(mapping_len); - if base >= mapping_len { - return 0; - } - let len = mapping_len - base; - len - len % SLOT_LEN + mapping_len - payload_base(mapping_len) } /// A validated payload byte range: the witness that offset arithmetic on this @@ -298,17 +303,16 @@ impl MappedLayout { /// # Panics /// /// Panics when the mapping cannot host the protocol at all: base not - /// `u64`-aligned, smaller than the header, or larger than - /// [`MAX_MAPPING_LEN`]. These indicate a broken caller, not - /// runtime data; senders guard untrusted mappings with - /// [`super::is_supported_region_len`] first. + /// `u64`-aligned, or a length that is not supported ([`is_supported`]). + /// These indicate a broken caller, not runtime data; senders guard + /// untrusted mappings with [`super::is_supported_region_len`] first. #[expect(clippy::cast_ptr_alignment, reason = "the base is asserted `u64`-aligned below")] pub(super) unsafe fn new(mem: *mut [u8]) -> Self { let base = mem.cast::(); let len = mem.len(); assert!(!base.is_null()); assert!(base.addr().is_multiple_of(align_of::
())); - assert!((HEADER_LEN..=MAX_MAPPING_LEN).contains(&len)); + assert!(is_supported(len)); // SAFETY: the base is non-null (asserted), and the header and the // table lie inside the mapping — the header by the asserts above, // the table by `max_slots` — at `u64`-aligned offsets. The @@ -384,23 +388,27 @@ mod tests { } #[test] - fn max_slots_floors_tiny_regions_at_eight_slots() { - assert!(max_slots(1024) == 15); - assert!(max_slots(256) == 8); - // Not enough space for the floor: the table takes what exists and - // payload capacity degrades to zero; claims fail gracefully. - assert!(max_slots(100) == 4); - assert!(max_slots(64) == 0); + fn unsupported_lengths_are_refused() { + assert!(is_supported(MIN_MAPPING_LEN)); + assert!(is_supported(MAX_MAPPING_LEN)); + // Too small, even as a multiple of 8. + assert!(!is_supported(1000)); + assert!(!is_supported(0)); + // Not a multiple of 8. + assert!(!is_supported(1025)); + // Too large. + assert!(!is_supported(MAX_MAPPING_LEN + 8)); } #[test] - fn payload_region_rounds_down_and_degrades_to_zero() { + fn payload_region_fills_the_rest() { + assert!(max_slots(1024) == 15); assert!(payload_base(1024) == 184); assert!(payload_region_len(1024) == 840); - assert!(payload_region_len(1000) == 824); - // The base at or past the mapping: no payload space at all. - assert!(payload_region_len(100) == 0); - assert!(payload_region_len(64) == 0); + // The three areas cover a supported region exactly. + assert!( + payload_base(MAX_MAPPING_LEN) + payload_region_len(MAX_MAPPING_LEN) == MAX_MAPPING_LEN + ); } #[test] diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs index 79b926b26..4f3d86fec 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs @@ -110,14 +110,25 @@ impl AsRawSlice for Mapping { } } -/// Whether a mapping of `len` bytes can host the protocol at all. +/// Whether a mapping of `len` bytes can host the protocol at all: a +/// multiple of 8 bytes, between 1 KiB and 4 GiB. /// /// Senders opening a file they do not control should refuse unsupported /// lengths with an error; the protocol's own constructors treat them as a -/// broken caller and panic. +/// broken caller and panic. Creators pick a supported length with +/// [`round_up_region_len`]. #[must_use] -pub fn is_supported_region_len(len: usize) -> bool { - (layout::HEADER_LEN..=layout::MAX_MAPPING_LEN).contains(&len) +pub const fn is_supported_region_len(len: usize) -> bool { + layout::is_supported(len) +} + +/// Rounds a desired region length up to the nearest supported one. +/// (Desired lengths beyond the 4 GiB maximum clamp down to it.) +#[must_use] +pub fn round_up_region_len(desired: usize) -> usize { + desired + .clamp(layout::MIN_MAPPING_LEN, layout::MAX_MAPPING_LEN) + .next_multiple_of(layout::SLOT_LEN) } /// Materializes the page backing the protocol header without changing From 1702a530041810721a0054d11aea2f64c0b9badd Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sat, 15 Aug 2026 18:20:25 +0800 Subject: [PATCH 38/92] refactor(fspy-shm): payload-relative descriptors; unshare the codec halves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Descriptors stored offsets measured from the start of the mapping while everything else already worked in payload coordinates: the counter hands out payload-relative offsets, the writer added payload_base only to encode, and the reader checked the base bound only to subtract it again. Encode what the counter returned: the conversions disappear, validate loses a parameter and its lower-bound check — in payload coordinates no bit pattern can name the header or the table, so the invalid states are unrepresentable rather than rejected — and MappedLayout's payload_base and base accessors go with them. The payloads view now carries both the location and the bound of the only area descriptors can point into. Also unshare layout.rs down to what both sides genuinely use: committed moves to the writer, decode/validate/PayloadSpan/ABORTED to the reader (with their tests), and layout keeps the format both sides agree on — the field layout, UNFINISHED, and the bit-field constants. Co-Authored-By: Claude Fable 5 --- .../src/ipc/channel/shm_io/README.md | 12 +- .../src/ipc/channel/shm_io/layout.rs | 205 +++--------------- .../fspy_shared/src/ipc/channel/shm_io/mod.rs | 2 +- .../src/ipc/channel/shm_io/reader.rs | 130 ++++++++++- .../src/ipc/channel/shm_io/writer.rs | 12 +- 5 files changed, 166 insertions(+), 195 deletions(-) diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/README.md b/crates/fspy_shared/src/ipc/channel/shm_io/README.md index 0de7b0d49..4df26a140 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/README.md +++ b/crates/fspy_shared/src/ipc/channel/shm_io/README.md @@ -182,12 +182,12 @@ CLAIMED (slot 0) ---+ ## Files -| File | Role | -| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `mod.rs` | Public surface (`ShmWriter`, `ShmReader`), the protocol overview docs, and the integration tests — they run against a mocked region and are miri-clean (`cargo miri test -p fspy_shared shm_io`). | -| `writer.rs` | The writer side: claim a frame, fill it, finish it. | -| `reader.rs` | The reader side: close the channel, then iterate the committed frames — with the argument for why its borrows are sound. | -| `layout.rs` | Everything both sides share: the region's shape and sizing math, the header, the descriptor codec, and `MappedLayout` — the shape bound to one concrete mapping. | +| File | Role | +| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `mod.rs` | Public surface (`ShmWriter`, `ShmReader`), the protocol overview docs, and the integration tests — they run against a mocked region and are miri-clean (`cargo miri test -p fspy_shared shm_io`). | +| `writer.rs` | The writer side: claim a frame, fill it, finish it. | +| `reader.rs` | The reader side: close the channel, then iterate the committed frames — with the argument for why its borrows are sound. | +| `layout.rs` | Only what both sides share: the region's shape and sizing math, the header, the descriptor format, and `MappedLayout` — the shape bound to one concrete mapping. Encoding lives with the writer, decoding with the reader. | Arrows point at what a file depends on: diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs index 9948e9da0..75015334f 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs @@ -58,8 +58,9 @@ pub(super) const MAX_PAYLOAD_LEN: usize = i32::MAX as usize; /// Maximum supported mapping size. /// -/// Payload offsets are stored in 32 bits, so every byte offset into the -/// mapping must fit `u32` arithmetic. +/// Descriptors store payload offsets in 32 bits, so the payload region +/// must fit `u32` arithmetic; capping the whole mapping at 4 GiB keeps +/// every offset and sum inside it. pub(super) const MAX_MAPPING_LEN: usize = 1 << 32; /// Minimum supported mapping size — a deliberate cutoff, not a derived @@ -83,23 +84,16 @@ pub(super) const fn is_supported(len: usize) -> bool { /// generous slack for typical record shapes, one 8-byte descriptor per /// payload of a few hundred bytes — and the region is sparse, so an /// oversized table costs address space, not memory. -pub(super) const fn max_slots(mapping_len: usize) -> usize { +pub(super) const fn table_slots(mapping_len: usize) -> usize { (mapping_len - HEADER_LEN) / (8 * SLOT_LEN) } -/// Byte offset where the payload region starts: right after the table. -/// `u64`-aligned. -pub(super) const fn payload_base(mapping_len: usize) -> usize { - HEADER_LEN + max_slots(mapping_len) * SLOT_LEN -} - /// Rounds a payload length up to a multiple of `size_of::()`. /// -/// Payload reservations are whole `u64`s — combined with the `u64`-aligned -/// region base they grow from, every payload offset stays `u64`-aligned, an -/// invariant [`PayloadSpan::validate`] uses to reject descriptors no correct -/// writer produces. The sub-`u64` padding stays inside the frame's own -/// reservation. +/// Payload reservations are whole `u64`s, so every payload offset stays +/// `u64`-aligned — an invariant [`PayloadSpan::validate`] uses to reject +/// descriptors no correct writer produces. The sub-`u64` padding stays +/// inside the frame's own reservation. pub(super) const fn reserved_payload_len(payload_len: usize) -> usize { payload_len.next_multiple_of(SLOT_LEN) } @@ -110,53 +104,7 @@ pub(super) const fn reserved_payload_len(payload_len: usize) -> usize { /// are — so a reservation of whole `u64`s inside it never reaches past /// `mapping_len`. pub(super) const fn payload_region_len(mapping_len: usize) -> usize { - mapping_len - payload_base(mapping_len) -} - -/// A validated payload byte range: the witness that offset arithmetic on this -/// span cannot leave the payload region. -/// -/// Constructing a `PayloadSpan` through [`PayloadSpan::validate`] is the -/// single validation point for descriptor metadata read back from shared -/// memory; code holding a span may rely on its bounds without re-checking. -#[derive(Clone, Copy, Debug)] -pub(super) struct PayloadSpan { - /// Byte offset of the payload from the start of the mapping. - /// Always `u64`-aligned. - pub(super) offset: usize, - /// Exact (unpadded) byte length of the payload. - pub(super) len: usize, -} - -impl PayloadSpan { - /// Validates a committed descriptor's payload range against a payload - /// region starting at `payload_base` and `payload_region_len` bytes - /// long. Returns `None` if the range could not have been produced by a - /// correct writer. - pub(super) const fn validate( - payload_base: usize, - payload_region_len: usize, - offset: usize, - len: usize, - ) -> Option { - if len == 0 || len > MAX_PAYLOAD_LEN { - return None; - } - // Writers reserve whole-`u64` spans from the `u64`-aligned region - // base, so a valid offset is `u64`-aligned and its padded length - // stays inside the region. - if !offset.is_multiple_of(SLOT_LEN) { - return None; - } - // `offset` and `len` come from 32-bit descriptor fields, so these - // sums cannot overflow `usize`. - if offset < payload_base - || offset + reserved_payload_len(len) > payload_base + payload_region_len - { - return None; - } - Some(Self { offset, len }) - } + mapping_len - HEADER_LEN - table_slots(mapping_len) * SLOT_LEN } // --- The descriptor slot codec --------------------------------------------- @@ -178,43 +126,16 @@ impl PayloadSpan { // committed value is always nonzero and the three states are disjoint. Once // a slot is committed or aborted, nothing ever changes it again. // -// The receiver never classifies invalid values: [`decode`] accepts exactly -// the committed descriptors a correct writer can produce for the mapping, -// and refuses everything else alike — an unfinished `0` decodes a zero -// length, and any value carrying the aborted bit decodes a length beyond -// [`MAX_PAYLOAD_LEN`], so both fail [`PayloadSpan::validate`]. +// Offsets are measured from the start of the payload area, so no +// descriptor can even name the header or the table. +// +// This defines the format both sides must agree on; encoding lives with +// the writer ([`super::writer`]) and decoding with the reader +// ([`super::reader`]). pub(super) const UNFINISHED: u64 = 0; -pub(super) const ABORTED: u64 = 1 << 63; -const LEN_SHIFT: u32 = 32; -const OFFSET_MAX: u64 = u32::MAX as u64; - -/// Encodes a committed descriptor. -/// -/// The caller guarantees `payload_len` is `1..=MAX_PAYLOAD_LEN` and -/// `payload_offset` fits 32 bits; both hold for any admitted reservation. -pub(super) fn committed(payload_offset: usize, payload_len: usize) -> u64 { - debug_assert!(payload_len > 0 && payload_len <= MAX_PAYLOAD_LEN); - debug_assert!(payload_offset as u64 <= OFFSET_MAX); - ((payload_len as u64) << LEN_SHIFT) | payload_offset as u64 -} - -/// Decodes a slot value read back from shared memory as a committed -/// descriptor. Returns `None` for any value that is not one a correct -/// writer could have committed for the payload region described by -/// `payload_base` and `payload_region_len`. -pub(super) const fn decode( - payload_base: usize, - payload_region_len: usize, - bits: u64, -) -> Option { - PayloadSpan::validate( - payload_base, - payload_region_len, - (bits & OFFSET_MAX) as usize, - (bits >> LEN_SHIFT) as usize, - ) -} +pub(super) const LEN_SHIFT: u32 = 32; +pub(super) const OFFSET_MAX: u64 = u32::MAX as u64; // --- The mapped layout and the ordering contract --------------------------- // `MappedLayout::new` builds three typed views of the region — the @@ -323,10 +244,10 @@ impl MappedLayout { header: NonNull::new_unchecked(base.cast::
()), table: NonNull::slice_from_raw_parts( NonNull::new_unchecked(base.add(HEADER_LEN).cast::()), - max_slots(len), + table_slots(len), ), payloads: std::ptr::slice_from_raw_parts_mut( - base.add(payload_base(len)), + base.add(HEADER_LEN + table_slots(len) * SLOT_LEN), payload_region_len(len), ), } @@ -347,22 +268,6 @@ impl MappedLayout { // SAFETY: as for `header`. unsafe { self.table.as_ref() } } - - /// Base address of the region: the header sits at offset zero. - pub(super) const fn base(&self) -> *const u8 { - self.header.as_ptr().cast() - } - - /// Byte offset where the payload region starts: right after the table. - pub(super) const fn payload_base(&self) -> usize { - HEADER_LEN + self.table().len() * SLOT_LEN - } - - /// Decodes a slot value from this mapping's descriptor table. See - /// [`decode`]. - pub(super) const fn decode(&self, bits: u64) -> Option { - decode(self.payload_base(), self.payloads.len(), bits) - } } #[cfg(test)] @@ -381,10 +286,10 @@ mod tests { } #[test] - fn max_slots_gives_an_eighth_to_the_table() { - assert!(max_slots(1 << 20) == 16383); + fn table_gets_an_eighth_of_the_region() { + assert!(table_slots(1 << 20) == 16383); // The 4 GiB production mapping: ~67M slots. - assert!(max_slots(MAX_MAPPING_LEN) == ((MAX_MAPPING_LEN - HEADER_LEN) / 8) / 8); + assert!(table_slots(MAX_MAPPING_LEN) == ((MAX_MAPPING_LEN - HEADER_LEN) / 8) / 8); } #[test] @@ -402,70 +307,14 @@ mod tests { #[test] fn payload_region_fills_the_rest() { - assert!(max_slots(1024) == 15); - assert!(payload_base(1024) == 184); + assert!(table_slots(1024) == 15); assert!(payload_region_len(1024) == 840); // The three areas cover a supported region exactly. assert!( - payload_base(MAX_MAPPING_LEN) + payload_region_len(MAX_MAPPING_LEN) == MAX_MAPPING_LEN + HEADER_LEN + + table_slots(MAX_MAPPING_LEN) * SLOT_LEN + + payload_region_len(MAX_MAPPING_LEN) + == MAX_MAPPING_LEN ); } - - #[test] - fn payload_span_validates_bounds() { - let mapping_len = 1024; - let base = payload_base(mapping_len); - let region = payload_region_len(mapping_len); - // A `u64`-aligned span at the region start. - assert!(PayloadSpan::validate(base, region, base, 8).is_some()); - // Exact end of the region, with padding inside it. - assert!(PayloadSpan::validate(base, region, base + region - 8, 5).is_some()); - // Zero length is never committed. - assert!(PayloadSpan::validate(base, region, base, 0).is_none()); - // Padded length may not cross the end of the region. - assert!(PayloadSpan::validate(base, region, base + region - 8, 9).is_none()); - // Payloads may not reach into the descriptor table or header. - assert!(PayloadSpan::validate(base, region, base - 8, 8).is_none()); - assert!(PayloadSpan::validate(base, region, 0, 8).is_none()); - // Unaligned offsets cannot come from a correct writer. - assert!(PayloadSpan::validate(base, region, base + 4, 4).is_none()); - // Oversized lengths are rejected before any arithmetic. - assert!(PayloadSpan::validate(base, region, base, MAX_PAYLOAD_LEN + 1).is_none()); - } - - #[test] - fn decode_roundtrips_committed_values() { - let mapping_len = 1024; - let base = payload_base(mapping_len); - let region = payload_region_len(mapping_len); - let span = decode(base, region, committed(base, 5)).unwrap(); - assert!(span.offset == base && span.len == 5); - - // The extremes of the descriptor fields on the largest mapping: the - // 31-bit length limit, and a span ending exactly at the region end. - let mapping_len = MAX_MAPPING_LEN; - let base = payload_base(mapping_len); - let region = payload_region_len(mapping_len); - let span = decode(base, region, committed(base, MAX_PAYLOAD_LEN)).unwrap(); - assert!(span.offset == base && span.len == MAX_PAYLOAD_LEN); - let last = base + region - 8; - let span = decode(base, region, committed(last, 8)).unwrap(); - assert!(span.offset == last && span.len == 8); - } - - #[test] - fn decode_rejects_values_no_writer_commits() { - let mapping_len = 1024; - let base = payload_base(mapping_len); - let region = payload_region_len(mapping_len); - // The non-committed slot states. - assert!(decode(base, region, UNFINISHED).is_none()); - assert!(decode(base, region, ABORTED).is_none()); - // The aborted bit combined with other bits: the length field then - // exceeds `MAX_PAYLOAD_LEN`. - assert!(decode(base, region, ABORTED | 1).is_none()); - assert!(decode(base, region, ABORTED | (1 << 62)).is_none()); - // A zero length with a nonzero offset. - assert!(decode(base, region, 42).is_none()); - } } diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs index 4f3d86fec..e6605b81e 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs @@ -623,7 +623,7 @@ mod tests { let writer = unsafe { ShmWriter::new(shm.clone()) }; assert!(writer.try_write_frame(b"hello")); - // Point slot 0 at a span escaping the mapping. + // Point slot 0 at a span escaping the payload region. let bogus_len = 8u64; let bogus_offset = 1020u64; shm.poke_u64(64, (bogus_len << 32) | bogus_offset); diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs index 47b410f5d..16467c97a 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs @@ -22,6 +22,62 @@ use super::{ layout::{self, CLOSED, MappedLayout}, }; +/// The terminal value the freeze pass installs in an unfinished slot: the +/// aborted bit of the slot codec ([`layout`]). +const ABORTED: u64 = 1 << 63; + +/// A validated payload byte range: the witness that offset arithmetic on this +/// span cannot leave the payload region. +/// +/// Constructing a `PayloadSpan` through [`PayloadSpan::validate`] is the +/// single validation point for descriptor metadata read back from shared +/// memory; code holding a span may rely on its bounds without re-checking. +#[derive(Clone, Copy, Debug)] +struct PayloadSpan { + /// Byte offset of the payload from the start of the payload region. + /// Always `u64`-aligned. + offset: usize, + /// Exact (unpadded) byte length of the payload. + len: usize, +} + +impl PayloadSpan { + /// Validates a committed descriptor's payload range against a payload + /// region of `payload_region_len` bytes. Returns `None` if the range + /// could not have been produced by a correct writer. + const fn validate(payload_region_len: usize, offset: usize, len: usize) -> Option { + if len == 0 || len > layout::MAX_PAYLOAD_LEN { + return None; + } + // Writers reserve whole-`u64` spans from the start of the region, + // so a valid offset is `u64`-aligned and its padded length stays + // inside the region. + if !offset.is_multiple_of(layout::SLOT_LEN) { + return None; + } + // `offset` and `len` come from 32-bit descriptor fields, so this + // sum cannot overflow `usize`. + if offset + layout::reserved_payload_len(len) > payload_region_len { + return None; + } + Some(Self { offset, len }) + } +} + +/// Decodes a slot value read back from shared memory as a committed +/// descriptor (the slot codec in [`layout`]). Returns `None` for any value +/// that is not one a correct writer could have committed for a payload +/// region of `payload_region_len` bytes: an unfinished `0` decodes a zero +/// length, and any value carrying the aborted bit decodes a length beyond +/// the frame limit, so both fail validation exactly like a scribble. +const fn decode(payload_region_len: usize, bits: u64) -> Option { + PayloadSpan::validate( + payload_region_len, + (bits & layout::OFFSET_MAX) as usize, + (bits >> layout::LEN_SHIFT) as usize, + ) +} + /// Shared-memory metadata that could not have been produced by this /// protocol. The region was corrupted; its frames are unusable. #[derive(thiserror::Error, Clone, Copy, PartialEq, Eq, Debug)] @@ -125,7 +181,7 @@ impl ShmReader { // visible. let Err(bits) = mapped.table()[slot_index].compare_exchange( layout::UNFINISHED, - layout::ABORTED, + ABORTED, Ordering::AcqRel, Ordering::Acquire, ) else { @@ -133,14 +189,14 @@ impl ShmReader { // aborted and stays ignored. continue; }; - if bits == layout::ABORTED { + if bits == ABORTED { // Aborted by an earlier close over the same region; // still ignored. continue; } // Any other terminal value must be a committed descriptor // with a valid span; a foreign scribble fails the decode. - if mapped.decode(bits).is_none() { + if decode(mapped.payloads.len(), bits).is_none() { return Err(ProtocolError::CorruptDescriptor { slot_index }); } frames += 1; @@ -200,16 +256,19 @@ impl<'a> Iterator for Iter<'a> { // `None` is an aborted slot: nothing was published. Corrupt // values cannot appear — `close` already failed the channel on // them — so every decoded span is one `close` validated. - let Some(span) = self.mapped.decode(bits) else { + let Some(span) = decode(self.mapped.payloads.len(), bits) else { continue; }; self.remaining -= 1; - // SAFETY: `close` validated the span against the mapping's - // layout, and a committed span is immutable for the mapping's - // lifetime (see the section comment above); the reader - // borrowed for `'a` keeps the mapping alive and mapped. + // SAFETY: `close` validated the span against the payload + // region, and a committed span is immutable for the mapping's + // lifetime (see the module docs above); the reader borrowed + // for `'a` keeps the mapping alive and mapped. return Some(unsafe { - slice::from_raw_parts(self.mapped.base().add(span.offset), span.len) + slice::from_raw_parts( + self.mapped.payloads.cast::().cast_const().add(span.offset), + span.len, + ) }); } None @@ -232,3 +291,56 @@ impl<'a, M: AsRawSlice> IntoIterator for &'a ShmReader { } } } + +#[cfg(test)] +mod tests { + use assert2::assert; + + use super::{super::writer::committed, *}; + + #[test] + fn payload_span_validates_bounds() { + let region = layout::payload_region_len(1024); + // A `u64`-aligned span at the region start. + assert!(PayloadSpan::validate(region, 0, 8).is_some()); + // Exact end of the region, with padding inside it. + assert!(PayloadSpan::validate(region, region - 8, 5).is_some()); + // Zero length is never committed. + assert!(PayloadSpan::validate(region, 0, 0).is_none()); + // Padded length may not cross the end of the region. + assert!(PayloadSpan::validate(region, region - 8, 9).is_none()); + // Unaligned offsets cannot come from a correct writer. + assert!(PayloadSpan::validate(region, 4, 4).is_none()); + // Oversized lengths are rejected before any arithmetic. + assert!(PayloadSpan::validate(region, 0, layout::MAX_PAYLOAD_LEN + 1).is_none()); + } + + #[test] + fn decode_roundtrips_committed_values() { + let region = layout::payload_region_len(1024); + let span = decode(region, committed(0, 5)).unwrap(); + assert!(span.offset == 0 && span.len == 5); + + // The extremes of the descriptor fields on the largest mapping: the + // 31-bit length limit, and a span ending exactly at the region end. + let region = layout::payload_region_len(layout::MAX_MAPPING_LEN); + let span = decode(region, committed(0, layout::MAX_PAYLOAD_LEN)).unwrap(); + assert!(span.offset == 0 && span.len == layout::MAX_PAYLOAD_LEN); + let span = decode(region, committed(region - 8, 8)).unwrap(); + assert!(span.offset == region - 8 && span.len == 8); + } + + #[test] + fn decode_rejects_values_no_writer_commits() { + let region = layout::payload_region_len(1024); + // The non-committed slot states. + assert!(decode(region, layout::UNFINISHED).is_none()); + assert!(decode(region, ABORTED).is_none()); + // The aborted bit combined with other bits: the length field then + // exceeds the frame limit. + assert!(decode(region, ABORTED | 1).is_none()); + assert!(decode(region, ABORTED | (1 << 62)).is_none()); + // A zero length with a nonzero offset. + assert!(decode(region, 42).is_none()); + } +} diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs index 4a9e69a4a..97058a6f5 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs @@ -149,7 +149,7 @@ impl ShmWriter { Ok(FrameMut { mapped, slot_index, - descriptor: layout::committed(mapped.payload_base() + payload_start, payload_len), + descriptor: committed(payload_start, payload_len), content, }) } @@ -230,3 +230,13 @@ impl FrameMut<'_> { ); } } + +/// Encodes a committed descriptor (the slot codec in [`layout`]). +/// +/// The caller guarantees `payload_len` is `1..=MAX_PAYLOAD_LEN` and +/// `payload_offset` fits 32 bits; both hold for any admitted reservation. +pub(super) fn committed(payload_offset: usize, payload_len: usize) -> u64 { + debug_assert!(payload_len > 0 && payload_len <= layout::MAX_PAYLOAD_LEN); + debug_assert!(payload_offset as u64 <= layout::OFFSET_MAX); + ((payload_len as u64) << layout::LEN_SHIFT) | payload_offset as u64 +} From f1127ed0bfbde07e4adc3876942723852d75d96a Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sat, 15 Aug 2026 18:27:47 +0800 Subject: [PATCH 39/92] refactor(fspy-shm): rename the reader's constructor to seal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'close' pulled double duty: the receiver's one-shot operation and the gate state any failed claim can set. Since the gate gained its second trigger the words have named different things, so split the vocabulary: 'seal' is the act — ShmReader::seal snapshots, gates, freezes, and returns the reader — while 'closed' and the CLOSED bit stay the state, reachable by seal or by an overflowing claim. It also sheds close's end-of-lifetime connotation on what is, for the reader, a constructor. The channel layer's Receiver::close keeps its name: at that API the receiver closing the channel is exactly what happens. Co-Authored-By: Claude Fable 5 --- crates/fspy_shared/src/ipc/channel/mod.rs | 2 +- .../src/ipc/channel/shm_io/README.md | 20 +++++----- .../src/ipc/channel/shm_io/layout.rs | 12 +++--- .../fspy_shared/src/ipc/channel/shm_io/mod.rs | 38 +++++++++---------- .../src/ipc/channel/shm_io/reader.rs | 24 ++++++------ .../src/ipc/channel/shm_io/writer.rs | 10 ++--- 6 files changed, 53 insertions(+), 53 deletions(-) diff --git a/crates/fspy_shared/src/ipc/channel/mod.rs b/crates/fspy_shared/src/ipc/channel/mod.rs index 2064cfbff..1f2525fe8 100644 --- a/crates/fspy_shared/src/ipc/channel/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/mod.rs @@ -287,7 +287,7 @@ impl Receiver { // SAFETY: `mapping` was created zero-initialized by `channel`, its // address is stable and independently owned, and all attached // processes access it only through the `shm_io` protocol. - unsafe { ShmReader::close(mapping) } + unsafe { ShmReader::seal(mapping) } .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err)) } } diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/README.md b/crates/fspy_shared/src/ipc/channel/shm_io/README.md index 4df26a140..c9fd1fd0e 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/README.md +++ b/crates/fspy_shared/src/ipc/channel/shm_io/README.md @@ -8,7 +8,7 @@ Three requirements shaped everything here: 1. **A writer may die at any instruction** — killed, crashed, anywhere. This must never corrupt the channel or lose another writer's records. 2. **A writer may outlive the channel.** The receiver must never wait for - writers; closing is immediate. + writers; sealing is immediate. 3. **The receiver must know whether it got everything.** Either the frames hold every record writers published, or they are flagged incomplete. Never a silently short result. @@ -82,7 +82,7 @@ runs is the heart of the design: These rules assume one thing about how the channel is used: **a writer publishes a record before performing the action the record describes.** Then a dead writer's missing record describes an action that never -happened, and a record refused after close describes an action performed +happened, and a record refused after the seal describes an action performed after the channel closed — both safe to ignore. A writer that records _after_ acting, or that abandons a frame and performs the action anyway, steps outside this rule and loses records silently. @@ -96,14 +96,14 @@ The writer skips that one record and carries on: recording must never stop or crash the program doing the work. The loss is not silent. Before moving on, the failed claim sets the -CLOSED gate — the same bit the receiver sets when it closes. When the -receiver closes the channel it reads the bit once; if it was already +CLOSED gate — the same bit the receiver sets when it seals. When the +receiver seals the channel it reads the bit once; if it was already set, `is_complete` returns false, and a reader that needs the full picture knows to throw the result away. Setting the bit before moving on matters for the same reason committing a record before acting does. If the receiver's read misses the bit, the -bit was set after close — so the skipped record describes an action +bit was set after the seal — so the skipped record describes an action performed after the channel closed, which the receiver never promised to include. And a writer that dies before setting the bit never performed its action, so nothing was actually lost. @@ -117,9 +117,9 @@ One more limit: a single frame holds at most 2 GiB, because a descriptor cannot describe more. Such a claim is refused — and reported — the same way. -## Closing and reading +## Sealing and reading -The receiver closes once: +The receiver seals the channel once: 1. **Snapshot** the claim counter with a plain load. This is the boundary: claims at or before it are in, later ones are not. @@ -170,7 +170,7 @@ CLAIMED (slot 0) ---+ ## Performance notes - Claiming is two atomic adds; committing is one CAS. Nothing retries. -- Closing costs one pass over the claimed slots. Nothing is copied and +- Sealing costs one pass over the claimed slots. Nothing is copied and nothing is allocated — the whole module is allocation-free; the reader re-reads the frozen table to iterate. - On Linux, the first touch of the sparse backing file can cost @@ -186,7 +186,7 @@ CLAIMED (slot 0) ---+ | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `mod.rs` | Public surface (`ShmWriter`, `ShmReader`), the protocol overview docs, and the integration tests — they run against a mocked region and are miri-clean (`cargo miri test -p fspy_shared shm_io`). | | `writer.rs` | The writer side: claim a frame, fill it, finish it. | -| `reader.rs` | The reader side: close the channel, then iterate the committed frames — with the argument for why its borrows are sound. | +| `reader.rs` | The reader side: seal the channel, then iterate the committed frames — with the argument for why its borrows are sound. | | `layout.rs` | Only what both sides share: the region's shape and sizing math, the header, the descriptor format, and `MappedLayout` — the shape bound to one concrete mapping. Encoding lives with the writer, decoding with the reader. | Arrows point at what a file depends on: @@ -194,7 +194,7 @@ Arrows point at what a file depends on: ```mermaid graph TD mod["mod.rs
public surface"] --> writer["writer.rs
claim, fill, finish"] - mod --> reader["reader.rs
close and iterate"] + mod --> reader["reader.rs
seal and iterate"] writer --> layout["layout.rs
what both sides share"] reader --> layout ``` diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs index 75015334f..3729f33fe 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs @@ -20,9 +20,9 @@ use std::{ptr::NonNull, sync::atomic::AtomicU64}; -/// The CLOSED gate bit of the claim counter, set by the receiver when it -/// closes the channel and by any writer whose claim failed — the loss -/// report that also condemns the channel (the parent module's rule 1). +/// The CLOSED gate bit of the claim counter, set when the receiver seals +/// the channel and by any writer whose claim failed — the loss report +/// that also condemns the channel (rule 1 below). /// The low 63 bits count claims, so no realistic claim volume can carry /// into the gate. pub(super) const CLOSED: u64 = 1 << 63; @@ -155,7 +155,7 @@ pub(super) const OFFSET_MAX: u64 = u32::MAX as u64; // claims ever attempted. Claiming is one wait-free `fetch_add`; the // returned old value carries the claim's slot index, the gate, and — by // comparison against the fixed table capacity — the capacity verdict. -// The gate is set by the receiver at close and by every failed claim: +// The gate is set by the receiver's seal and by every failed claim: // one bit is both the loss report completeness derives from and the // valve that stops writers spending work on a channel whose result the // receiver must already reject. @@ -173,7 +173,7 @@ pub(super) const OFFSET_MAX: u64 = u32::MAX as u64; // // Three synchronization rules cover the whole protocol: // -// 1. **Claim versus close** — the receiver's close boundary is a plain +// 1. **Claim versus seal** — the receiver's seal boundary is a plain // snapshot load of the claim counter: claims ordered at or before the // value it reads (in the counter's modification order) are in the // snapshot; later ones receive slot indices the receiver never visits. @@ -194,7 +194,7 @@ pub(super) const OFFSET_MAX: u64 = u32::MAX as u64; // uses `Release`: every payload write happens-before the committed // descriptor becomes visible. // 3. **Receiver observation** — the freeze compare-and-swap in -// `ShmReader::close` uses `Acquire` on failure: observing a committed +// `ShmReader::seal` uses `Acquire` on failure: observing a committed // descriptor also makes the payload writes it published visible, so the // borrows `ShmReader` later hands out read settled bytes. diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs index e6605b81e..cdd24b38e 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs @@ -52,19 +52,19 @@ //! and disjoint from everything a live writer may still touch (see the //! receiver section's trust argument below). //! -//! # Close boundary +//! # Seal boundary //! -//! [`ShmReader::close`]'s boundary is a snapshot of the claim counter. +//! [`ShmReader::seal`]'s boundary is a snapshot of the claim counter. //! A writer admitted before the snapshot races the freeze pass per slot and //! its frame is either included (commit won) or ignored (abort won) — never //! torn; a claim after the snapshot lands in a slot the receiver never -//! visits and is dropped, and the CLOSED gate set before close returns +//! visits and is dropped, and the CLOSED gate set before the seal returns //! stops stragglers from claiming (and materializing pages) forever. Both //! drops are sound because writers publish a record *before* performing the //! recorded operation: a process that died mid-frame never performed the //! operation, and one that claimed or committed after the snapshot performs -//! it outside the channel's boundary. A record refused *before* close — a -//! full region, an oversized frame — sets the CLOSED gate first, so the +//! it outside the channel's boundary. A record refused *before* the seal — +//! a full region, an oversized frame — sets the CLOSED gate first, so the //! channel reports itself incomplete ([`ShmReader::is_complete`]) and //! refuses every later claim: once one record is lost the receiver must //! reject the result, and further records would be wasted work. @@ -133,7 +133,7 @@ pub fn round_up_region_len(desired: usize) -> usize { /// Materializes the page backing the protocol header without changing /// protocol state, so that neither a writer's first claim nor -/// [`ShmReader::close`]'s snapshot pays for the backing file's first +/// [`ShmReader::seal`]'s snapshot pays for the backing file's first /// block allocation — a millisecond-scale cost on some journalling /// filesystems, for reads of holes as well as writes. Run it off any /// latency-sensitive path. Only Linux channels use this: elsewhere the @@ -230,7 +230,7 @@ mod tests { fn collect_frames(shm: &MockedShm) -> ShmReader { // SAFETY: `MockedShm` provides a stable, zero-initialized allocation // accessed only through the protocol. - unsafe { ShmReader::close(shm.clone()) }.unwrap() + unsafe { ShmReader::seal(shm.clone()) }.unwrap() } #[test] @@ -456,7 +456,7 @@ mod tests { } #[test] - fn claims_after_close_are_gated_without_poisoning() { + fn claims_after_seal_are_gated_without_poisoning() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. let writer = unsafe { ShmWriter::new(shm.clone()) }; @@ -469,9 +469,9 @@ mod tests { assert!(iter.next() == None); assert!(frames.is_complete()); - // Close set the gate: a straggler's claim fails cleanly and does - // not mark the channel incomplete — the operation is outside the - // closed boundary. + // The seal set the gate: a straggler's claim fails cleanly and + // does not mark the channel incomplete — the operation is outside + // the sealed boundary. assert!(writer.is_closed()); assert!(writer.claim_frame(5.try_into().unwrap()).unwrap_err() == ClaimError::Closed); assert!(frames.is_complete()); @@ -494,8 +494,8 @@ mod tests { // The late commit loses the race silently. frame.finish(); - // A second close still reads no frames — and reports incomplete: - // the gate was set by the first close, and a re-close cannot vouch + // A second seal still reads no frames — and reports incomplete: + // the gate was set by the first seal, and a re-seal cannot vouch // for records refused since then. let frames = collect_frames(&shm); assert!(frames.iter().count() == 0); @@ -566,7 +566,7 @@ mod tests { } #[test] - fn close_races_with_active_writers() { + fn seal_races_with_active_writers() { let shm = MockedShm::alloc(1024 * 64); let barrier = Barrier::new(3); @@ -577,7 +577,7 @@ mod tests { let writer = unsafe { ShmWriter::new(shm.clone()) }; barrier.wait(); let mut written = 0usize; - // Bounded so the test terminates even if close is slow; + // Bounded so the test terminates even if the seal is slow; // the region is large enough that capacity never fails. for _ in 0..200 { match writer.claim_frame(5.try_into().unwrap()) { @@ -629,7 +629,7 @@ mod tests { shm.poke_u64(64, (bogus_len << 32) | bogus_offset); // SAFETY: see `collect_frames`. - let result = unsafe { ShmReader::close(shm) }; + let result = unsafe { ShmReader::seal(shm) }; assert!(result.unwrap_err() == ProtocolError::CorruptDescriptor { slot_index: 0 }); } @@ -645,7 +645,7 @@ mod tests { shm.poke_u64(64, (1 << 63) | (8u64 << 32) | 8); // SAFETY: see `collect_frames`. - let result = unsafe { ShmReader::close(shm) }; + let result = unsafe { ShmReader::seal(shm) }; assert!(result.unwrap_err() == ProtocolError::CorruptDescriptor { slot_index: 0 }); } @@ -749,7 +749,7 @@ mod tests { // SAFETY: the mapping is a valid shared-memory region created zeroed // and accessed only through the protocol. - let frames = unsafe { ShmReader::close(mapping) }.unwrap(); + let frames = unsafe { ShmReader::seal(mapping) }.unwrap(); assert!(frames.is_complete()); let collected = frames.iter().map(BStr::new).collect::>(); assert!(collected.len() == CHILD_COUNT * FRAME_COUNT_EACH_CHILD); @@ -817,7 +817,7 @@ mod tests { assert!(writer.try_write_frame(b"alive")); // SAFETY: see `real_shm_across_processes`. - let frames = unsafe { ShmReader::close(writer.into_memory()) }.unwrap(); + let frames = unsafe { ShmReader::seal(writer.into_memory()) }.unwrap(); let mut iter = frames.iter(); assert!(iter.next().unwrap() == b"alive"); assert!(iter.next() == None); diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs index 16467c97a..166fe4b10 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs @@ -1,4 +1,4 @@ -//! The reader side: close the channel, then iterate the committed frames. +//! The reader side: seal the channel, then iterate the committed frames. //! //! //! Closing never waits for writers, and no payload byte is read or copied: @@ -86,7 +86,7 @@ pub enum ProtocolError { CorruptDescriptor { slot_index: usize }, } -/// A reader over the committed frames of a closed channel, serving them +/// A reader over the committed frames of a sealed channel, serving them /// straight out of the mapping, which stays alive inside this value and /// is released when the reader drops. It holds no buffer: iteration /// re-reads the frozen descriptor table, so closing allocates nothing. @@ -111,7 +111,7 @@ unsafe impl Send for ShmReader {} unsafe impl Sync for ShmReader {} impl ShmReader { - /// Closes the channel over a shared-memory region and returns the + /// Seals the channel — no further records — and returns the /// reader of its committed frames. /// /// Never blocks on writers: writers admitted before the snapshot race @@ -140,7 +140,7 @@ impl ShmReader { /// /// Panics when the region is not `u64`-aligned or its size is outside /// the supported range (see [`MappedLayout::new`]). - pub unsafe fn close(mem: M) -> Result { + pub unsafe fn seal(mem: M) -> Result { // SAFETY: forwarded from this function's contract, which keeps the // region valid for the reader's lifetime — and so for every use of // the views, which are stored in and dropped with the reader. @@ -149,7 +149,7 @@ impl ShmReader { let mut frames = 0; let complete; { - // The close boundary (rule 1): claims at or before this + // The seal boundary (rule 1): claims at or before this // snapshot are inside it, later ones land in slots this pass // never visits. The count is clamped to the table capacity, so // a counter inflated by failed claims (or by a foreign @@ -159,7 +159,7 @@ impl ShmReader { usize::try_from(claims & !CLOSED).unwrap_or(usize::MAX).min(mapped.table().len()); // The same load carries the completeness verdict: a gate set // before this boundary is a failed claim's loss report — or an - // earlier close, and a re-close cannot vouch for records + // earlier seal, and a re-seal cannot vouch for records // refused since then (rule 1). complete = claims & CLOSED == 0; @@ -190,7 +190,7 @@ impl ShmReader { continue; }; if bits == ABORTED { - // Aborted by an earlier close over the same region; + // Aborted by an earlier seal over the same region; // still ignored. continue; } @@ -213,7 +213,7 @@ impl ShmReader { /// Whether every record a writer published made it in. /// - /// False when a claim failed before the channel closed — the region + /// False when a claim failed before the seal — the region /// was out of space, or a frame exceeded the frame limit: its record /// was lost, and the frames under-report what writers went on to do. /// Consumers that need completeness must reject them. @@ -248,19 +248,19 @@ impl<'a> Iterator for Iter<'a> { fn next(&mut self) -> Option { while let Some((slot, rest)) = self.table.split_first() { self.table = rest; - // The slot is terminal (`close` froze it), so this plain load + // The slot is terminal (`seal` froze it), so this plain load // reads the same value the freeze pass saw; the transfer that // carried the reader to this thread carried the freeze pass's // `Acquire` payload visibility with it (rule 3). let bits = slot.load(Ordering::Relaxed); // `None` is an aborted slot: nothing was published. Corrupt - // values cannot appear — `close` already failed the channel on - // them — so every decoded span is one `close` validated. + // values cannot appear — `seal` already failed the channel on + // them — so every decoded span is one `seal` validated. let Some(span) = decode(self.mapped.payloads.len(), bits) else { continue; }; self.remaining -= 1; - // SAFETY: `close` validated the span against the payload + // SAFETY: `seal` validated the span against the payload // region, and a committed span is immutable for the mapping's // lifetime (see the module docs above); the reader borrowed // for `'a` keeps the mapping alive and mapped. diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs index 97058a6f5..1195fa10e 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs @@ -37,7 +37,7 @@ unsafe impl Sync for ShmWriter {} /// Why a frame could not be claimed. #[derive(thiserror::Error, Clone, Copy, PartialEq, Eq, Debug)] pub enum ClaimError { - /// The CLOSED gate was set: the receiver closed the channel, or an + /// The CLOSED gate was set: the receiver sealed the channel, or an /// earlier failed claim condemned it. Skipping the record is sound /// either way — it is outside the receiver's boundary, or the same /// bit already makes the receiver report the channel incomplete. @@ -74,7 +74,7 @@ impl ShmWriter { Self { mem, mapped } } - /// Whether the CLOSED gate is set: the receiver closed the channel, + /// Whether the CLOSED gate is set: the receiver sealed the channel, /// or an earlier failed claim condemned it. pub fn is_closed(&self) -> bool { self.mapped.header().claims.load(Ordering::Relaxed) & CLOSED != 0 @@ -128,7 +128,7 @@ impl ShmWriter { let claims = mapped.header().claims.fetch_add(1, Ordering::Relaxed); if claims & CLOSED != 0 { - // Not a loss: a record refused after close describes an + // Not a loss: a record refused after the seal describes an // operation performed outside the channel's boundary. return Err(ClaimError::Closed); } @@ -215,9 +215,9 @@ impl DerefMut for FrameMut<'_> { impl FrameMut<'_> { /// Commits the frame, making it visible to the receiver. /// - /// If the receiver closed the channel and aborted this frame's slot + /// If the receiver sealed the channel and aborted this frame's slot /// first, the swap fails and the frame is silently discarded: the - /// record belongs to the close race and is intentionally excluded + /// record belongs to the seal race and is intentionally excluded /// either way. pub fn finish(self) { // Rule 2: `Release` orders every payload write before the From cd4149824bfcf11ce515b4bcecb2b2bff1740d81 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sat, 15 Aug 2026 20:15:49 +0800 Subject: [PATCH 40/92] refactor(fspy-shm): fix the table length at compile time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The table length becomes a const parameter and the header and table become one repr(C) struct — Meta — that the channel instantiates (SLOTS = 1 << 26, matching what the eighth rule gave the 4 GiB production region). The payload area is simply the rest of the mapping, so the runtime geometry collapses to one number, size_of::(), and attaching checks a single bound: the struct must fit the mapping, leaving a payload area the descriptors' 32-bit offsets can address. The sizing rules, the supported-length predicate, and the eighth-rule arithmetic are all gone; channel capacity now means payload bytes, with the fixed struct added on top. Unlike the earlier const-generic round, nothing needs to derive one const from another and no exact size handshake exists — senders accept any mapping the struct fits into — so the parameter stops infecting call sites beyond the channel's own aliases; fspy is untouched. Co-Authored-By: Claude Fable 5 --- crates/fspy_shared/src/ipc/channel/mod.rs | 31 ++- .../src/ipc/channel/shm_io/README.md | 24 +-- .../src/ipc/channel/shm_io/layout.rs | 192 +++++++----------- .../fspy_shared/src/ipc/channel/shm_io/mod.rs | 125 +++++++----- .../src/ipc/channel/shm_io/reader.rs | 37 ++-- .../src/ipc/channel/shm_io/writer.rs | 26 +-- 6 files changed, 203 insertions(+), 232 deletions(-) diff --git a/crates/fspy_shared/src/ipc/channel/mod.rs b/crates/fspy_shared/src/ipc/channel/mod.rs index 1f2525fe8..d37cbb546 100644 --- a/crates/fspy_shared/src/ipc/channel/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/mod.rs @@ -15,9 +15,17 @@ use fspy_nostd_alloc::OsCString; use fspy_shm::Mapping; use shm_io::{ShmReader, ShmWriter}; +/// Descriptor slots per channel — the compile-time half of the region's +/// shape, shared by the receiver and every sender through this constant. +/// It matches what the old an-eighth-of-the-region rule gave the 4 GiB +/// production region: one 8-byte descriptor per ~56 payload bytes at full +/// capacity, generous slack for record-sized frames. The table is sparse +/// address space until slots are actually touched. +const SLOTS: usize = 1 << 26; + /// The committed frames of a closed channel; borrows the shared mapping, /// which stays alive (and mapped) until this value drops. -pub type Frames = shm_io::ShmReader; +pub type Frames = shm_io::ShmReader; use uuid::Uuid; use wincode::{SchemaRead, SchemaWrite, Serialize as _, config::DefaultConfig}; @@ -39,13 +47,14 @@ pub struct ChannelConf { /// Creates a mpsc IPC channel with one receiver and a `ChannelConf` that can be passed around processes and used to create multiple senders. /// -/// The channel's layout is derived from `capacity` alone, on both ends, so -/// senders need no configuration beyond the `ChannelConf`. +/// `capacity` is the payload budget: the region is sized to the +/// compile-time descriptor table plus that many payload bytes. Senders +/// need no configuration beyond the `ChannelConf` — the layout is the +/// compile-time table plus whatever the mapped file's size says. #[expect(clippy::missing_errors_doc, reason = "non-vt crate: cannot use vt_str/vt_path types")] pub fn channel(capacity: usize) -> io::Result<(ChannelConf, Receiver)> { - // The protocol supports multiple-of-8 region lengths from 1 KiB to - // 4 GiB; round the requested capacity up into the supported set. - let capacity = shm_io::round_up_region_len(capacity); + // The region: the compile-time fixed struct plus the payload budget. + let capacity = shm_io::region_len::(capacity); let shm_c_path = os_c_string(shm_backing_path()?.as_os_str())?; let handle = fspy_shm::create(shm_c_path.as_c_str().as_thin(), capacity).map_err(shm_error_to_io)?; @@ -65,7 +74,7 @@ pub fn channel(capacity: usize) -> io::Result<(ChannelConf, Receiver)> { let _ = std::thread::Builder::new().name("fspy-shm-prefault".into()).spawn(move || { // SAFETY: the mapping views the region created zero-initialized // above, which is only accessed through the `shm_io` protocol. - unsafe { shm_io::pre_fault(&prefault_mapping) }; + unsafe { shm_io::pre_fault::(&prefault_mapping) }; }); } @@ -183,7 +192,7 @@ impl ChannelConf { .map_err(shm_error_to_io)?; // A truncated or foreign file must fail here, not panic the host // process inside the protocol's geometry assertions. - if !shm_io::is_supported_region_len(mapping.len()) { + if !shm_io::is_supported_region_len::(mapping.len()) { return Err(io::Error::new( io::ErrorKind::InvalidData, "shared-memory region size cannot host the channel", @@ -205,7 +214,7 @@ impl ChannelConf { } pub struct Sender { - writer: ShmWriter, + writer: ShmWriter, } impl Sender { @@ -287,7 +296,7 @@ impl Receiver { // SAFETY: `mapping` was created zero-initialized by `channel`, its // address is stable and independently owned, and all attached // processes access it only through the `shm_io` protocol. - unsafe { ShmReader::seal(mapping) } + unsafe { ShmReader::<_, SLOTS>::seal(mapping) } .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err)) } } @@ -402,7 +411,7 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn concurrent_senders() { - // 64 KiB: a 1023-slot table for the 200 frames sent below. + // 64 KiB of payload room for the 200 frames sent below. let (conf, receiver) = channel(64 * 1024).unwrap(); for i in 0u16..200 { let cmd = command_for_fn!((conf.clone(), i), |(conf, i): (ChannelConf, u16)| { diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/README.md b/crates/fspy_shared/src/ipc/channel/shm_io/README.md index c9fd1fd0e..2bd0afa87 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/README.md +++ b/crates/fspy_shared/src/ipc/channel/shm_io/README.md @@ -27,11 +27,13 @@ file mapped into every participating process. A sparse mapping is address space, not memory: only pages that are actually written get backed. ```text -| header (64 B) | descriptor table (1/8 of the region) | payloads (the rest, grow up) | +| header (64 B) | descriptor table (SLOTS slots) | payloads (the rest, grow up) | ``` -The header is a `repr(C)` struct of two `AtomicU64` counters, which only -ever count up: +The header and the table together are one `repr(C)` struct: the header — +two `AtomicU64` counters, which only ever count up — followed by one +8-byte descriptor slot per frame. The table length is a compile-time +constant the channel picks once for both ends: - the **claim counter** — how many frames were ever claimed. Bit 63 is the CLOSED gate, set by the receiver when it closes the channel — and @@ -39,15 +41,13 @@ ever count up: that a record was lost. - the **payload counter** — how many payload bytes were ever reserved. -The table has one 8-byte slot per frame — an eighth of the region. Every -bound is derived from the mapping length alone, so the region is -self-describing: writers and the receiver compute the same layout from the -size of the file they mapped, with nothing else to agree on. Supported -lengths are multiples of 8 bytes between 1 KiB and 4 GiB — the creator -rounds its requested size up into that set, so the layout rules never -meet a degenerate region. For a 4 GiB -region that is ~67 million slots; the ~3.5 GiB payload region fits ~15–20 -million records of a few hundred bytes, so payload space runs out first. +The payload area is simply the rest of the mapping, so the whole +geometry reduces to one number — the struct's size — and attaching +checks a single bound: the struct must fit inside the mapping, leaving a +payload area the descriptors' 32-bit offsets can address. The production +channel uses ~67 million slots; a 4 GiB payload budget holds ~15–20 +million records of a few hundred bytes, so payload space runs out +first. The line between table space and payload space never moves. That is what keeps claiming free of retry loops: each counter is checked against a diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs index 3729f33fe..19caecba9 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs @@ -6,17 +6,19 @@ //! | header | descriptor table | payloads (grow up) | //! ``` //! -//! This module holds the region's shape — the sizing rule that turns a -//! mapping length into table and payload bounds, payload rounding, and -//! payload-span validation — the header type, the descriptor-slot codec -//! both sides encode and decode, and [`MappedLayout`]: the shape bound to -//! one concrete mapping, built once when an endpoint attaches. The sides -//! themselves live in [`super::writer`] and [`super::reader`]. +//! The header and the table have compile-time shape: one `repr(C)` +//! [`Meta`] struct whose table length is a const parameter the channel +//! specifies. The payload area is simply the rest of the mapping, so the +//! whole geometry reduces to that struct's size. This module holds the +//! struct, the descriptor-slot wire format, payload rounding, and +//! [`MappedLayout`]: the views bound to one concrete mapping, built once +//! when an endpoint attaches. The sides themselves live in +//! [`super::writer`] and [`super::reader`]. //! -//! Overflow safety follows from one bound enforced at construction time: -//! the mapping length never exceeds [`MAX_MAPPING_LEN`], so all offsets fit -//! the 32-bit descriptor fields and all sums fit `usize` on the 64-bit -//! targets the parent module asserts. +//! Overflow safety follows from one bound enforced at attach time: the +//! payload region never exceeds [`MAX_PAYLOAD_REGION_LEN`], so all +//! offsets fit the 32-bit descriptor fields and all sums fit `usize` on +//! the 64-bit targets the parent module asserts. use std::{ptr::NonNull, sync::atomic::AtomicU64}; @@ -40,12 +42,18 @@ pub(super) struct Header { } // One cache line: shrink `_reserved` when adding a field. The alignment is -// what lets a `u64`-aligned mapping base be cast to `&Header`. +// what lets a `u64`-aligned mapping base be cast to `&Meta`. const _: () = assert!(size_of::
() == 64); const _: () = assert!(align_of::
() == align_of::()); -/// Byte size of the region header, taken from [`Header`] itself. -pub(super) const HEADER_LEN: usize = size_of::
(); +/// The region's fixed-location part — the header and the descriptor +/// table — as one `repr(C)` struct. The payload area is simply the rest +/// of the mapping. +#[repr(C)] +pub(super) struct Meta { + pub(super) header: Header, + pub(super) table: [AtomicU64; SLOTS], +} /// Byte size of one descriptor slot. pub(super) const SLOT_LEN: usize = size_of::(); @@ -56,57 +64,22 @@ pub(super) const SLOT_LEN: usize = size_of::(); /// which caps them at `i32::MAX`. pub(super) const MAX_PAYLOAD_LEN: usize = i32::MAX as usize; -/// Maximum supported mapping size. +/// Maximum payload-region size. /// /// Descriptors store payload offsets in 32 bits, so the payload region -/// must fit `u32` arithmetic; capping the whole mapping at 4 GiB keeps -/// every offset and sum inside it. -pub(super) const MAX_MAPPING_LEN: usize = 1 << 32; - -/// Minimum supported mapping size — a deliberate cutoff, not a derived -/// one: regions under a KiB are not worth a channel. It keeps every -/// supported region's shape regular (at least 15 slots and 840 payload -/// bytes), so the sizing rules below need no small-region special cases. -pub(super) const MIN_MAPPING_LEN: usize = 1024; - -/// Whether `len` is a supported mapping length: a multiple of the slot -/// size between [`MIN_MAPPING_LEN`] and [`MAX_MAPPING_LEN`]. Everything -/// below assumes a supported length. -pub(super) const fn is_supported(len: usize) -> bool { - len.is_multiple_of(SLOT_LEN) && len >= MIN_MAPPING_LEN && len <= MAX_MAPPING_LEN -} - -/// The descriptor-table length of a supported `mapping_len`-byte region. -/// -/// Both endpoints derive the layout from the mapping length alone, so the -/// region is self-describing: no side channel has to agree on a table -/// size. An eighth of the space beyond the header goes to descriptors — -/// generous slack for typical record shapes, one 8-byte descriptor per -/// payload of a few hundred bytes — and the region is sparse, so an -/// oversized table costs address space, not memory. -pub(super) const fn table_slots(mapping_len: usize) -> usize { - (mapping_len - HEADER_LEN) / (8 * SLOT_LEN) -} +/// must fit `u32` arithmetic. +pub(super) const MAX_PAYLOAD_REGION_LEN: usize = 1 << 32; /// Rounds a payload length up to a multiple of `size_of::()`. /// /// Payload reservations are whole `u64`s, so every payload offset stays -/// `u64`-aligned — an invariant [`PayloadSpan::validate`] uses to reject +/// `u64`-aligned — an invariant the reader's validation uses to reject /// descriptors no correct writer produces. The sub-`u64` padding stays /// inside the frame's own reservation. pub(super) const fn reserved_payload_len(payload_len: usize) -> usize { payload_len.next_multiple_of(SLOT_LEN) } -/// Byte size of the payload region of a supported `mapping_len`-byte -/// mapping: everything after the table. A multiple of `size_of::()` -/// by construction — supported lengths, the header, and the table all -/// are — so a reservation of whole `u64`s inside it never reaches past -/// `mapping_len`. -pub(super) const fn payload_region_len(mapping_len: usize) -> usize { - mapping_len - HEADER_LEN - table_slots(mapping_len) * SLOT_LEN -} - // --- The descriptor slot codec --------------------------------------------- // // One slot is a 64-bit value that publishes a frame: @@ -138,14 +111,13 @@ pub(super) const LEN_SHIFT: u32 = 32; pub(super) const OFFSET_MAX: u64 = u32::MAX as u64; // --- The mapped layout and the ordering contract --------------------------- -// `MappedLayout::new` builds three typed views of the region — the -// `repr(C)` `Header`, the descriptor table as a slice of atomics sized by -// the mapping length, and the untyped payload area as a raw slice — once, -// when an endpoint attaches; the endpoint stores them beside the mapping -// they point into. Every access after that is a plain field access or a -// bounds-checked index. The payload area stays raw because writers hold -// exclusive `&mut` borrows into it, which must not alias any shared -// reference. +// `MappedLayout::new` builds two typed views of the region — the +// `repr(C)` `Meta` struct (header and descriptor table) and the untyped +// payload area as a raw slice — once, when an endpoint attaches; the +// endpoint stores them beside the mapping they point into. Every access +// after that is a plain field access or a bounds-checked index. The +// payload area stays raw because writers hold exclusive `&mut` borrows +// into it, which must not alias any shared reference. // // # Shared atomics // @@ -198,19 +170,17 @@ pub(super) const OFFSET_MAX: u64 = u32::MAX as u64; // descriptor also makes the payload writes it published visible, so the // borrows `ShmReader` later hands out read settled bytes. -/// The typed views of the region: the header, the descriptor table -/// sized from the mapping length, and the raw payload area. Built once -/// when an endpoint attaches and stored in it; the views stay valid -/// because they point into the mapping's stable target, not into the -/// endpoint value. +/// The typed views of the region: the fixed-location [`Meta`] struct +/// and the raw payload area. Built once when an endpoint attaches and +/// stored in it; the views stay valid because they point into the +/// mapping's stable target, not into the endpoint value. #[derive(Clone, Copy)] -pub(super) struct MappedLayout { - header: NonNull
, - table: NonNull<[AtomicU64]>, +pub(super) struct MappedLayout { + meta: NonNull>, pub(super) payloads: *mut [u8], } -impl MappedLayout { +impl MappedLayout { /// Builds the typed views of a shared mapping. /// /// # Safety @@ -224,49 +194,52 @@ impl MappedLayout { /// # Panics /// /// Panics when the mapping cannot host the protocol at all: base not - /// `u64`-aligned, or a length that is not supported ([`is_supported`]). - /// These indicate a broken caller, not runtime data; senders guard + /// `u64`-aligned, [`Meta`] not fitting inside the mapping, or the + /// payload area beyond the descriptors' 32-bit offsets. These + /// indicate a broken caller, not runtime data; senders guard /// untrusted mappings with [`super::is_supported_region_len`] first. - #[expect(clippy::cast_ptr_alignment, reason = "the base is asserted `u64`-aligned below")] pub(super) unsafe fn new(mem: *mut [u8]) -> Self { let base = mem.cast::(); let len = mem.len(); assert!(!base.is_null()); - assert!(base.addr().is_multiple_of(align_of::
())); - assert!(is_supported(len)); - // SAFETY: the base is non-null (asserted), and the header and the - // table lie inside the mapping — the header by the asserts above, - // the table by `max_slots` — at `u64`-aligned offsets. The - // payload area keeps the rest of the mapping as a raw slice; - // `layout` bounds every span carved from it. + assert!(base.addr().is_multiple_of(align_of::>())); + // The whole geometry check: the payload area is everything after + // the fixed-location struct, so the struct must fit inside the + // mapping, and the rest must fit the descriptors' 32-bit offsets. + let payload_base = size_of::>(); + assert!(payload_base <= len); + assert!(len - payload_base <= MAX_PAYLOAD_REGION_LEN); + // SAFETY: the base is non-null and `u64`-aligned, and `Meta` fits + // inside the mapping (all asserted). The payload area keeps the + // rest of the mapping as a raw slice. unsafe { Self { - header: NonNull::new_unchecked(base.cast::
()), - table: NonNull::slice_from_raw_parts( - NonNull::new_unchecked(base.add(HEADER_LEN).cast::()), - table_slots(len), - ), + meta: NonNull::new_unchecked(base.cast::>()), payloads: std::ptr::slice_from_raw_parts_mut( - base.add(HEADER_LEN + table_slots(len) * SLOT_LEN), - payload_region_len(len), + base.add(payload_base), + len - payload_base, ), } } } + /// The fixed-location part of the region. + const fn meta(&self) -> &Meta { + // SAFETY: `new`'s contract keeps the target valid while any view + // is used, and `Meta` consists of atomics, so the shared borrow + // is valid even while other threads and processes access the same + // memory through them. + unsafe { self.meta.as_ref() } + } + /// The protocol header. pub(super) const fn header(&self) -> &Header { - // SAFETY: `new`'s contract keeps the target valid while any view - // is used, and the header consists of atomics, so the shared - // borrow is valid even while other threads and processes access - // the same memory through them. - unsafe { self.header.as_ref() } + &self.meta().header } /// The descriptor table. pub(super) const fn table(&self) -> &[AtomicU64] { - // SAFETY: as for `header`. - unsafe { self.table.as_ref() } + &self.meta().table } } @@ -286,35 +259,8 @@ mod tests { } #[test] - fn table_gets_an_eighth_of_the_region() { - assert!(table_slots(1 << 20) == 16383); - // The 4 GiB production mapping: ~67M slots. - assert!(table_slots(MAX_MAPPING_LEN) == ((MAX_MAPPING_LEN - HEADER_LEN) / 8) / 8); - } - - #[test] - fn unsupported_lengths_are_refused() { - assert!(is_supported(MIN_MAPPING_LEN)); - assert!(is_supported(MAX_MAPPING_LEN)); - // Too small, even as a multiple of 8. - assert!(!is_supported(1000)); - assert!(!is_supported(0)); - // Not a multiple of 8. - assert!(!is_supported(1025)); - // Too large. - assert!(!is_supported(MAX_MAPPING_LEN + 8)); - } - - #[test] - fn payload_region_fills_the_rest() { - assert!(table_slots(1024) == 15); - assert!(payload_region_len(1024) == 840); - // The three areas cover a supported region exactly. - assert!( - HEADER_LEN - + table_slots(MAX_MAPPING_LEN) * SLOT_LEN - + payload_region_len(MAX_MAPPING_LEN) - == MAX_MAPPING_LEN - ); + fn meta_is_the_header_then_the_table() { + assert!(size_of::>() == 64 + 15 * SLOT_LEN); + assert!(align_of::>() == align_of::()); } } diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs index cdd24b38e..503c2af24 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs @@ -18,13 +18,13 @@ //! fixed descriptor table payloads grow up -> //! ``` //! -//! The layout is derived from the mapping length alone ([`layout`]), so -//! the region is self-describing: every process computes the same table -//! and payload bounds from the mapped size. Attaching constructs typed -//! views of the header (a `repr(C)` struct of two monotonic `AtomicU64` -//! counters — claims, carrying the CLOSED gate bit, and payload bytes -//! reserved) and of the descriptor table (a slice of atomics); the -//! payload area stays untyped bytes. +//! The header and the descriptor table form one `repr(C)` struct whose +//! table length is a compile-time constant every endpoint shares (the +//! channel specifies it); the payload area is simply the rest of the +//! mapping ([`layout`]). Attaching constructs typed views of that struct +//! — the header is two monotonic `AtomicU64` counters: claims, carrying +//! the CLOSED gate bit, and payload bytes reserved — while the payload +//! area stays untyped bytes. //! A claim is two wait-free `fetch_add`s — one reserves payload bytes, one //! reserves a descriptor slot — validated against the fixed region bounds //! from the returned old values. A failed claim sets the CLOSED gate as @@ -110,25 +110,31 @@ impl AsRawSlice for Mapping { } } -/// Whether a mapping of `len` bytes can host the protocol at all: a -/// multiple of 8 bytes, between 1 KiB and 4 GiB. +/// Whether a mapping of `len` bytes can host a channel with `SLOTS` +/// descriptor slots: the fixed-location struct must fit inside the +/// mapping, and the payload area — the rest — must fit the descriptors' +/// 32-bit offsets. /// /// Senders opening a file they do not control should refuse unsupported /// lengths with an error; the protocol's own constructors treat them as a -/// broken caller and panic. Creators pick a supported length with -/// [`round_up_region_len`]. +/// broken caller and panic. Creators size the file with [`region_len`]. #[must_use] -pub const fn is_supported_region_len(len: usize) -> bool { - layout::is_supported(len) +pub const fn is_supported_region_len(len: usize) -> bool { + size_of::>() <= len + && len - size_of::>() <= layout::MAX_PAYLOAD_REGION_LEN } -/// Rounds a desired region length up to the nearest supported one. -/// (Desired lengths beyond the 4 GiB maximum clamp down to it.) +/// The region length for a channel with `SLOTS` descriptor slots and up +/// to `payload_capacity` payload bytes (clamped to the payload area's +/// 32-bit-offset maximum). #[must_use] -pub fn round_up_region_len(desired: usize) -> usize { - desired - .clamp(layout::MIN_MAPPING_LEN, layout::MAX_MAPPING_LEN) - .next_multiple_of(layout::SLOT_LEN) +pub const fn region_len(payload_capacity: usize) -> usize { + let payload = if payload_capacity < layout::MAX_PAYLOAD_REGION_LEN { + payload_capacity + } else { + layout::MAX_PAYLOAD_REGION_LEN + }; + size_of::>() + payload } /// Materializes the page backing the protocol header without changing @@ -143,9 +149,9 @@ pub fn round_up_region_len(desired: usize) -> usize { /// /// Same contract as [`ShmWriter::new`]. #[cfg(target_os = "linux")] -pub unsafe fn pre_fault(mem: &impl AsRawSlice) { +pub unsafe fn pre_fault(mem: &impl AsRawSlice) { // SAFETY: forwarded from this function's contract. - let mapped = unsafe { MappedLayout::new(mem.as_raw_slice()) }; + let mapped = unsafe { MappedLayout::::new(mem.as_raw_slice()) }; // A compare-exchange of zero with zero on the claim counter: on an // untouched region it performs a real write — allocating the first // block of a sparse backing file — without changing protocol state. If @@ -227,7 +233,10 @@ mod tests { } } - fn collect_frames(shm: &MockedShm) -> ShmReader { + /// The table length most tests use; regions add payload room on top. + const S: usize = 15; + + fn collect_frames(shm: &MockedShm) -> ShmReader { // SAFETY: `MockedShm` provides a stable, zero-initialized allocation // accessed only through the protocol. unsafe { ShmReader::seal(shm.clone()) }.unwrap() @@ -238,7 +247,7 @@ mod tests { let shm = MockedShm::alloc(1024); // SAFETY: `MockedShm::alloc` provides a valid, properly-sized, // zero-initialized allocation. - let writer = unsafe { ShmWriter::new(shm.clone()) }; + let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }; assert!(writer.try_write_frame(b"hello")); assert!(writer.try_write_frame(b"world")); assert!(writer.try_write_frame(b"this is a test")); @@ -256,7 +265,7 @@ mod tests { fn zero_sized_frames_are_rejected() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer = unsafe { ShmWriter::new(shm.clone()) }; + let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }; assert!(writer.try_write_frame(b"hello")); assert!(!writer.try_write_frame(b"")); @@ -270,7 +279,7 @@ mod tests { fn frame_spanning_many_u64s_roundtrips_exactly() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer = unsafe { ShmWriter::new(shm.clone()) }; + let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }; let pattern: Vec = (0..=99).collect(); assert!(writer.try_write_frame(&pattern)); @@ -284,7 +293,7 @@ mod tests { fn full_region_marks_the_channel_incomplete() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer = unsafe { ShmWriter::new(shm.clone()) }; + let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }; assert!(writer.try_write_frame(b"test")); @@ -303,7 +312,7 @@ mod tests { fn oversized_frame_is_refused_and_marks_incomplete() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer = unsafe { ShmWriter::new(shm.clone()) }; + let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }; assert!(writer.try_write_frame(b"kept")); // No descriptor can describe a frame this long: the claim is @@ -325,7 +334,7 @@ mod tests { fn crash_after_claim_is_skipped() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer = unsafe { ShmWriter::new(shm.clone()) }; + let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }; assert!(writer.try_write_frame(b"foo")); // A crash right after claiming and an abandoned frame leave the @@ -347,7 +356,7 @@ mod tests { fn crash_during_partial_write_is_skipped() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer = unsafe { ShmWriter::new(shm.clone()) }; + let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }; assert!(writer.try_write_frame(b"foo")); // Simulate a crash during writing: the frame is abandoned @@ -373,7 +382,7 @@ mod tests { // receiver from finding the valid frames around them. let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer = unsafe { ShmWriter::new(shm.clone()) }; + let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }; assert!(writer.try_write_frame(b"foo")); @@ -400,7 +409,7 @@ mod tests { fn abandoned_frame_is_ignored() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer = unsafe { ShmWriter::new(shm.clone()) }; + let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }; assert!(writer.try_write_frame(b"foo")); // Dropping an unfinished frame abandons it: the receiver ignores @@ -419,15 +428,15 @@ mod tests { fn pre_fault_does_not_disturb_protocol_state() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer = unsafe { ShmWriter::new(shm.clone()) }; + let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }; // On the untouched region, before any claim. // SAFETY: see `collect_frames`. - unsafe { pre_fault(&shm) }; + unsafe { pre_fault::(&shm) }; assert!(writer.try_write_frame(b"foo")); // Racing an already claimed region must change nothing either. // SAFETY: see `collect_frames`. - unsafe { pre_fault(&shm) }; + unsafe { pre_fault::(&shm) }; assert!(writer.try_write_frame(b"bar")); let frames = collect_frames(&shm); @@ -444,7 +453,7 @@ mod tests { // on the slot side while payload space remains. let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer = unsafe { ShmWriter::new(shm.clone()) }; + let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }; for _ in 0..15 { assert!(writer.try_write_frame(b"x")); } @@ -459,7 +468,7 @@ mod tests { fn claims_after_seal_are_gated_without_poisoning() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer = unsafe { ShmWriter::new(shm.clone()) }; + let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }; assert!(writer.try_write_frame(b"foo")); assert!(!writer.is_closed()); @@ -481,7 +490,7 @@ mod tests { fn commit_after_abort_publishes_nothing() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer = unsafe { ShmWriter::new(shm.clone()) }; + let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }; let mut frame = writer.claim_frame(5.try_into().unwrap()).unwrap(); frame.copy_from_slice(b"late!"); @@ -504,7 +513,9 @@ mod tests { #[test] fn concurrent() { - // 16 KiB: a 255-slot table for the 120 frames written below. + // A 255-slot table for the 120 frames written below, with 16 KiB + // of room for the fixed struct and the payloads. + const S: usize = 255; let shm = MockedShm::alloc(1024 * 16); thread::scope(|s| { @@ -513,7 +524,7 @@ mod tests { // SAFETY: see `single_thread_basic`. The clone shares the // same backing memory, which is safe because the protocol // synchronizes concurrent access with atomics. - let writer = unsafe { ShmWriter::new(shm.clone()) }; + let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }; for _ in 0..10 { assert!(writer.try_write_frame(b"hello")); assert!(writer.try_write_frame(b"foo")); @@ -523,7 +534,8 @@ mod tests { } }); - let frames = collect_frames(&shm); + // SAFETY: see `collect_frames`. + let frames = unsafe { ShmReader::<_, S>::seal(shm) }.unwrap(); let mut count = 0; for frame in &frames { count += 1; @@ -538,7 +550,7 @@ mod tests { fn concurrent_exceeded_size() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer = unsafe { ShmWriter::new(shm.clone()) }; + let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }; thread::scope(|s| { for _ in 0..4 { s.spawn(|| { @@ -567,6 +579,8 @@ mod tests { #[test] fn seal_races_with_active_writers() { + // Plenty of slots and payload room: capacity must never fail here. + const S: usize = 1023; let shm = MockedShm::alloc(1024 * 64); let barrier = Barrier::new(3); @@ -574,7 +588,7 @@ mod tests { let writers = [(); 2].map(|()| { s.spawn(|| { // SAFETY: see `concurrent`. - let writer = unsafe { ShmWriter::new(shm.clone()) }; + let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }; barrier.wait(); let mut written = 0usize; // Bounded so the test terminates even if the seal is slow; @@ -595,7 +609,8 @@ mod tests { }); barrier.wait(); - let frames = collect_frames(&shm); + // SAFETY: see `collect_frames`. + let frames = unsafe { ShmReader::<_, S>::seal(shm.clone()) }.unwrap(); let results = writers.map(|writer| writer.join().unwrap()); (frames, results) }); @@ -620,7 +635,7 @@ mod tests { fn corrupt_committed_descriptor_is_a_protocol_error() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer = unsafe { ShmWriter::new(shm.clone()) }; + let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }; assert!(writer.try_write_frame(b"hello")); // Point slot 0 at a span escaping the payload region. @@ -629,7 +644,7 @@ mod tests { shm.poke_u64(64, (bogus_len << 32) | bogus_offset); // SAFETY: see `collect_frames`. - let result = unsafe { ShmReader::seal(shm) }; + let result = unsafe { ShmReader::<_, S>::seal(shm) }; assert!(result.unwrap_err() == ProtocolError::CorruptDescriptor { slot_index: 0 }); } @@ -637,7 +652,7 @@ mod tests { fn corrupt_aborted_descriptor_is_a_protocol_error() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer = unsafe { ShmWriter::new(shm.clone()) }; + let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }; assert!(writer.try_write_frame(b"hello")); // The aborted bit combined with payload bits is a value no protocol @@ -645,7 +660,7 @@ mod tests { shm.poke_u64(64, (1 << 63) | (8u64 << 32) | 8); // SAFETY: see `collect_frames`. - let result = unsafe { ShmReader::seal(shm) }; + let result = unsafe { ShmReader::<_, S>::seal(shm) }; assert!(result.unwrap_err() == ProtocolError::CorruptDescriptor { slot_index: 0 }); } @@ -653,7 +668,7 @@ mod tests { fn overshot_claim_counter_clamps_to_the_table() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer = unsafe { ShmWriter::new(shm.clone()) }; + let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }; assert!(writer.try_write_frame(b"hello")); // A wildly inflated claim counter — mass claim failures or a foreign @@ -690,7 +705,7 @@ mod tests { let result = std::panic::catch_unwind(|| { // SAFETY: Intentionally passing a misaligned pointer to test that // the geometry assertion correctly panics. - unsafe { ShmWriter::new(misaligned_shm) }; + unsafe { ShmWriter::<_, S>::new(misaligned_shm) }; }); assert!(result.is_err(), "should panic on a misaligned region"); } @@ -706,6 +721,8 @@ mod tests { const CHILD_COUNT: usize = 12; const FRAME_COUNT_EACH_CHILD: usize = 100; + // Room for every child's frames, in slots and in payload bytes. + const S: usize = 16383; const SHM_SIZE: usize = 1024 * 1024; let shm_path = crate::ipc::channel::shm_backing_path().unwrap(); @@ -731,7 +748,7 @@ mod tests { // SAFETY: `mapping` is a freshly mapped shared memory // region with a valid pointer and size; the protocol // synchronizes concurrent access. - let writer = unsafe { ShmWriter::new(mapping) }; + let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(mapping) }; for i in 0..FRAME_COUNT_EACH_CHILD { let frame_data = std::format!("{child_index} {i}"); assert!(writer.try_write_frame(frame_data.as_bytes())); @@ -749,7 +766,7 @@ mod tests { // SAFETY: the mapping is a valid shared-memory region created zeroed // and accessed only through the protocol. - let frames = unsafe { ShmReader::seal(mapping) }.unwrap(); + let frames = unsafe { ShmReader::<_, S>::seal(mapping) }.unwrap(); assert!(frames.is_complete()); let collected = frames.iter().map(BStr::new).collect::>(); assert!(collected.len() == CHILD_COUNT * FRAME_COUNT_EACH_CHILD); @@ -787,7 +804,7 @@ mod tests { let c_path = crate::ipc::channel::os_c_string(std::ffi::OsStr::new(&shm_name)).unwrap(); let child_mapping = fspy_shm::open(c_path.as_c_str().as_thin()).unwrap().map().unwrap(); // SAFETY: see `real_shm_across_processes`. - let writer = unsafe { ShmWriter::new(child_mapping) }; + let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(child_mapping) }; let mut frame = writer.claim_frame(5.try_into().unwrap()).unwrap(); frame[..3].copy_from_slice(b"wor"); // Signal the parent that the frame is claimed and partially @@ -813,11 +830,11 @@ mod tests { // A surviving writer keeps working after the kill. // SAFETY: see `real_shm_across_processes`. - let writer = unsafe { ShmWriter::new(mapping) }; + let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(mapping) }; assert!(writer.try_write_frame(b"alive")); // SAFETY: see `real_shm_across_processes`. - let frames = unsafe { ShmReader::seal(writer.into_memory()) }.unwrap(); + let frames = unsafe { ShmReader::<_, S>::seal(writer.into_memory()) }.unwrap(); let mut iter = frames.iter(); assert!(iter.next().unwrap() == b"alive"); assert!(iter.next() == None); diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs index 166fe4b10..570888f8c 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs @@ -90,12 +90,12 @@ pub enum ProtocolError { /// straight out of the mapping, which stays alive inside this value and /// is released when the reader drops. It holds no buffer: iteration /// re-reads the frozen descriptor table, so closing allocates nothing. -pub struct ShmReader { +pub struct ShmReader { /// Owns the region the views point into; dropped with the reader. #[expect(dead_code, reason = "held to keep the region alive")] mem: M, /// The layout mapped onto the owned region. - mapped: MappedLayout, + mapped: MappedLayout, /// Length of the frozen prefix of the descriptor table. slot_count: usize, /// Committed frames in that prefix. @@ -106,11 +106,11 @@ pub struct ShmReader { // SAFETY: the reader reads only the header atomics, frozen slots, and // immutable committed spans; the stored views point into the mapping's // stable, independently owned target, not into the reader value itself. -unsafe impl Send for ShmReader {} +unsafe impl Send for ShmReader {} // SAFETY: see the `Send` impl. -unsafe impl Sync for ShmReader {} +unsafe impl Sync for ShmReader {} -impl ShmReader { +impl ShmReader { /// Seals the channel — no further records — and returns the /// reader of its committed frames. /// @@ -155,8 +155,7 @@ impl ShmReader { // a counter inflated by failed claims (or by a foreign // scribble) degrades to a full-table sweep, not an error. let claims = mapped.header().claims.load(Ordering::Relaxed); - slot_count = - usize::try_from(claims & !CLOSED).unwrap_or(usize::MAX).min(mapped.table().len()); + slot_count = usize::try_from(claims & !CLOSED).unwrap_or(usize::MAX).min(SLOTS); // The same load carries the completeness verdict: a gate set // before this boundary is a failed claim's loss report — or an // earlier seal, and a re-seal cannot vouch for records @@ -207,7 +206,7 @@ impl ShmReader { } /// Iterates over the committed frames in claim order. - pub fn iter(&self) -> Iter<'_> { + pub fn iter(&self) -> Iter<'_, SLOTS> { self.into_iter() } @@ -223,7 +222,7 @@ impl ShmReader { } } -impl fmt::Debug for ShmReader { +impl fmt::Debug for ShmReader { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("ShmReader") .field("frames", &self.frames) @@ -233,16 +232,16 @@ impl fmt::Debug for ShmReader { } /// Iterator over a [`ShmReader`]'s committed frames, in claim order. -pub struct Iter<'a> { +pub struct Iter<'a, const SLOTS: usize> { /// The layout the spans decode against and point into. - mapped: MappedLayout, + mapped: MappedLayout, /// The not-yet-visited part of the table's frozen prefix. table: &'a [AtomicU64], /// Committed frames not yet yielded. remaining: usize, } -impl<'a> Iterator for Iter<'a> { +impl<'a, const SLOTS: usize> Iterator for Iter<'a, SLOTS> { type Item = &'a [u8]; fn next(&mut self) -> Option { @@ -279,11 +278,11 @@ impl<'a> Iterator for Iter<'a> { } } -impl<'a, M: AsRawSlice> IntoIterator for &'a ShmReader { - type IntoIter = Iter<'a>; +impl<'a, M: AsRawSlice, const SLOTS: usize> IntoIterator for &'a ShmReader { + type IntoIter = Iter<'a, SLOTS>; type Item = &'a [u8]; - fn into_iter(self) -> Iter<'a> { + fn into_iter(self) -> Iter<'a, SLOTS> { Iter { mapped: self.mapped, table: &self.mapped.table()[..self.slot_count], @@ -300,7 +299,7 @@ mod tests { #[test] fn payload_span_validates_bounds() { - let region = layout::payload_region_len(1024); + let region = 1024; // A `u64`-aligned span at the region start. assert!(PayloadSpan::validate(region, 0, 8).is_some()); // Exact end of the region, with padding inside it. @@ -317,13 +316,13 @@ mod tests { #[test] fn decode_roundtrips_committed_values() { - let region = layout::payload_region_len(1024); + let region = 1024; let span = decode(region, committed(0, 5)).unwrap(); assert!(span.offset == 0 && span.len == 5); // The extremes of the descriptor fields on the largest mapping: the // 31-bit length limit, and a span ending exactly at the region end. - let region = layout::payload_region_len(layout::MAX_MAPPING_LEN); + let region = layout::MAX_PAYLOAD_REGION_LEN; let span = decode(region, committed(0, layout::MAX_PAYLOAD_LEN)).unwrap(); assert!(span.offset == 0 && span.len == layout::MAX_PAYLOAD_LEN); let span = decode(region, committed(region - 8, 8)).unwrap(); @@ -332,7 +331,7 @@ mod tests { #[test] fn decode_rejects_values_no_writer_commits() { - let region = layout::payload_region_len(1024); + let region = 1024; // The non-committed slot states. assert!(decode(region, layout::UNFINISHED).is_none()); assert!(decode(region, ABORTED).is_none()); diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs index 1195fa10e..7f15da8ad 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs @@ -18,21 +18,21 @@ use super::{ /// Safe to use across threads and processes at the same time: frames are /// reserved with atomic operations, filled in uniquely owned payload spans, /// and published with an atomic commit (see the ordering contract above). -pub struct ShmWriter { +pub struct ShmWriter { /// Owns the region the views point into; dropped with the writer. #[cfg_attr(any(not(test), miri), expect(dead_code, reason = "held to keep the region alive"))] mem: M, - mapped: MappedLayout, + mapped: MappedLayout, } // SAFETY: the writer touches the region only through the protocol's // atomics, which synchronize access from any thread; the stored views // point into the mapping's stable, independently owned target, not into // the writer value itself. -unsafe impl Send for ShmWriter {} +unsafe impl Send for ShmWriter {} // SAFETY: see the `Send` impl; the writer's shared-reference API is // internally synchronized by the protocol. -unsafe impl Sync for ShmWriter {} +unsafe impl Sync for ShmWriter {} /// Why a frame could not be claimed. #[derive(thiserror::Error, Clone, Copy, PartialEq, Eq, Debug)] @@ -51,7 +51,7 @@ pub enum ClaimError { Capacity, } -impl ShmWriter { +impl ShmWriter { /// Creates a writer backed by a shared-memory region. /// /// # Safety @@ -92,7 +92,7 @@ impl ShmWriter { /// does not fit fails after setting the CLOSED gate: the receiver /// learns a record was lost, and later claims are refused — their /// records would ride a result the receiver must already reject. - pub fn claim_frame(&self, frame_size: NonZeroUsize) -> Result, ClaimError> { + pub fn claim_frame(&self, frame_size: NonZeroUsize) -> Result, ClaimError> { let mapped = self.mapped; let payload_len = frame_size.get(); @@ -133,7 +133,7 @@ impl ShmWriter { return Err(ClaimError::Closed); } let slot_index = usize::try_from(claims).expect("claim count exceeds usize"); - if slot_index >= mapped.table().len() { + if slot_index >= SLOTS { return Err(report_loss()); } @@ -182,14 +182,14 @@ impl ShmWriter { /// exactly as if the writer had died there. A writer that abandons a frame /// and still performs the operation it described steps outside the usage /// contract — records are published before the recorded operation. -pub struct FrameMut<'a> { - mapped: MappedLayout, +pub struct FrameMut<'a, const SLOTS: usize> { + mapped: MappedLayout, slot_index: usize, descriptor: u64, content: &'a mut [u8], } -impl fmt::Debug for FrameMut<'_> { +impl fmt::Debug for FrameMut<'_, SLOTS> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("FrameMut") .field("slot_index", &self.slot_index) @@ -198,7 +198,7 @@ impl fmt::Debug for FrameMut<'_> { } } -impl Deref for FrameMut<'_> { +impl Deref for FrameMut<'_, SLOTS> { type Target = [u8]; fn deref(&self) -> &Self::Target { @@ -206,13 +206,13 @@ impl Deref for FrameMut<'_> { } } -impl DerefMut for FrameMut<'_> { +impl DerefMut for FrameMut<'_, SLOTS> { fn deref_mut(&mut self) -> &mut Self::Target { self.content } } -impl FrameMut<'_> { +impl FrameMut<'_, SLOTS> { /// Commits the frame, making it visible to the receiver. /// /// If the receiver sealed the channel and aborted this frame's slot From 91e4d7bbdb6aec683423e135d93ae2882cf6eb27 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sat, 15 Aug 2026 21:08:55 +0800 Subject: [PATCH 41/92] refactor(fspy-shm): claim exact lengths; drop the alignment fossil MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Payload offsets were u64-aligned because the original protocol stored an AtomicI32 size header inline at the start of every frame — atomics must be aligned, so frame boundaries had to be. The descriptor redesign moved every typed thing out of the payload area, leaving untyped bytes nothing dereferences, but the rounding survived on a circular justification: reservations were rounded so offsets stayed aligned, and validation checked alignment because rounding guaranteed it. Claims now reserve exactly the frame's length: reserved_payload_len and SLOT_LEN are gone, validation is a length range and one bound, and up to seven padding bytes per record return to the payload budget. Spans may be byte-adjacent — disjoint &mut [u8] ranges are race-free at byte granularity, which miri now exercises via the odd-length concurrent tests. Two more fossils from the same sweep: ProtocolError kept its enum shape from a deleted second variant — now a struct — and fspy's SHM_CAPACITY comment predated capacity meaning payload budget. Co-Authored-By: Claude Fable 5 --- crates/fspy/src/ipc.rs | 6 ++-- .../src/ipc/channel/shm_io/layout.rs | 28 ++-------------- .../fspy_shared/src/ipc/channel/shm_io/mod.rs | 4 +-- .../src/ipc/channel/shm_io/reader.rs | 32 +++++++------------ .../src/ipc/channel/shm_io/writer.rs | 15 ++++----- 5 files changed, 27 insertions(+), 58 deletions(-) diff --git a/crates/fspy/src/ipc.rs b/crates/fspy/src/ipc.rs index f2443954a..ae49b1246 100644 --- a/crates/fspy/src/ipc.rs +++ b/crates/fspy/src/ipc.rs @@ -5,9 +5,9 @@ use fspy_shared::ipc::{ channel::{Frames, Receiver}, }; -// Shared memory size for storing path accesses. -// 4 GiB is large enough to store path accesses in almost any realistic scenario. -// This doesn't allocate physical memory until it's actually used. +// Payload budget for path-access records; the channel adds its fixed +// descriptor table on top. 4 GiB is large enough for almost any realistic +// scenario, and none of it occupies physical memory until actually used. pub const SHM_CAPACITY: usize = 4 * 1024 * 1024 * 1024; /// The path accesses a run reported through the IPC channel. diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs index 19caecba9..1c68b5888 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs @@ -10,8 +10,8 @@ //! [`Meta`] struct whose table length is a const parameter the channel //! specifies. The payload area is simply the rest of the mapping, so the //! whole geometry reduces to that struct's size. This module holds the -//! struct, the descriptor-slot wire format, payload rounding, and -//! [`MappedLayout`]: the views bound to one concrete mapping, built once +//! struct, the descriptor-slot wire format, and [`MappedLayout`]: the +//! views bound to one concrete mapping, built once //! when an endpoint attaches. The sides themselves live in //! [`super::writer`] and [`super::reader`]. //! @@ -55,9 +55,6 @@ pub(super) struct Meta { pub(super) table: [AtomicU64; SLOTS], } -/// Byte size of one descriptor slot. -pub(super) const SLOT_LEN: usize = size_of::(); - /// Maximum payload size of a single frame. /// /// Committed lengths are stored in the 31-bit length field of a descriptor, @@ -70,16 +67,6 @@ pub(super) const MAX_PAYLOAD_LEN: usize = i32::MAX as usize; /// must fit `u32` arithmetic. pub(super) const MAX_PAYLOAD_REGION_LEN: usize = 1 << 32; -/// Rounds a payload length up to a multiple of `size_of::()`. -/// -/// Payload reservations are whole `u64`s, so every payload offset stays -/// `u64`-aligned — an invariant the reader's validation uses to reject -/// descriptors no correct writer produces. The sub-`u64` padding stays -/// inside the frame's own reservation. -pub(super) const fn reserved_payload_len(payload_len: usize) -> usize { - payload_len.next_multiple_of(SLOT_LEN) -} - // --- The descriptor slot codec --------------------------------------------- // // One slot is a 64-bit value that publishes a frame: @@ -249,18 +236,9 @@ mod tests { use super::*; - #[test] - fn reserved_payload_len_rounds_up_to_u64s() { - assert!(reserved_payload_len(1) == 8); - assert!(reserved_payload_len(7) == 8); - assert!(reserved_payload_len(8) == 8); - assert!(reserved_payload_len(9) == 16); - assert!(reserved_payload_len(MAX_PAYLOAD_LEN) == MAX_PAYLOAD_LEN + 1); - } - #[test] fn meta_is_the_header_then_the_table() { - assert!(size_of::>() == 64 + 15 * SLOT_LEN); + assert!(size_of::>() == 64 + 15 * size_of::()); assert!(align_of::>() == align_of::()); } } diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs index 503c2af24..021d43868 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs @@ -645,7 +645,7 @@ mod tests { // SAFETY: see `collect_frames`. let result = unsafe { ShmReader::<_, S>::seal(shm) }; - assert!(result.unwrap_err() == ProtocolError::CorruptDescriptor { slot_index: 0 }); + assert!(result.unwrap_err() == ProtocolError { slot_index: 0 }); } #[test] @@ -661,7 +661,7 @@ mod tests { // SAFETY: see `collect_frames`. let result = unsafe { ShmReader::<_, S>::seal(shm) }; - assert!(result.unwrap_err() == ProtocolError::CorruptDescriptor { slot_index: 0 }); + assert!(result.unwrap_err() == ProtocolError { slot_index: 0 }); } #[test] diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs index 570888f8c..2673d1922 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs @@ -35,7 +35,6 @@ const ABORTED: u64 = 1 << 63; #[derive(Clone, Copy, Debug)] struct PayloadSpan { /// Byte offset of the payload from the start of the payload region. - /// Always `u64`-aligned. offset: usize, /// Exact (unpadded) byte length of the payload. len: usize, @@ -49,15 +48,9 @@ impl PayloadSpan { if len == 0 || len > layout::MAX_PAYLOAD_LEN { return None; } - // Writers reserve whole-`u64` spans from the start of the region, - // so a valid offset is `u64`-aligned and its padded length stays - // inside the region. - if !offset.is_multiple_of(layout::SLOT_LEN) { - return None; - } // `offset` and `len` come from 32-bit descriptor fields, so this // sum cannot overflow `usize`. - if offset + layout::reserved_payload_len(len) > payload_region_len { + if offset + len > payload_region_len { return None; } Some(Self { offset, len }) @@ -78,12 +71,12 @@ const fn decode(payload_region_len: usize, bits: u64) -> Option { ) } -/// Shared-memory metadata that could not have been produced by this -/// protocol. The region was corrupted; its frames are unusable. +/// A descriptor that no correct writer could have committed: the region +/// was corrupted, and its frames are unusable. #[derive(thiserror::Error, Clone, Copy, PartialEq, Eq, Debug)] -pub enum ProtocolError { - #[error("corrupt shared-memory frame descriptor at slot {slot_index}")] - CorruptDescriptor { slot_index: usize }, +#[error("corrupt shared-memory frame descriptor at slot {slot_index}")] +pub struct ProtocolError { + pub slot_index: usize, } /// A reader over the committed frames of a sealed channel, serving them @@ -196,7 +189,7 @@ impl ShmReader { // Any other terminal value must be a committed descriptor // with a valid span; a foreign scribble fails the decode. if decode(mapped.payloads.len(), bits).is_none() { - return Err(ProtocolError::CorruptDescriptor { slot_index }); + return Err(ProtocolError { slot_index }); } frames += 1; } @@ -300,16 +293,15 @@ mod tests { #[test] fn payload_span_validates_bounds() { let region = 1024; - // A `u64`-aligned span at the region start. + // Any byte range inside the region, at any offset. assert!(PayloadSpan::validate(region, 0, 8).is_some()); - // Exact end of the region, with padding inside it. - assert!(PayloadSpan::validate(region, region - 8, 5).is_some()); + assert!(PayloadSpan::validate(region, 3, 5).is_some()); + // A span ending exactly at the region end. + assert!(PayloadSpan::validate(region, region - 5, 5).is_some()); // Zero length is never committed. assert!(PayloadSpan::validate(region, 0, 0).is_none()); - // Padded length may not cross the end of the region. + // The span may not cross the end of the region. assert!(PayloadSpan::validate(region, region - 8, 9).is_none()); - // Unaligned offsets cannot come from a correct writer. - assert!(PayloadSpan::validate(region, 4, 4).is_none()); // Oversized lengths are rejected before any arithmetic. assert!(PayloadSpan::validate(region, 0, layout::MAX_PAYLOAD_LEN + 1).is_none()); } diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs index 7f15da8ad..99720aad9 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs @@ -110,17 +110,15 @@ impl ShmWriter { if payload_len > layout::MAX_PAYLOAD_LEN { return Err(report_loss()); } - let reserved_len = layout::reserved_payload_len(payload_len); - // Payload bytes first, so a payload-capacity failure does not burn a // slot. A failed reservation stays counted — overshoot is harmless // because the counter is not what locates payloads (descriptors are) // and a `u64` cannot realistically wrap. let payload_start = - mapped.header().payload_reserved.fetch_add(reserved_len as u64, Ordering::Relaxed); + mapped.header().payload_reserved.fetch_add(payload_len as u64, Ordering::Relaxed); // Checked: a foreign scribble of the counter must fail the claim, // not wrap the bound into an out-of-bounds reservation. - let payload_end = payload_start.checked_add(reserved_len as u64); + let payload_end = payload_start.checked_add(payload_len as u64); if payload_end.is_none_or(|end| end > mapped.payloads.len() as u64) { return Err(report_loss()); } @@ -138,11 +136,12 @@ impl ShmWriter { } // SAFETY: the claim reserved - // `[payload_start, payload_start + reserved_len)` — inside the + // `[payload_start, payload_start + payload_len)` — inside the // payload region by the capacity check above — exclusively for this - // frame: other writers reserve disjoint spans, and the receiver - // never reads a payload before observing its committed descriptor, - // which `finish` publishes only when it consumes this borrow. + // frame: other writers reserve disjoint (if byte-adjacent) spans, + // and the receiver never reads a payload before observing its + // committed descriptor, which `finish` publishes only when it + // consumes this borrow. let content = unsafe { slice::from_raw_parts_mut(mapped.payloads.cast::().add(payload_start), payload_len) }; From c0a6ab92a8f6947c4ae11135caacf8ee42142ed5 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sat, 15 Aug 2026 21:10:43 +0800 Subject: [PATCH 42/92] refactor(fspy-shm): capacity is the region length again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Payload-budget capacity existed to keep small test capacities working under the huge compile-time table, but region-length capacity is less code — the channel fail-fasts through the existing supported-length check instead of computing a file size — and it puts the production region back at exactly 4 GiB with the same ~3.5 GiB payload area the eighth rule produced, so CI and the benchmarks compare like for like. Channel tests pass a sparse GiB instead of a few KiB. Co-Authored-By: Claude Fable 5 --- crates/fspy/src/ipc.rs | 6 ++-- crates/fspy_shared/src/ipc/channel/mod.rs | 31 ++++++++++++------- .../src/ipc/channel/shm_io/README.md | 6 ++-- .../fspy_shared/src/ipc/channel/shm_io/mod.rs | 15 +-------- 4 files changed, 26 insertions(+), 32 deletions(-) diff --git a/crates/fspy/src/ipc.rs b/crates/fspy/src/ipc.rs index ae49b1246..4220483ad 100644 --- a/crates/fspy/src/ipc.rs +++ b/crates/fspy/src/ipc.rs @@ -5,9 +5,9 @@ use fspy_shared::ipc::{ channel::{Frames, Receiver}, }; -// Payload budget for path-access records; the channel adds its fixed -// descriptor table on top. 4 GiB is large enough for almost any realistic -// scenario, and none of it occupies physical memory until actually used. +// Shared memory region size: the channel's fixed descriptor table plus +// ~3.5 GiB of payload room — enough path accesses for almost any realistic +// scenario. None of it occupies physical memory until actually used. pub const SHM_CAPACITY: usize = 4 * 1024 * 1024 * 1024; /// The path accesses a run reported through the IPC channel. diff --git a/crates/fspy_shared/src/ipc/channel/mod.rs b/crates/fspy_shared/src/ipc/channel/mod.rs index d37cbb546..b13ff8ef3 100644 --- a/crates/fspy_shared/src/ipc/channel/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/mod.rs @@ -47,14 +47,19 @@ pub struct ChannelConf { /// Creates a mpsc IPC channel with one receiver and a `ChannelConf` that can be passed around processes and used to create multiple senders. /// -/// `capacity` is the payload budget: the region is sized to the -/// compile-time descriptor table plus that many payload bytes. Senders -/// need no configuration beyond the `ChannelConf` — the layout is the +/// `capacity` is the region size in bytes; it must hold the compile-time +/// descriptor table, and the rest of it is payload room. Senders need no +/// configuration beyond the `ChannelConf` — the layout is the /// compile-time table plus whatever the mapped file's size says. #[expect(clippy::missing_errors_doc, reason = "non-vt crate: cannot use vt_str/vt_path types")] pub fn channel(capacity: usize) -> io::Result<(ChannelConf, Receiver)> { - // The region: the compile-time fixed struct plus the payload budget. - let capacity = shm_io::region_len::(capacity); + // Fail fast on a capacity the compile-time table cannot fit into. + if !shm_io::is_supported_region_len::(capacity) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "capacity cannot host the channel's descriptor table", + )); + } let shm_c_path = os_c_string(shm_backing_path()?.as_os_str())?; let handle = fspy_shm::create(shm_c_path.as_c_str().as_thin(), capacity).map_err(shm_error_to_io)?; @@ -311,12 +316,15 @@ mod tests { use super::*; + /// Any test region must hold the compile-time table; sparse, so cheap. + const GIB: usize = 1 << 30; + /// The shared-memory path is generated absolute, so a sender in a process /// with a different working directory and a relative temporary directory /// must still attach. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn sender_ignores_changed_temp_and_working_directory() { - let (conf, receiver) = channel(4096).unwrap(); + let (conf, receiver) = channel(GIB).unwrap(); let changed_cwd = temp_dir().join(format!("fspy-ipc-changed-cwd-{}", Uuid::new_v4())); fs::create_dir(&changed_cwd).unwrap(); @@ -342,7 +350,7 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn smoke() { - let (conf, receiver) = channel(4096).unwrap(); + let (conf, receiver) = channel(GIB).unwrap(); let cmd = command_for_fn!(conf, |conf: ChannelConf| { let sender = conf.sender().unwrap(); let frame_size = NonZeroUsize::new(2).unwrap(); @@ -365,7 +373,7 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[expect(clippy::print_stdout, reason = "test diagnostics")] async fn forbid_new_senders_after_close() { - let (conf, receiver) = channel(4096).unwrap(); + let (conf, receiver) = channel(GIB).unwrap(); let _frames = receiver.close().unwrap(); let cmd = command_for_fn!(conf, |conf: ChannelConf| { @@ -378,7 +386,7 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[expect(clippy::print_stdout, reason = "test diagnostics")] async fn forbid_new_senders_after_receiver_dropped() { - let (conf, receiver) = channel(4096).unwrap(); + let (conf, receiver) = channel(GIB).unwrap(); drop(receiver); let cmd = command_for_fn!(conf, |conf: ChannelConf| { @@ -392,7 +400,7 @@ mod tests { /// claim any new frame afterwards. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn attached_sender_cannot_claim_after_close() { - let (conf, receiver) = channel(4096).unwrap(); + let (conf, receiver) = channel(GIB).unwrap(); let sender = conf.sender().unwrap(); let mut frame = sender.writer.claim_frame(NonZeroUsize::new(2).unwrap()).unwrap(); @@ -411,8 +419,7 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn concurrent_senders() { - // 64 KiB of payload room for the 200 frames sent below. - let (conf, receiver) = channel(64 * 1024).unwrap(); + let (conf, receiver) = channel(GIB).unwrap(); for i in 0u16..200 { let cmd = command_for_fn!((conf.clone(), i), |(conf, i): (ChannelConf, u16)| { let sender = conf.sender().unwrap(); diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/README.md b/crates/fspy_shared/src/ipc/channel/shm_io/README.md index 2bd0afa87..0e01339f9 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/README.md +++ b/crates/fspy_shared/src/ipc/channel/shm_io/README.md @@ -45,9 +45,9 @@ The payload area is simply the rest of the mapping, so the whole geometry reduces to one number — the struct's size — and attaching checks a single bound: the struct must fit inside the mapping, leaving a payload area the descriptors' 32-bit offsets can address. The production -channel uses ~67 million slots; a 4 GiB payload budget holds ~15–20 -million records of a few hundred bytes, so payload space runs out -first. +channel uses ~67 million slots in a 4 GiB region; the ~3.5 GiB payload +area holds ~15–20 million records of a few hundred bytes, so payload +space runs out first. The line between table space and payload space never moves. That is what keeps claiming free of retry loops: each counter is checked against a diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs index 021d43868..eb4972803 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs @@ -117,26 +117,13 @@ impl AsRawSlice for Mapping { /// /// Senders opening a file they do not control should refuse unsupported /// lengths with an error; the protocol's own constructors treat them as a -/// broken caller and panic. Creators size the file with [`region_len`]. +/// broken caller and panic. #[must_use] pub const fn is_supported_region_len(len: usize) -> bool { size_of::>() <= len && len - size_of::>() <= layout::MAX_PAYLOAD_REGION_LEN } -/// The region length for a channel with `SLOTS` descriptor slots and up -/// to `payload_capacity` payload bytes (clamped to the payload area's -/// 32-bit-offset maximum). -#[must_use] -pub const fn region_len(payload_capacity: usize) -> usize { - let payload = if payload_capacity < layout::MAX_PAYLOAD_REGION_LEN { - payload_capacity - } else { - layout::MAX_PAYLOAD_REGION_LEN - }; - size_of::>() + payload -} - /// Materializes the page backing the protocol header without changing /// protocol state, so that neither a writer's first claim nor /// [`ShmReader::seal`]'s snapshot pays for the backing file's first From 612e93eadccdbe337c9aeeaba46e4ef144ba1804 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sat, 15 Aug 2026 21:18:37 +0800 Subject: [PATCH 43/92] refactor(fspy-shm): flatten the counters into Meta; drop the reserve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Header existed to be reached through: every consumer immediately took .claims or .payload_reserved out of it. Meta is now literally the protocol's fixed part — two counters, then the slots — and MappedLayout exposes claims()/payload_reserved() directly. The _reserved padding goes with it. Its compatibility half was a fossil of file-format thinking: both endpoints compile from one crate and a region never outlives its channel, so there is no version boundary to reserve space across. Its cache-line half shielded only the first six slots' commits from the counters' line, for the first microseconds of a channel that then runs for a whole build — speculative padding of the kind this series has been deleting. If a benchmark ever shows the false sharing, one padding field brings it back. Checked and kept in the same sweep: the channel-level Send/Sync impls (Windows Mapping has no auto impls, so they are the cross-platform seam), and the infallible-on-64-bit try_from guards (the 64-bit-target boundary, not dead defensiveness). Co-Authored-By: Claude Fable 5 --- .../src/ipc/channel/shm_io/README.md | 22 ++++---- .../src/ipc/channel/shm_io/layout.rs | 56 +++++++++---------- .../fspy_shared/src/ipc/channel/shm_io/mod.rs | 28 +++++----- .../src/ipc/channel/shm_io/reader.rs | 4 +- .../src/ipc/channel/shm_io/writer.rs | 8 +-- 5 files changed, 58 insertions(+), 60 deletions(-) diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/README.md b/crates/fspy_shared/src/ipc/channel/shm_io/README.md index 0e01339f9..07fc49602 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/README.md +++ b/crates/fspy_shared/src/ipc/channel/shm_io/README.md @@ -27,13 +27,13 @@ file mapped into every participating process. A sparse mapping is address space, not memory: only pages that are actually written get backed. ```text -| header (64 B) | descriptor table (SLOTS slots) | payloads (the rest, grow up) | +| counters | descriptor table (SLOTS slots) | payloads (the rest, grow up) | ``` -The header and the table together are one `repr(C)` struct: the header — -two `AtomicU64` counters, which only ever count up — followed by one -8-byte descriptor slot per frame. The table length is a compile-time -constant the channel picks once for both ends: +The region starts with one `repr(C)` struct: two `AtomicU64` counters, +which only ever count up, followed by one 8-byte descriptor slot per +frame. The table length is a compile-time constant the channel picks +once for both ends: - the **claim counter** — how many frames were ever claimed. Bit 63 is the CLOSED gate, set by the receiver when it closes the channel — and @@ -182,12 +182,12 @@ CLAIMED (slot 0) ---+ ## Files -| File | Role | -| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `mod.rs` | Public surface (`ShmWriter`, `ShmReader`), the protocol overview docs, and the integration tests — they run against a mocked region and are miri-clean (`cargo miri test -p fspy_shared shm_io`). | -| `writer.rs` | The writer side: claim a frame, fill it, finish it. | -| `reader.rs` | The reader side: seal the channel, then iterate the committed frames — with the argument for why its borrows are sound. | -| `layout.rs` | Only what both sides share: the region's shape and sizing math, the header, the descriptor format, and `MappedLayout` — the shape bound to one concrete mapping. Encoding lives with the writer, decoding with the reader. | +| File | Role | +| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `mod.rs` | Public surface (`ShmWriter`, `ShmReader`), the protocol overview docs, and the integration tests — they run against a mocked region and are miri-clean (`cargo miri test -p fspy_shared shm_io`). | +| `writer.rs` | The writer side: claim a frame, fill it, finish it. | +| `reader.rs` | The reader side: seal the channel, then iterate the committed frames — with the argument for why its borrows are sound. | +| `layout.rs` | Only what both sides share: the region's `repr(C)` shape — counters, then slots — the descriptor format, and `MappedLayout`, that shape bound to one concrete mapping. Encoding lives with the writer, decoding with the reader. | Arrows point at what a file depends on: diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs index 1c68b5888..d24030b7b 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs @@ -3,10 +3,10 @@ //! The region is divided into three fixed areas: //! //! ```text -//! | header | descriptor table | payloads (grow up) | +//! | counters | descriptor table | payloads (grow up) | //! ``` //! -//! The header and the table have compile-time shape: one `repr(C)` +//! The counters and the table have compile-time shape: one `repr(C)` //! [`Meta`] struct whose table length is a const parameter the channel //! specifies. The payload area is simply the rest of the mapping, so the //! whole geometry reduces to that struct's size. This module holds the @@ -29,32 +29,23 @@ use std::{ptr::NonNull, sync::atomic::AtomicU64}; /// into the gate. pub(super) const CLOSED: u64 = 1 << 63; -/// The region header: two protocol counters, padded so the descriptor -/// table starts off their cache line and there is room for future header -/// fields, which must start zeroed. +/// The region's fixed-location part — the protocol counters and the +/// descriptor table — as one `repr(C)` struct, which must start zeroed. +/// The payload area is simply the rest of the mapping. #[repr(C)] -pub(super) struct Header { +pub(super) struct Meta { /// Bit 63 is the CLOSED gate; the low bits count claims ever attempted. pub(super) claims: AtomicU64, /// Payload bytes ever reserved, including by failed claims. pub(super) payload_reserved: AtomicU64, - _reserved: [u64; 6], -} - -// One cache line: shrink `_reserved` when adding a field. The alignment is -// what lets a `u64`-aligned mapping base be cast to `&Meta`. -const _: () = assert!(size_of::
() == 64); -const _: () = assert!(align_of::
() == align_of::()); - -/// The region's fixed-location part — the header and the descriptor -/// table — as one `repr(C)` struct. The payload area is simply the rest -/// of the mapping. -#[repr(C)] -pub(super) struct Meta { - pub(super) header: Header, + /// One descriptor slot per frame. pub(super) table: [AtomicU64; SLOTS], } +// The `u64`-aligned mapping base is cast to `&Meta`; nothing in it may +// raise the alignment. +const _: () = assert!(align_of::>() == align_of::()); + /// Maximum payload size of a single frame. /// /// Committed lengths are stored in the 31-bit length field of a descriptor, @@ -99,7 +90,7 @@ pub(super) const OFFSET_MAX: u64 = u32::MAX as u64; // --- The mapped layout and the ordering contract --------------------------- // `MappedLayout::new` builds two typed views of the region — the -// `repr(C)` `Meta` struct (header and descriptor table) and the untyped +// `repr(C)` `Meta` struct (counters and descriptor table) and the untyped // payload area as a raw slice — once, when an endpoint attaches; the // endpoint stores them beside the mapping they point into. Every access // after that is a plain field access or a bounds-checked index. The @@ -108,7 +99,7 @@ pub(super) const OFFSET_MAX: u64 = u32::MAX as u64; // // # Shared atomics // -// The header holds two independent monotonic `AtomicU64` counters: +// The region starts with two independent monotonic `AtomicU64` counters: // // - the **claim counter**: bit 63 is the CLOSED gate, the low bits count // claims ever attempted. Claiming is one wait-free `fetch_add`; the @@ -213,15 +204,20 @@ impl MappedLayout { /// The fixed-location part of the region. const fn meta(&self) -> &Meta { // SAFETY: `new`'s contract keeps the target valid while any view - // is used, and `Meta` consists of atomics, so the shared borrow - // is valid even while other threads and processes access the same - // memory through them. + // is used, and `Meta` consists entirely of atomics, so the shared + // borrow is valid even while other threads and processes access + // the same memory through them. unsafe { self.meta.as_ref() } } - /// The protocol header. - pub(super) const fn header(&self) -> &Header { - &self.meta().header + /// The claim counter. + pub(super) const fn claims(&self) -> &AtomicU64 { + &self.meta().claims + } + + /// The payload counter. + pub(super) const fn payload_reserved(&self) -> &AtomicU64 { + &self.meta().payload_reserved } /// The descriptor table. @@ -237,8 +233,8 @@ mod tests { use super::*; #[test] - fn meta_is_the_header_then_the_table() { - assert!(size_of::>() == 64 + 15 * size_of::()); + fn meta_is_the_counters_then_the_table() { + assert!(size_of::>() == (2 + 15) * size_of::()); assert!(align_of::>() == align_of::()); } } diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs index eb4972803..985f2b93d 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs @@ -12,19 +12,19 @@ //! //! ```text //! low addresses high addresses -//! +--------+--------+--------+---------+-----------+-----------+------+ -//! | header | slot 0 | slot 1 | ... | payload 0 | payload 1 | ... | -//! +--------+--------+--------+---------+-----------+-----------+------+ -//! fixed descriptor table payloads grow up -> +//! +----------+--------+--------+---------+-----------+-----------+------+ +//! | counters | slot 0 | slot 1 | ... | payload 0 | payload 1 | ... | +//! +----------+--------+--------+---------+-----------+-----------+------+ +//! fixed descriptor table payloads grow up -> //! ``` //! -//! The header and the descriptor table form one `repr(C)` struct whose +//! The counters and the descriptor table form one `repr(C)` struct whose //! table length is a compile-time constant every endpoint shares (the //! channel specifies it); the payload area is simply the rest of the //! mapping ([`layout`]). Attaching constructs typed views of that struct -//! — the header is two monotonic `AtomicU64` counters: claims, carrying -//! the CLOSED gate bit, and payload bytes reserved — while the payload -//! area stays untyped bytes. +//! — two monotonic `AtomicU64` counters: claims, carrying the CLOSED +//! gate bit, and payload bytes reserved — while the payload area stays +//! untyped bytes. //! A claim is two wait-free `fetch_add`s — one reserves payload bytes, one //! reserves a descriptor slot — validated against the fixed region bounds //! from the returned old values. A failed claim sets the CLOSED gate as @@ -124,8 +124,8 @@ pub const fn is_supported_region_len(len: usize) -> bool { && len - size_of::>() <= layout::MAX_PAYLOAD_REGION_LEN } -/// Materializes the page backing the protocol header without changing -/// protocol state, so that neither a writer's first claim nor +/// Materializes the region's first page without changing protocol +/// state, so that neither a writer's first claim nor /// [`ShmReader::seal`]'s snapshot pays for the backing file's first /// block allocation — a millisecond-scale cost on some journalling /// filesystems, for reads of holes as well as writes. Run it off any @@ -146,7 +146,7 @@ pub unsafe fn pre_fault(mem: &impl AsRawSlice) { // exchange changes nothing. (An `or` of zero would not do: the // compiler may lower it to a plain load, which materializes only a // hole page without allocating the block.) - let _ = mapped.header().claims.compare_exchange(0, 0, Ordering::Relaxed, Ordering::Relaxed); + let _ = mapped.claims().compare_exchange(0, 0, Ordering::Relaxed, Ordering::Relaxed); } #[cfg(test)] @@ -628,7 +628,8 @@ mod tests { // Point slot 0 at a span escaping the payload region. let bogus_len = 8u64; let bogus_offset = 1020u64; - shm.poke_u64(64, (bogus_len << 32) | bogus_offset); + // Slot 0 sits right after the two counters. + shm.poke_u64(16, (bogus_len << 32) | bogus_offset); // SAFETY: see `collect_frames`. let result = unsafe { ShmReader::<_, S>::seal(shm) }; @@ -644,7 +645,8 @@ mod tests { // The aborted bit combined with payload bits is a value no protocol // operation produces. - shm.poke_u64(64, (1 << 63) | (8u64 << 32) | 8); + // Slot 0 sits right after the two counters. + shm.poke_u64(16, (1 << 63) | (8u64 << 32) | 8); // SAFETY: see `collect_frames`. let result = unsafe { ShmReader::<_, S>::seal(shm) }; diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs index 2673d1922..01c0b8f50 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs @@ -147,7 +147,7 @@ impl ShmReader { // never visits. The count is clamped to the table capacity, so // a counter inflated by failed claims (or by a foreign // scribble) degrades to a full-table sweep, not an error. - let claims = mapped.header().claims.load(Ordering::Relaxed); + let claims = mapped.claims().load(Ordering::Relaxed); slot_count = usize::try_from(claims & !CLOSED).unwrap_or(usize::MAX).min(SLOTS); // The same load carries the completeness verdict: a gate set // before this boundary is a failed claim's loss report — or an @@ -160,7 +160,7 @@ impl ShmReader { // this page where first touches are expensive. Claims racing // between the snapshot and this gate are dropped soundly (see // the module docs above). - mapped.header().claims.fetch_or(CLOSED, Ordering::Relaxed); + mapped.claims().fetch_or(CLOSED, Ordering::Relaxed); // Freeze pass: drive every admitted slot to a terminal state // and validate the committed descriptors. After this loop the diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs index 99720aad9..eb0c106c0 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs @@ -77,7 +77,7 @@ impl ShmWriter { /// Whether the CLOSED gate is set: the receiver sealed the channel, /// or an earlier failed claim condemned it. pub fn is_closed(&self) -> bool { - self.mapped.header().claims.load(Ordering::Relaxed) & CLOSED != 0 + self.mapped.claims().load(Ordering::Relaxed) & CLOSED != 0 } /// Claims a frame of exactly `frame_size` bytes. @@ -100,7 +100,7 @@ impl ShmWriter { // moves on (rule 1): the gate makes the receiver report the // channel incomplete, and condemns further claims. let report_loss = || { - mapped.header().claims.fetch_or(CLOSED, Ordering::Relaxed); + mapped.claims().fetch_or(CLOSED, Ordering::Relaxed); ClaimError::Capacity }; @@ -115,7 +115,7 @@ impl ShmWriter { // because the counter is not what locates payloads (descriptors are) // and a `u64` cannot realistically wrap. let payload_start = - mapped.header().payload_reserved.fetch_add(payload_len as u64, Ordering::Relaxed); + mapped.payload_reserved().fetch_add(payload_len as u64, Ordering::Relaxed); // Checked: a foreign scribble of the counter must fail the claim, // not wrap the bound into an out-of-bounds reservation. let payload_end = payload_start.checked_add(payload_len as u64); @@ -124,7 +124,7 @@ impl ShmWriter { } let payload_start = usize::try_from(payload_start).expect("bounded by the payload region"); - let claims = mapped.header().claims.fetch_add(1, Ordering::Relaxed); + let claims = mapped.claims().fetch_add(1, Ordering::Relaxed); if claims & CLOSED != 0 { // Not a loss: a record refused after the seal describes an // operation performed outside the channel's boundary. From 2ce915cdb25c62fdecff2cb69eb77194f733147f Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sat, 15 Aug 2026 21:29:05 +0800 Subject: [PATCH 44/92] refactor(fspy-shm): parse descriptor fields, don't validate them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The frame limit was a named constant compared against on both sides, then converted anyway — validate-then-convert where the conversion alone answers the question. The 31-bit length field's honest spelling is i32::try_from: the writer's oversize check becomes that conversion (MAX_PAYLOAD_LEN deleted), the offset conversion narrows to u32 — the actual field width — and committed's arguments become the field types, replacing its debug_asserts. On the reader, decode reverses the same conversions: one i32::try_from refuses the aborted bit, an unfinished zero, and oversize alike, and PayloadSpan::validate dissolves into decode, its tests re-expressed as wire values. Co-Authored-By: Claude Fable 5 --- .../src/ipc/channel/shm_io/layout.rs | 6 -- .../src/ipc/channel/shm_io/reader.rs | 74 ++++++++----------- .../src/ipc/channel/shm_io/writer.rs | 30 ++++---- 3 files changed, 46 insertions(+), 64 deletions(-) diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs index d24030b7b..03edbad60 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs @@ -46,12 +46,6 @@ pub(super) struct Meta { // raise the alignment. const _: () = assert!(align_of::>() == align_of::()); -/// Maximum payload size of a single frame. -/// -/// Committed lengths are stored in the 31-bit length field of a descriptor, -/// which caps them at `i32::MAX`. -pub(super) const MAX_PAYLOAD_LEN: usize = i32::MAX as usize; - /// Maximum payload-region size. /// /// Descriptors store payload offsets in 32 bits, so the payload region diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs index 01c0b8f50..af7271869 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs @@ -29,9 +29,8 @@ const ABORTED: u64 = 1 << 63; /// A validated payload byte range: the witness that offset arithmetic on this /// span cannot leave the payload region. /// -/// Constructing a `PayloadSpan` through [`PayloadSpan::validate`] is the -/// single validation point for descriptor metadata read back from shared -/// memory; code holding a span may rely on its bounds without re-checking. +/// [`decode`] is the only constructor, so code holding a span may rely on +/// its bounds without re-checking. #[derive(Clone, Copy, Debug)] struct PayloadSpan { /// Byte offset of the payload from the start of the payload region. @@ -40,35 +39,26 @@ struct PayloadSpan { len: usize, } -impl PayloadSpan { - /// Validates a committed descriptor's payload range against a payload - /// region of `payload_region_len` bytes. Returns `None` if the range - /// could not have been produced by a correct writer. - const fn validate(payload_region_len: usize, offset: usize, len: usize) -> Option { - if len == 0 || len > layout::MAX_PAYLOAD_LEN { - return None; - } - // `offset` and `len` come from 32-bit descriptor fields, so this - // sum cannot overflow `usize`. - if offset + len > payload_region_len { - return None; - } - Some(Self { offset, len }) - } -} - /// Decodes a slot value read back from shared memory as a committed /// descriptor (the slot codec in [`layout`]). Returns `None` for any value /// that is not one a correct writer could have committed for a payload -/// region of `payload_region_len` bytes: an unfinished `0` decodes a zero -/// length, and any value carrying the aborted bit decodes a length beyond -/// the frame limit, so both fail validation exactly like a scribble. -const fn decode(payload_region_len: usize, bits: u64) -> Option { - PayloadSpan::validate( - payload_region_len, - (bits & layout::OFFSET_MAX) as usize, - (bits >> layout::LEN_SHIFT) as usize, - ) +/// region of `payload_region_len` bytes. +fn decode(payload_region_len: usize, bits: u64) -> Option { + // Reverse the writer's conversions: the length field is a nonzero + // 31-bit value — `i32::try_from` refuses the aborted bit, an + // unfinished `0`'s zero length, and oversize alike — and the offset + // field is the low 32 bits. + let len = i32::try_from(bits >> layout::LEN_SHIFT).ok()?; + if len == 0 { + return None; + } + let len = len.cast_unsigned() as usize; + let offset = (bits & layout::OFFSET_MAX) as usize; + // Both fields are 32 bits wide, so this sum cannot overflow `usize`. + if offset + len > payload_region_len { + return None; + } + Some(PayloadSpan { offset, len }) } /// A descriptor that no correct writer could have committed: the region @@ -291,19 +281,16 @@ mod tests { use super::{super::writer::committed, *}; #[test] - fn payload_span_validates_bounds() { + fn decode_validates_bounds() { let region = 1024; // Any byte range inside the region, at any offset. - assert!(PayloadSpan::validate(region, 0, 8).is_some()); - assert!(PayloadSpan::validate(region, 3, 5).is_some()); + assert!(decode(region, committed(0, 8)).is_some()); + assert!(decode(region, committed(3, 5)).is_some()); // A span ending exactly at the region end. - assert!(PayloadSpan::validate(region, region - 5, 5).is_some()); - // Zero length is never committed. - assert!(PayloadSpan::validate(region, 0, 0).is_none()); + let span = decode(region, committed(1019, 5)).unwrap(); + assert!(span.offset == 1019 && span.len == 5); // The span may not cross the end of the region. - assert!(PayloadSpan::validate(region, region - 8, 9).is_none()); - // Oversized lengths are rejected before any arithmetic. - assert!(PayloadSpan::validate(region, 0, layout::MAX_PAYLOAD_LEN + 1).is_none()); + assert!(decode(region, committed(1016, 9)).is_none()); } #[test] @@ -312,12 +299,13 @@ mod tests { let span = decode(region, committed(0, 5)).unwrap(); assert!(span.offset == 0 && span.len == 5); - // The extremes of the descriptor fields on the largest mapping: the + // The extremes of the descriptor fields on the largest region: the // 31-bit length limit, and a span ending exactly at the region end. let region = layout::MAX_PAYLOAD_REGION_LEN; - let span = decode(region, committed(0, layout::MAX_PAYLOAD_LEN)).unwrap(); - assert!(span.offset == 0 && span.len == layout::MAX_PAYLOAD_LEN); - let span = decode(region, committed(region - 8, 8)).unwrap(); + let span = decode(region, committed(0, i32::MAX.cast_unsigned())).unwrap(); + assert!(span.offset == 0 && span.len == i32::MAX as usize); + let last = u32::try_from(region - 8).unwrap(); + let span = decode(region, committed(last, 8)).unwrap(); assert!(span.offset == region - 8 && span.len == 8); } @@ -328,7 +316,7 @@ mod tests { assert!(decode(region, layout::UNFINISHED).is_none()); assert!(decode(region, ABORTED).is_none()); // The aborted bit combined with other bits: the length field then - // exceeds the frame limit. + // fails `i32::try_from` — as does any oversized length. assert!(decode(region, ABORTED | 1).is_none()); assert!(decode(region, ABORTED | (1 << 62)).is_none()); // A zero length with a nonzero offset. diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs index eb0c106c0..ff0c3027f 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs @@ -104,12 +104,12 @@ impl ShmWriter { ClaimError::Capacity }; - // No descriptor can describe a payload this long; refuse it before - // touching the counters, so the channel keeps working for every - // record after it. - if payload_len > layout::MAX_PAYLOAD_LEN { + // The descriptor's 31-bit length field is the oversize check: + // refuse, before touching the counters, a frame it cannot + // describe — the channel keeps working for every record after it. + let Ok(encoded_len) = i32::try_from(payload_len) else { return Err(report_loss()); - } + }; // Payload bytes first, so a payload-capacity failure does not burn a // slot. A failed reservation stays counted — overshoot is harmless // because the counter is not what locates payloads (descriptors are) @@ -122,7 +122,10 @@ impl ShmWriter { if payload_end.is_none_or(|end| end > mapped.payloads.len() as u64) { return Err(report_loss()); } - let payload_start = usize::try_from(payload_start).expect("bounded by the payload region"); + // Bounded by the capacity check: the payload region fits 32-bit + // offsets. + let payload_offset = u32::try_from(payload_start).expect("bounded by the payload region"); + let payload_start = payload_offset as usize; let claims = mapped.claims().fetch_add(1, Ordering::Relaxed); if claims & CLOSED != 0 { @@ -148,7 +151,7 @@ impl ShmWriter { Ok(FrameMut { mapped, slot_index, - descriptor: committed(payload_start, payload_len), + descriptor: committed(payload_offset, encoded_len.cast_unsigned()), content, }) } @@ -230,12 +233,9 @@ impl FrameMut<'_, SLOTS> { } } -/// Encodes a committed descriptor (the slot codec in [`layout`]). -/// -/// The caller guarantees `payload_len` is `1..=MAX_PAYLOAD_LEN` and -/// `payload_offset` fits 32 bits; both hold for any admitted reservation. -pub(super) fn committed(payload_offset: usize, payload_len: usize) -> u64 { - debug_assert!(payload_len > 0 && payload_len <= layout::MAX_PAYLOAD_LEN); - debug_assert!(payload_offset as u64 <= layout::OFFSET_MAX); - ((payload_len as u64) << layout::LEN_SHIFT) | payload_offset as u64 +/// Encodes a committed descriptor (the slot codec in [`layout`]). The +/// argument types are the field widths; the length came through +/// `i32::try_from`, so bit 63 stays clear. +pub(super) fn committed(payload_offset: u32, payload_len: u32) -> u64 { + (u64::from(payload_len) << layout::LEN_SHIFT) | u64::from(payload_offset) } From 21ac689c7e94dc7e42670e8a23a819d2ca61442c Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sat, 15 Aug 2026 21:35:11 +0800 Subject: [PATCH 45/92] refactor(fspy-shm): give the length field its full 32 bits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The i32 detour existed only because ABORTED sat on bit 63, stealing one bit from the length field above it. But the aborted value is the protocol's own choice, and any value with a zero length field is unmistakable — committed lengths are never zero — so ABORTED is now 1: a zero length with offset one, which no writer commits. With bit 63 freed, the descriptor is simply two u32 halves. The writer's oversize check is u32::try_from, decode's extraction is two truncations and a zero test, PayloadSpan stores the field types themselves (usize appears only at the pointer boundary), and the frame limit rises from 2 GiB to u32::MAX bytes. Co-Authored-By: Claude Fable 5 --- .../src/ipc/channel/shm_io/README.md | 6 +-- .../src/ipc/channel/shm_io/layout.rs | 15 +++--- .../fspy_shared/src/ipc/channel/shm_io/mod.rs | 9 ++-- .../src/ipc/channel/shm_io/reader.rs | 46 +++++++++---------- .../src/ipc/channel/shm_io/writer.rs | 15 +++--- 5 files changed, 44 insertions(+), 47 deletions(-) diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/README.md b/crates/fspy_shared/src/ipc/channel/shm_io/README.md index 07fc49602..eb6c88e17 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/README.md +++ b/crates/fspy_shared/src/ipc/channel/shm_io/README.md @@ -113,9 +113,9 @@ channel: every later claim is refused. That refusal costs nothing — `is_complete` is already false, so the result must be thrown away, and any further records would ride a result nobody can use. -One more limit: a single frame holds at most 2 GiB, because a descriptor -cannot describe more. Such a claim is refused — and reported — the same -way. +One more limit: a single frame holds at most `u32::MAX` bytes, because a +descriptor cannot describe more. Such a claim is refused — and reported — +the same way. ## Sealing and reading diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs index 03edbad60..cb96156c6 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs @@ -57,19 +57,20 @@ pub(super) const MAX_PAYLOAD_REGION_LEN: usize = 1 << 32; // One slot is a 64-bit value that publishes a frame: // // ```text -// bit 63 bits 32..=62 bits 0..=31 -// ABORTED payload length (31) payload offset (32) +// bits 32..=63 bits 0..=31 +// payload length (32) payload offset (32) // ``` // // | Value | State | // | --------------------- | ----------------------------------------------- | // | `0` | Unfinished: slot reserved, nothing published | -// | `1 << 63` | Aborted: the receiver froze the unfinished slot | -// | nonzero, bit 63 clear | Committed: offset and length of the payload | +// | `1` | Aborted: the receiver froze the unfinished slot | +// | length field nonzero | Committed: offset and length of the payload | // -// Committed lengths are nonzero (a zero-length frame is never claimed), so a -// committed value is always nonzero and the three states are disjoint. Once -// a slot is committed or aborted, nothing ever changes it again. +// Committed lengths are nonzero (a zero-length frame is never claimed), so +// a committed value's length field is nonzero and the three states are +// disjoint: `1` is a zero length with offset `1`, which no writer commits. +// Once a slot is committed or aborted, nothing ever changes it again. // // Offsets are measured from the start of the payload area, so no // descriptor can even name the header or the table. diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs index 985f2b93d..ca5fd1c92 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs @@ -305,7 +305,7 @@ mod tests { // No descriptor can describe a frame this long: the claim is // refused and sets the gate, condemning later claims — their // records would ride a result the receiver must already reject. - let oversized = ((i32::MAX as usize) + 1).try_into().unwrap(); + let oversized = ((u32::MAX as usize) + 1).try_into().unwrap(); assert!(matches!(writer.claim_frame(oversized), Err(ClaimError::Capacity))); assert!(writer.is_closed()); assert!(!writer.try_write_frame(b"refused")); @@ -637,16 +637,15 @@ mod tests { } #[test] - fn corrupt_aborted_descriptor_is_a_protocol_error() { + fn corrupt_oversized_descriptor_is_a_protocol_error() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }; assert!(writer.try_write_frame(b"hello")); - // The aborted bit combined with payload bits is a value no protocol - // operation produces. + // A length field far beyond anything this region can hold. // Slot 0 sits right after the two counters. - shm.poke_u64(16, (1 << 63) | (8u64 << 32) | 8); + shm.poke_u64(16, (1 << 62) | (8u64 << 32) | 8); // SAFETY: see `collect_frames`. let result = unsafe { ShmReader::<_, S>::seal(shm) }; diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs index af7271869..f76b6972d 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs @@ -23,8 +23,9 @@ use super::{ }; /// The terminal value the freeze pass installs in an unfinished slot: the -/// aborted bit of the slot codec ([`layout`]). -const ABORTED: u64 = 1 << 63; +/// aborted value of the slot codec ([`layout`]): a zero length field +/// with offset `1`, which no writer ever commits. +const ABORTED: u64 = 1; /// A validated payload byte range: the witness that offset arithmetic on this /// span cannot leave the payload region. @@ -34,28 +35,26 @@ const ABORTED: u64 = 1 << 63; #[derive(Clone, Copy, Debug)] struct PayloadSpan { /// Byte offset of the payload from the start of the payload region. - offset: usize, - /// Exact (unpadded) byte length of the payload. - len: usize, + offset: u32, + /// Byte length of the payload. + len: u32, } /// Decodes a slot value read back from shared memory as a committed /// descriptor (the slot codec in [`layout`]). Returns `None` for any value /// that is not one a correct writer could have committed for a payload /// region of `payload_region_len` bytes. -fn decode(payload_region_len: usize, bits: u64) -> Option { - // Reverse the writer's conversions: the length field is a nonzero - // 31-bit value — `i32::try_from` refuses the aborted bit, an - // unfinished `0`'s zero length, and oversize alike — and the offset - // field is the low 32 bits. - let len = i32::try_from(bits >> layout::LEN_SHIFT).ok()?; +const fn decode(payload_region_len: usize, bits: u64) -> Option { + // Reverse the writer's conversions: the fields are the value's two + // halves, and a zero length — an unfinished or aborted slot — is + // never a committed descriptor. + let len = (bits >> layout::LEN_SHIFT) as u32; if len == 0 { return None; } - let len = len.cast_unsigned() as usize; - let offset = (bits & layout::OFFSET_MAX) as usize; + let offset = (bits & layout::OFFSET_MAX) as u32; // Both fields are 32 bits wide, so this sum cannot overflow `usize`. - if offset + len > payload_region_len { + if offset as usize + len as usize > payload_region_len { return None; } Some(PayloadSpan { offset, len }) @@ -248,8 +247,8 @@ impl<'a, const SLOTS: usize> Iterator for Iter<'a, SLOTS> { // for `'a` keeps the mapping alive and mapped. return Some(unsafe { slice::from_raw_parts( - self.mapped.payloads.cast::().cast_const().add(span.offset), - span.len, + self.mapped.payloads.cast::().cast_const().add(span.offset as usize), + span.len as usize, ) }); } @@ -300,13 +299,14 @@ mod tests { assert!(span.offset == 0 && span.len == 5); // The extremes of the descriptor fields on the largest region: the - // 31-bit length limit, and a span ending exactly at the region end. + // full-width length limit, and a span ending exactly at the region + // end. let region = layout::MAX_PAYLOAD_REGION_LEN; - let span = decode(region, committed(0, i32::MAX.cast_unsigned())).unwrap(); - assert!(span.offset == 0 && span.len == i32::MAX as usize); + let span = decode(region, committed(0, u32::MAX)).unwrap(); + assert!(span.offset == 0 && span.len == u32::MAX); let last = u32::try_from(region - 8).unwrap(); let span = decode(region, committed(last, 8)).unwrap(); - assert!(span.offset == region - 8 && span.len == 8); + assert!(span.offset == last && span.len == 8); } #[test] @@ -315,11 +315,9 @@ mod tests { // The non-committed slot states. assert!(decode(region, layout::UNFINISHED).is_none()); assert!(decode(region, ABORTED).is_none()); - // The aborted bit combined with other bits: the length field then - // fails `i32::try_from` — as does any oversized length. - assert!(decode(region, ABORTED | 1).is_none()); - assert!(decode(region, ABORTED | (1 << 62)).is_none()); // A zero length with a nonzero offset. assert!(decode(region, 42).is_none()); + // A length no region this size can hold. + assert!(decode(region, (2048 << 32) | 16).is_none()); } } diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs index ff0c3027f..50c0a0f95 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs @@ -44,7 +44,7 @@ pub enum ClaimError { #[error("the channel has been closed")] Closed, /// The claim was refused for space: the region was full, or the frame - /// was larger than the `i32::MAX`-byte frame limit. The loss is + /// was larger than the `u32::MAX`-byte frame limit. The loss is /// already recorded — this claim set the CLOSED gate — so the channel /// will report itself incomplete and refuse further claims. #[error("no space left in the shared-memory region")] @@ -85,7 +85,7 @@ impl ShmWriter { /// The frame is invisible to the receiver until [`FrameMut::finish`] /// commits it. Dropping the frame without finishing abandons the claim: /// the receiver ignores the slot, exactly as if the writer had died. - /// Frames larger than `i32::MAX` bytes are refused as + /// Frames larger than `u32::MAX` bytes are refused as /// [`ClaimError::Capacity`]. /// /// Wait-free: two `fetch_add`s, no retry loop (rule 1). A claim that @@ -104,10 +104,10 @@ impl ShmWriter { ClaimError::Capacity }; - // The descriptor's 31-bit length field is the oversize check: + // The descriptor's 32-bit length field is the oversize check: // refuse, before touching the counters, a frame it cannot // describe — the channel keeps working for every record after it. - let Ok(encoded_len) = i32::try_from(payload_len) else { + let Ok(encoded_len) = u32::try_from(payload_len) else { return Err(report_loss()); }; // Payload bytes first, so a payload-capacity failure does not burn a @@ -151,7 +151,7 @@ impl ShmWriter { Ok(FrameMut { mapped, slot_index, - descriptor: committed(payload_offset, encoded_len.cast_unsigned()), + descriptor: committed(payload_offset, encoded_len), content, }) } @@ -233,9 +233,8 @@ impl FrameMut<'_, SLOTS> { } } -/// Encodes a committed descriptor (the slot codec in [`layout`]). The -/// argument types are the field widths; the length came through -/// `i32::try_from`, so bit 63 stays clear. +/// Encodes a committed descriptor (the slot codec in [`layout`]): the +/// argument types are exactly the field widths. pub(super) fn committed(payload_offset: u32, payload_len: u32) -> u64 { (u64::from(payload_len) << layout::LEN_SHIFT) | u64::from(payload_offset) } From d5aaec33063917c91c13366c9b01c639aaa92887 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sat, 15 Aug 2026 21:38:58 +0800 Subject: [PATCH 46/92] refactor(fspy-shm): declare the views before the region that owns them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fields drop in declaration order, and what borrows must die before what is borrowed. MappedLayout has no drop glue today, so the old order was merely fragile rather than wrong — this makes the endpoints correct by construction instead of by that accident. Co-Authored-By: Claude Fable 5 --- crates/fspy_shared/src/ipc/channel/shm_io/reader.rs | 9 +++++---- crates/fspy_shared/src/ipc/channel/shm_io/writer.rs | 7 ++++--- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs index f76b6972d..106366763 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs @@ -73,11 +73,12 @@ pub struct ProtocolError { /// is released when the reader drops. It holds no buffer: iteration /// re-reads the frozen descriptor table, so closing allocates nothing. pub struct ShmReader { - /// Owns the region the views point into; dropped with the reader. - #[expect(dead_code, reason = "held to keep the region alive")] - mem: M, /// The layout mapped onto the owned region. mapped: MappedLayout, + /// Owns the region the views point into. Declared after them: fields + /// drop in order, and what borrows must die before what is borrowed. + #[expect(dead_code, reason = "held to keep the region alive")] + mem: M, /// Length of the frozen prefix of the descriptor table. slot_count: usize, /// Committed frames in that prefix. @@ -184,7 +185,7 @@ impl ShmReader { } } - Ok(Self { mem, mapped, slot_count, frames, complete }) + Ok(Self { mapped, mem, slot_count, frames, complete }) } /// Iterates over the committed frames in claim order. diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs index 50c0a0f95..091b04128 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs @@ -19,10 +19,11 @@ use super::{ /// reserved with atomic operations, filled in uniquely owned payload spans, /// and published with an atomic commit (see the ordering contract above). pub struct ShmWriter { - /// Owns the region the views point into; dropped with the writer. + mapped: MappedLayout, + /// Owns the region the views point into. Declared after them: fields + /// drop in order, and what borrows must die before what is borrowed. #[cfg_attr(any(not(test), miri), expect(dead_code, reason = "held to keep the region alive"))] mem: M, - mapped: MappedLayout, } // SAFETY: the writer touches the region only through the protocol's @@ -71,7 +72,7 @@ impl ShmWriter { // and so for every use of the views, which are stored in and // dropped with the writer. let mapped = unsafe { MappedLayout::new(mem.as_raw_slice()) }; - Self { mem, mapped } + Self { mapped, mem } } /// Whether the CLOSED gate is set: the receiver sealed the channel, From 4cdb7e6a216781bc71487474e77602d707044be1 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sat, 15 Aug 2026 21:49:36 +0800 Subject: [PATCH 47/92] refactor(fspy-shm): validate inside the constructors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit is_supported_region_len was a precondition API: call it first or the constructor panics. The constructors now answer for themselves — MappedLayout::new and ShmWriter::new return None for a region that cannot host the protocol, seal reports ProtocolError::UnsupportedRegion (the enum's second variant is real again), and pre_fault quietly does nothing, since a region nobody can attach to needs no warm-up. The alignment check is the pointer conversion itself: a stable stand-in for the still-unstable <*mut T>::try_cast_aligned, alongside NonNull::new. The predicate is deleted. Senders map None to an error instead of guarding; the channel fail-fasts at creation by performing the same fallible attach a sender would (over &Mapping — AsRawSlice now has a reference impl), so a bad capacity fails the task before any child spawns. The misaligned-region test asserts a None instead of catching a panic. Co-Authored-By: Claude Fable 5 --- crates/fspy_shared/src/ipc/channel/mod.rs | 37 +++++---- .../src/ipc/channel/shm_io/layout.rs | 56 ++++++------- .../fspy_shared/src/ipc/channel/shm_io/mod.rs | 79 +++++++++---------- .../src/ipc/channel/shm_io/reader.rs | 32 ++++---- .../src/ipc/channel/shm_io/writer.rs | 15 ++-- 5 files changed, 108 insertions(+), 111 deletions(-) diff --git a/crates/fspy_shared/src/ipc/channel/mod.rs b/crates/fspy_shared/src/ipc/channel/mod.rs index b13ff8ef3..0fbe4ec95 100644 --- a/crates/fspy_shared/src/ipc/channel/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/mod.rs @@ -53,13 +53,6 @@ pub struct ChannelConf { /// compile-time table plus whatever the mapped file's size says. #[expect(clippy::missing_errors_doc, reason = "non-vt crate: cannot use vt_str/vt_path types")] pub fn channel(capacity: usize) -> io::Result<(ChannelConf, Receiver)> { - // Fail fast on a capacity the compile-time table cannot fit into. - if !shm_io::is_supported_region_len::(capacity) { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "capacity cannot host the channel's descriptor table", - )); - } let shm_c_path = os_c_string(shm_backing_path()?.as_os_str())?; let handle = fspy_shm::create(shm_c_path.as_c_str().as_thin(), capacity).map_err(shm_error_to_io)?; @@ -83,6 +76,18 @@ pub fn channel(capacity: usize) -> io::Result<(ChannelConf, Receiver)> { }); } + // Prove the region can host the protocol — the same fallible attach + // senders perform — so a bad capacity fails the task now, not at its + // first record. + // SAFETY: the region was just created zero-initialized and is only + // accessed through the `shm_io` protocol. + if unsafe { ShmWriter::<_, SLOTS>::new(&mapping) }.is_none() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "capacity cannot host the channel's descriptor table", + )); + } + let conf = ChannelConf { shm_id: IpcStr::from_os_c_str(keeper.path.as_c_str()).to_boxed() }; Ok((conf, Receiver { _keeper: keeper, mapping })) @@ -195,19 +200,17 @@ impl ChannelConf { .map_err(shm_error_to_io)? .map() .map_err(shm_error_to_io)?; - // A truncated or foreign file must fail here, not panic the host - // process inside the protocol's geometry assertions. - if !shm_io::is_supported_region_len::(mapping.len()) { + // SAFETY: `mapping` is a freshly mapped shared memory region created + // zero-initialized by `channel` and accessed only through the + // `shm_io` protocol by every attached process. + let Some(writer) = (unsafe { ShmWriter::new(mapping) }) else { + // A truncated or foreign file fails here — it never panics the + // host process. return Err(io::Error::new( io::ErrorKind::InvalidData, - "shared-memory region size cannot host the channel", + "shared-memory region cannot host the channel", )); - } - // SAFETY: `mapping` is a freshly mapped shared memory region created - // zero-initialized by `channel` and accessed only through the - // `shm_io` protocol by every attached process; the protocol derives - // one layout from the mapped size on every side. - let writer = unsafe { ShmWriter::new(mapping) }; + }; if writer.is_closed() { return Err(io::Error::new( io::ErrorKind::BrokenPipe, diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs index cb96156c6..90721dfae 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs @@ -143,6 +143,15 @@ pub(super) const OFFSET_MAX: u64 = u32::MAX as u64; // descriptor also makes the payload writes it published visible, so the // borrows `ShmReader` later hands out read settled bytes. +/// Casts to a pointer of another type, returning `None` when the pointer +/// is not aligned for `U`: a stable stand-in for the still-unstable +/// [`<*mut T>::try_cast_aligned`][std]. +/// +/// [std]: https://doc.rust-lang.org/std/primitive.pointer.html#method.try_cast_aligned +fn try_cast_aligned(ptr: *mut u8) -> Option<*mut U> { + if ptr.addr().is_multiple_of(align_of::()) { Some(ptr.cast()) } else { None } +} + /// The typed views of the region: the fixed-location [`Meta`] struct /// and the raw payload area. Built once when an endpoint attaches and /// stored in it; the views stay valid because they point into the @@ -154,7 +163,10 @@ pub(super) struct MappedLayout { } impl MappedLayout { - /// Builds the typed views of a shared mapping. + /// Builds the typed views of a shared mapping, or `None` when the + /// mapping cannot host the protocol at all: base null or not + /// `u64`-aligned, [`Meta`] not fitting inside the mapping, or the + /// payload area beyond the descriptors' 32-bit offsets. /// /// # Safety /// @@ -163,37 +175,27 @@ impl MappedLayout { /// used. /// - The memory must have been zero-initialized when the region was /// created, and accessed only through this protocol since. - /// - /// # Panics - /// - /// Panics when the mapping cannot host the protocol at all: base not - /// `u64`-aligned, [`Meta`] not fitting inside the mapping, or the - /// payload area beyond the descriptors' 32-bit offsets. These - /// indicate a broken caller, not runtime data; senders guard - /// untrusted mappings with [`super::is_supported_region_len`] first. - pub(super) unsafe fn new(mem: *mut [u8]) -> Self { - let base = mem.cast::(); + pub(super) unsafe fn new(mem: *mut [u8]) -> Option { let len = mem.len(); - assert!(!base.is_null()); - assert!(base.addr().is_multiple_of(align_of::>())); - // The whole geometry check: the payload area is everything after + // The pointer conversions are the base checks: aligned, non-null. + let meta = NonNull::new(try_cast_aligned::>(mem.cast::())?)?; + // The rest of the geometry: the payload area is everything after // the fixed-location struct, so the struct must fit inside the // mapping, and the rest must fit the descriptors' 32-bit offsets. let payload_base = size_of::>(); - assert!(payload_base <= len); - assert!(len - payload_base <= MAX_PAYLOAD_REGION_LEN); - // SAFETY: the base is non-null and `u64`-aligned, and `Meta` fits - // inside the mapping (all asserted). The payload area keeps the - // rest of the mapping as a raw slice. - unsafe { - Self { - meta: NonNull::new_unchecked(base.cast::>()), - payloads: std::ptr::slice_from_raw_parts_mut( - base.add(payload_base), - len - payload_base, - ), - } + if payload_base > len || len - payload_base > MAX_PAYLOAD_REGION_LEN { + return None; } + // SAFETY: `Meta` fits inside the mapping at its base (checked + // above); the payload area keeps the rest of the mapping as a raw + // slice. + let payloads = unsafe { + std::ptr::slice_from_raw_parts_mut( + mem.cast::().add(payload_base), + len - payload_base, + ) + }; + Some(Self { meta, payloads }) } /// The fixed-location part of the region. diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs index ca5fd1c92..373bc8ab3 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs @@ -110,18 +110,10 @@ impl AsRawSlice for Mapping { } } -/// Whether a mapping of `len` bytes can host a channel with `SLOTS` -/// descriptor slots: the fixed-location struct must fit inside the -/// mapping, and the payload area — the rest — must fit the descriptors' -/// 32-bit offsets. -/// -/// Senders opening a file they do not control should refuse unsupported -/// lengths with an error; the protocol's own constructors treat them as a -/// broken caller and panic. -#[must_use] -pub const fn is_supported_region_len(len: usize) -> bool { - size_of::>() <= len - && len - size_of::>() <= layout::MAX_PAYLOAD_REGION_LEN +impl AsRawSlice for &M { + fn as_raw_slice(&self) -> *mut [u8] { + (**self).as_raw_slice() + } } /// Materializes the region's first page without changing protocol @@ -137,8 +129,12 @@ pub const fn is_supported_region_len(len: usize) -> bool { /// Same contract as [`ShmWriter::new`]. #[cfg(target_os = "linux")] pub unsafe fn pre_fault(mem: &impl AsRawSlice) { + // Best-effort: a region that cannot host the protocol needs no + // warm-up — attaching to it will fail anyway. // SAFETY: forwarded from this function's contract. - let mapped = unsafe { MappedLayout::::new(mem.as_raw_slice()) }; + let Some(mapped) = (unsafe { MappedLayout::::new(mem.as_raw_slice()) }) else { + return; + }; // A compare-exchange of zero with zero on the claim counter: on an // untouched region it performs a real write — allocating the first // block of a sparse backing file — without changing protocol state. If @@ -234,7 +230,7 @@ mod tests { let shm = MockedShm::alloc(1024); // SAFETY: `MockedShm::alloc` provides a valid, properly-sized, // zero-initialized allocation. - let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }; + let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }.unwrap(); assert!(writer.try_write_frame(b"hello")); assert!(writer.try_write_frame(b"world")); assert!(writer.try_write_frame(b"this is a test")); @@ -252,7 +248,7 @@ mod tests { fn zero_sized_frames_are_rejected() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }; + let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }.unwrap(); assert!(writer.try_write_frame(b"hello")); assert!(!writer.try_write_frame(b"")); @@ -266,7 +262,7 @@ mod tests { fn frame_spanning_many_u64s_roundtrips_exactly() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }; + let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }.unwrap(); let pattern: Vec = (0..=99).collect(); assert!(writer.try_write_frame(&pattern)); @@ -280,7 +276,7 @@ mod tests { fn full_region_marks_the_channel_incomplete() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }; + let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }.unwrap(); assert!(writer.try_write_frame(b"test")); @@ -299,7 +295,7 @@ mod tests { fn oversized_frame_is_refused_and_marks_incomplete() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }; + let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }.unwrap(); assert!(writer.try_write_frame(b"kept")); // No descriptor can describe a frame this long: the claim is @@ -321,7 +317,7 @@ mod tests { fn crash_after_claim_is_skipped() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }; + let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }.unwrap(); assert!(writer.try_write_frame(b"foo")); // A crash right after claiming and an abandoned frame leave the @@ -343,7 +339,7 @@ mod tests { fn crash_during_partial_write_is_skipped() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }; + let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }.unwrap(); assert!(writer.try_write_frame(b"foo")); // Simulate a crash during writing: the frame is abandoned @@ -369,7 +365,7 @@ mod tests { // receiver from finding the valid frames around them. let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }; + let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }.unwrap(); assert!(writer.try_write_frame(b"foo")); @@ -396,7 +392,7 @@ mod tests { fn abandoned_frame_is_ignored() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }; + let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }.unwrap(); assert!(writer.try_write_frame(b"foo")); // Dropping an unfinished frame abandons it: the receiver ignores @@ -415,7 +411,7 @@ mod tests { fn pre_fault_does_not_disturb_protocol_state() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }; + let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }.unwrap(); // On the untouched region, before any claim. // SAFETY: see `collect_frames`. @@ -440,7 +436,7 @@ mod tests { // on the slot side while payload space remains. let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }; + let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }.unwrap(); for _ in 0..15 { assert!(writer.try_write_frame(b"x")); } @@ -455,7 +451,7 @@ mod tests { fn claims_after_seal_are_gated_without_poisoning() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }; + let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }.unwrap(); assert!(writer.try_write_frame(b"foo")); assert!(!writer.is_closed()); @@ -477,7 +473,7 @@ mod tests { fn commit_after_abort_publishes_nothing() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }; + let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }.unwrap(); let mut frame = writer.claim_frame(5.try_into().unwrap()).unwrap(); frame.copy_from_slice(b"late!"); @@ -511,7 +507,7 @@ mod tests { // SAFETY: see `single_thread_basic`. The clone shares the // same backing memory, which is safe because the protocol // synchronizes concurrent access with atomics. - let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }; + let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }.unwrap(); for _ in 0..10 { assert!(writer.try_write_frame(b"hello")); assert!(writer.try_write_frame(b"foo")); @@ -537,7 +533,7 @@ mod tests { fn concurrent_exceeded_size() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }; + let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }.unwrap(); thread::scope(|s| { for _ in 0..4 { s.spawn(|| { @@ -575,7 +571,7 @@ mod tests { let writers = [(); 2].map(|()| { s.spawn(|| { // SAFETY: see `concurrent`. - let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }; + let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }.unwrap(); barrier.wait(); let mut written = 0usize; // Bounded so the test terminates even if the seal is slow; @@ -622,7 +618,7 @@ mod tests { fn corrupt_committed_descriptor_is_a_protocol_error() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }; + let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }.unwrap(); assert!(writer.try_write_frame(b"hello")); // Point slot 0 at a span escaping the payload region. @@ -633,14 +629,14 @@ mod tests { // SAFETY: see `collect_frames`. let result = unsafe { ShmReader::<_, S>::seal(shm) }; - assert!(result.unwrap_err() == ProtocolError { slot_index: 0 }); + assert!(result.unwrap_err() == ProtocolError::CorruptDescriptor { slot_index: 0 }); } #[test] fn corrupt_oversized_descriptor_is_a_protocol_error() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }; + let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }.unwrap(); assert!(writer.try_write_frame(b"hello")); // A length field far beyond anything this region can hold. @@ -649,14 +645,14 @@ mod tests { // SAFETY: see `collect_frames`. let result = unsafe { ShmReader::<_, S>::seal(shm) }; - assert!(result.unwrap_err() == ProtocolError { slot_index: 0 }); + assert!(result.unwrap_err() == ProtocolError::CorruptDescriptor { slot_index: 0 }); } #[test] fn overshot_claim_counter_clamps_to_the_table() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }; + let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }.unwrap(); assert!(writer.try_write_frame(b"hello")); // A wildly inflated claim counter — mass claim failures or a foreign @@ -690,12 +686,9 @@ mod tests { let misaligned_shm = Misaligned(MockedShm::alloc(1024)); assert!(misaligned_shm.as_raw_slice().cast::() as usize % align_of::() != 0); - let result = std::panic::catch_unwind(|| { - // SAFETY: Intentionally passing a misaligned pointer to test that - // the geometry assertion correctly panics. - unsafe { ShmWriter::<_, S>::new(misaligned_shm) }; - }); - assert!(result.is_err(), "should panic on a misaligned region"); + // SAFETY: the wrapped allocation is valid; only its alignment is + // deliberately wrong. + assert!(unsafe { ShmWriter::<_, S>::new(misaligned_shm) }.is_none()); } #[test] @@ -736,7 +729,7 @@ mod tests { // SAFETY: `mapping` is a freshly mapped shared memory // region with a valid pointer and size; the protocol // synchronizes concurrent access. - let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(mapping) }; + let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(mapping) }.unwrap(); for i in 0..FRAME_COUNT_EACH_CHILD { let frame_data = std::format!("{child_index} {i}"); assert!(writer.try_write_frame(frame_data.as_bytes())); @@ -792,7 +785,7 @@ mod tests { let c_path = crate::ipc::channel::os_c_string(std::ffi::OsStr::new(&shm_name)).unwrap(); let child_mapping = fspy_shm::open(c_path.as_c_str().as_thin()).unwrap().map().unwrap(); // SAFETY: see `real_shm_across_processes`. - let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(child_mapping) }; + let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(child_mapping) }.unwrap(); let mut frame = writer.claim_frame(5.try_into().unwrap()).unwrap(); frame[..3].copy_from_slice(b"wor"); // Signal the parent that the frame is claimed and partially @@ -818,7 +811,7 @@ mod tests { // A surviving writer keeps working after the kill. // SAFETY: see `real_shm_across_processes`. - let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(mapping) }; + let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(mapping) }.unwrap(); assert!(writer.try_write_frame(b"alive")); // SAFETY: see `real_shm_across_processes`. diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs index 106366763..e578aa52d 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs @@ -60,12 +60,17 @@ const fn decode(payload_region_len: usize, bits: u64) -> Option { Some(PayloadSpan { offset, len }) } -/// A descriptor that no correct writer could have committed: the region -/// was corrupted, and its frames are unusable. +/// Why a channel could not be sealed into readable frames. #[derive(thiserror::Error, Clone, Copy, PartialEq, Eq, Debug)] -#[error("corrupt shared-memory frame descriptor at slot {slot_index}")] -pub struct ProtocolError { - pub slot_index: usize, +pub enum ProtocolError { + /// The mapping cannot host the protocol at all (see + /// [`MappedLayout::new`]). + #[error("the shared-memory region cannot host the channel")] + UnsupportedRegion, + /// A descriptor that no correct writer could have committed: the + /// region was corrupted, and its frames are unusable. + #[error("corrupt shared-memory frame descriptor at slot {slot_index}")] + CorruptDescriptor { slot_index: usize }, } /// A reader over the committed frames of a sealed channel, serving them @@ -115,19 +120,16 @@ impl ShmReader { /// /// # Errors /// - /// [`ProtocolError`] when the shared-memory metadata could not have - /// been produced by a correct writer; the region was corrupted and its - /// frames are unusable. - /// - /// # Panics - /// - /// Panics when the region is not `u64`-aligned or its size is outside - /// the supported range (see [`MappedLayout::new`]). + /// [`ProtocolError`]: the mapping cannot host the protocol, or its + /// metadata could not have been produced by a correct writer — the + /// region was corrupted and its frames are unusable. pub unsafe fn seal(mem: M) -> Result { // SAFETY: forwarded from this function's contract, which keeps the // region valid for the reader's lifetime — and so for every use of // the views, which are stored in and dropped with the reader. - let mapped = unsafe { MappedLayout::new(mem.as_raw_slice()) }; + let Some(mapped) = (unsafe { MappedLayout::new(mem.as_raw_slice()) }) else { + return Err(ProtocolError::UnsupportedRegion); + }; let slot_count; let mut frames = 0; let complete; @@ -179,7 +181,7 @@ impl ShmReader { // Any other terminal value must be a committed descriptor // with a valid span; a foreign scribble fails the decode. if decode(mapped.payloads.len(), bits).is_none() { - return Err(ProtocolError { slot_index }); + return Err(ProtocolError::CorruptDescriptor { slot_index }); } frames += 1; } diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs index 091b04128..9b92b4695 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs @@ -53,7 +53,9 @@ pub enum ClaimError { } impl ShmWriter { - /// Creates a writer backed by a shared-memory region. + /// Creates a writer backed by a shared-memory region, or `None` when + /// the region cannot host the protocol (see [`MappedLayout::new`]) — + /// a truncated or foreign file, for a sender that did not create it. /// /// # Safety /// @@ -61,18 +63,13 @@ impl ShmWriter { /// whole region for the writer's lifetime. /// - The region must have been zero-initialized when it was created and /// accessed only through this protocol since. - /// - /// # Panics - /// - /// Panics when the region is not `u64`-aligned or its size is outside the - /// supported range (see [`MappedLayout::new`]). - pub unsafe fn new(mem: M) -> Self { + pub unsafe fn new(mem: M) -> Option { // SAFETY: forwarded from this function's contract, which keeps the // region valid and protocol-governed for the writer's lifetime — // and so for every use of the views, which are stored in and // dropped with the writer. - let mapped = unsafe { MappedLayout::new(mem.as_raw_slice()) }; - Self { mapped, mem } + let mapped = unsafe { MappedLayout::new(mem.as_raw_slice()) }?; + Some(Self { mapped, mem }) } /// Whether the CLOSED gate is set: the receiver sealed the channel, From 99f5093156466d1481177c91e1ac0f939c534112 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sat, 15 Aug 2026 21:53:41 +0800 Subject: [PATCH 48/92] refactor(fspy-shm): underscore the owner fields; drop into_memory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The expect(dead_code) machinery — including the cfg_attr keyed on both test and miri — existed only because into_memory read the writer's owner field in some build configurations. into_memory itself was a workaround for ownership that the AsRawSlice reference impl now solves: its one caller, the killed-writer test, borrows the mapping for its surviving writer and hands the mapping itself to the seal. With the last read gone, both owner fields are plain _mem, and the underscore says everything the lint attributes said. Co-Authored-By: Claude Fable 5 --- crates/fspy_shared/src/ipc/channel/shm_io/mod.rs | 7 ++++--- crates/fspy_shared/src/ipc/channel/shm_io/reader.rs | 5 ++--- crates/fspy_shared/src/ipc/channel/shm_io/writer.rs | 11 ++--------- 3 files changed, 8 insertions(+), 15 deletions(-) diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs index 373bc8ab3..2aefca1af 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs @@ -809,13 +809,14 @@ mod tests { child.kill().unwrap(); child.wait().unwrap(); - // A surviving writer keeps working after the kill. + // A surviving writer keeps working after the kill. It borrows the + // mapping so the seal below can take it over. // SAFETY: see `real_shm_across_processes`. - let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(mapping) }.unwrap(); + let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(&mapping) }.unwrap(); assert!(writer.try_write_frame(b"alive")); // SAFETY: see `real_shm_across_processes`. - let frames = unsafe { ShmReader::<_, S>::seal(writer.into_memory()) }.unwrap(); + let frames = unsafe { ShmReader::<_, S>::seal(mapping) }.unwrap(); let mut iter = frames.iter(); assert!(iter.next().unwrap() == b"alive"); assert!(iter.next() == None); diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs index e578aa52d..46550845c 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs @@ -82,8 +82,7 @@ pub struct ShmReader { mapped: MappedLayout, /// Owns the region the views point into. Declared after them: fields /// drop in order, and what borrows must die before what is borrowed. - #[expect(dead_code, reason = "held to keep the region alive")] - mem: M, + _mem: M, /// Length of the frozen prefix of the descriptor table. slot_count: usize, /// Committed frames in that prefix. @@ -187,7 +186,7 @@ impl ShmReader { } } - Ok(Self { mapped, mem, slot_count, frames, complete }) + Ok(Self { mapped, _mem: mem, slot_count, frames, complete }) } /// Iterates over the committed frames in claim order. diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs index 9b92b4695..3c699943f 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs @@ -22,8 +22,7 @@ pub struct ShmWriter { mapped: MappedLayout, /// Owns the region the views point into. Declared after them: fields /// drop in order, and what borrows must die before what is borrowed. - #[cfg_attr(any(not(test), miri), expect(dead_code, reason = "held to keep the region alive"))] - mem: M, + _mem: M, } // SAFETY: the writer touches the region only through the protocol's @@ -69,7 +68,7 @@ impl ShmWriter { // and so for every use of the views, which are stored in and // dropped with the writer. let mapped = unsafe { MappedLayout::new(mem.as_raw_slice()) }?; - Some(Self { mapped, mem }) + Some(Self { mapped, _mem: mem }) } /// Whether the CLOSED gate is set: the receiver sealed the channel, @@ -154,12 +153,6 @@ impl ShmWriter { }) } - // Unwrap `self` and return the underlying memory. - #[cfg(all(test, not(miri)))] - pub fn into_memory(self) -> M { - self.mem - } - #[cfg(test)] pub fn try_write_frame(&self, frame: &[u8]) -> bool { let Some(frame_size) = NonZeroUsize::new(frame.len()) else { From 27a1cfe6e338a72a877b472df37d76b2d4c642f0 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sat, 15 Aug 2026 22:55:52 +0800 Subject: [PATCH 49/92] refactor(fspy-shm): parse slots into SlotState MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A slot value is now parsed once into what it means — SlotState, either Unfinished (nothing published: never finished, died, abandoned, or frozen by the seal) or Committed with a NonZeroU32 length — instead of shift-and-mask helpers on both sides. The nonzero length is the wire invariant as a type: it cannot collide with UNFINISHED or FROZEN, and NonZeroU32::try_from(frame_size) makes the writer's oversize check, zero-exclusion, and field-width conversion one step. bytemuck casts the u64 to its two u32 halves (same machine, native byte order), and the bounds check lives with the two readers of a span. Reviewed while finishing: CLOSED stays bit 63 rather than u64::MAX — stragglers keep fetch_adding after the seal, and an OR-ed bit survives 2^63 increments while an exact sentinel is destroyed by the first, silently reopening the gate; and the payload area keeps its u32 bound (now a conversion), without which the writer's offset conversion could panic on >4 GiB regions. ABORTED is renamed FROZEN to match the seal vocabulary. Co-Authored-By: Claude Fable 5 --- .../src/ipc/channel/shm_io/README.md | 8 +- .../src/ipc/channel/shm_io/layout.rs | 122 ++++++++++----- .../fspy_shared/src/ipc/channel/shm_io/mod.rs | 6 +- .../src/ipc/channel/shm_io/reader.rs | 142 ++++-------------- .../src/ipc/channel/shm_io/writer.rs | 20 +-- 5 files changed, 132 insertions(+), 166 deletions(-) diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/README.md b/crates/fspy_shared/src/ipc/channel/shm_io/README.md index eb6c88e17..08c789bde 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/README.md +++ b/crates/fspy_shared/src/ipc/channel/shm_io/README.md @@ -125,7 +125,7 @@ The receiver seals the channel once: claims at or before it are in, later ones are not. 2. **Gate** further claims by setting the CLOSED bit, so stragglers stop. 3. **Freeze** every slot in the snapshot: a compare-and-swap flips zero to - ABORTED. If the slot was already committed, the swap fails and the frame + FROZEN. If the slot was already committed, the swap fails and the frame is kept. Exactly one side wins each slot, and either way the slot never changes again. 4. **Validate** each committed descriptor's bounds. A descriptor no correct @@ -142,7 +142,7 @@ the reader is dropped. writer's commit CAS wins +------------------------------> COMMITTED (readable) CLAIMED (slot 0) ---+ - +------------------------------> ABORTED (ignored) + +------------------------------> FROZEN (ignored) receiver's freeze CAS wins ``` @@ -153,9 +153,9 @@ CLAIMED (slot 0) ---+ - A payload is reachable only through its committed descriptor. The commit is a `Release` write and the receiver's failed freeze is an `Acquire` read, so an observed descriptor implies fully visible payload bytes. -- Once a slot is committed or aborted, nothing ever changes it again. +- Once a slot is committed or frozen, nothing ever changes it again. - Counters only grow. The receiver clamps them to the fixed capacities — - an inflated counter degrades into extra aborted slots, not corruption. + an inflated counter degrades into extra frozen slots, not corruption. Loss is reported through the CLOSED gate: a failed claim sets it before the writer carries on, which both marks the result incomplete and refuses every later claim. diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs index 90721dfae..8857e4b4c 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs @@ -16,17 +16,20 @@ //! [`super::writer`] and [`super::reader`]. //! //! Overflow safety follows from one bound enforced at attach time: the -//! payload region never exceeds [`MAX_PAYLOAD_REGION_LEN`], so all -//! offsets fit the 32-bit descriptor fields and all sums fit `usize` on -//! the 64-bit targets the parent module asserts. +//! payload region fits `u32`, so all offsets fit the 32-bit descriptor +//! fields and all sums fit `usize` on the 64-bit targets the parent +//! module asserts. -use std::{ptr::NonNull, sync::atomic::AtomicU64}; +use std::{num::NonZeroU32, ptr::NonNull, sync::atomic::AtomicU64}; /// The CLOSED gate bit of the claim counter, set when the receiver seals /// the channel and by any writer whose claim failed — the loss report /// that also condemns the channel (rule 1 below). -/// The low 63 bits count claims, so no realistic claim volume can carry -/// into the gate. +/// +/// The gate must be a bit, not a sentinel value: stragglers keep +/// `fetch_add`ing the counter after it is set, and an OR-ed bit survives +/// 2^63 increments, while any exact value would be destroyed by the +/// first. No realistic claim volume carries into bit 63. pub(super) const CLOSED: u64 = 1 << 63; /// The region's fixed-location part — the protocol counters and the @@ -46,12 +49,6 @@ pub(super) struct Meta { // raise the alignment. const _: () = assert!(align_of::>() == align_of::()); -/// Maximum payload-region size. -/// -/// Descriptors store payload offsets in 32 bits, so the payload region -/// must fit `u32` arithmetic. -pub(super) const MAX_PAYLOAD_REGION_LEN: usize = 1 << 32; - // --- The descriptor slot codec --------------------------------------------- // // One slot is a 64-bit value that publishes a frame: @@ -64,13 +61,13 @@ pub(super) const MAX_PAYLOAD_REGION_LEN: usize = 1 << 32; // | Value | State | // | --------------------- | ----------------------------------------------- | // | `0` | Unfinished: slot reserved, nothing published | -// | `1` | Aborted: the receiver froze the unfinished slot | +// | `1` | Frozen by the seal: never publishable again | // | length field nonzero | Committed: offset and length of the payload | // // Committed lengths are nonzero (a zero-length frame is never claimed), so // a committed value's length field is nonzero and the three states are // disjoint: `1` is a zero length with offset `1`, which no writer commits. -// Once a slot is committed or aborted, nothing ever changes it again. +// Once a slot is committed or frozen, nothing ever changes it again. // // Offsets are measured from the start of the payload area, so no // descriptor can even name the header or the table. @@ -80,8 +77,11 @@ pub(super) const MAX_PAYLOAD_REGION_LEN: usize = 1 << 32; // ([`super::reader`]). pub(super) const UNFINISHED: u64 = 0; -pub(super) const LEN_SHIFT: u32 = 32; -pub(super) const OFFSET_MAX: u64 = u32::MAX as u64; + +/// The terminal value the seal installs in an unfinished slot: still +/// nothing published (a zero length field), but no longer zero, so a late +/// commit's compare-and-swap from [`UNFINISHED`] loses. +pub(super) const FROZEN: u64 = 1; // --- The mapped layout and the ordering contract --------------------------- // `MappedLayout::new` builds two typed views of the region — the @@ -148,7 +148,7 @@ pub(super) const OFFSET_MAX: u64 = u32::MAX as u64; /// [`<*mut T>::try_cast_aligned`][std]. /// /// [std]: https://doc.rust-lang.org/std/primitive.pointer.html#method.try_cast_aligned -fn try_cast_aligned(ptr: *mut u8) -> Option<*mut U> { +fn try_cast_aligned(ptr: *mut T) -> Option<*mut U> { if ptr.addr().is_multiple_of(align_of::()) { Some(ptr.cast()) } else { None } } @@ -162,6 +162,46 @@ pub(super) struct MappedLayout { pub(super) payloads: *mut [u8], } +/// A decoded descriptor slot (the codec above). +#[derive(Clone, Copy)] +pub(super) enum SlotState { + /// Nothing is published in the slot: the writer has not finished it + /// yet, died or abandoned it before finishing, or the seal froze it + /// ([`FROZEN`]) so it never can be. The receiver ignores such slots. + Unfinished, + /// A payload is committed: the receiver may read its span, once + /// checked against the payload area's bounds. + Committed { + /// Byte offset of the payload from the start of the payload region. + offset: u32, + /// Byte length of the payload. Nonzero, so that a committed value + /// can never collide with [`UNFINISHED`] or [`FROZEN`] — the + /// offset alone could be zero. + len: NonZeroU32, + }, +} + +impl SlotState { + /// Decodes a slot value into its state. The two processes sharing a + /// value run on one machine, so native byte order is fine. + pub(super) const fn decode(slot_value: u64) -> Self { + let [offset, len] = bytemuck::must_cast::(slot_value); + let Some(len) = NonZeroU32::new(len) else { + return Self::Unfinished; + }; + Self::Committed { offset, len } + } + + /// Encodes this state as a slot value; the inverse of [`Self::decode`]. + pub(super) const fn encode(self) -> u64 { + let [offset, len] = match self { + Self::Unfinished => [0, 0], + Self::Committed { offset, len } => [offset, len.get()], + }; + bytemuck::must_cast([offset, len]) + } +} + impl MappedLayout { /// Builds the typed views of a shared mapping, or `None` when the /// mapping cannot host the protocol at all: base null or not @@ -176,25 +216,21 @@ impl MappedLayout { /// - The memory must have been zero-initialized when the region was /// created, and accessed only through this protocol since. pub(super) unsafe fn new(mem: *mut [u8]) -> Option { - let len = mem.len(); + let mem_start = mem.cast::(); + let mem_len = mem.len(); + // The mapping must be large enough to hold the fixed-location struct. + let payload_len = mem_len.checked_sub(size_of::>())?; + // Descriptors store payload offsets in 32 bits: the conversion is + // the bound on the payload area. + u32::try_from(payload_len).ok()?; // The pointer conversions are the base checks: aligned, non-null. - let meta = NonNull::new(try_cast_aligned::>(mem.cast::())?)?; - // The rest of the geometry: the payload area is everything after - // the fixed-location struct, so the struct must fit inside the - // mapping, and the rest must fit the descriptors' 32-bit offsets. - let payload_base = size_of::>(); - if payload_base > len || len - payload_base > MAX_PAYLOAD_REGION_LEN { - return None; - } - // SAFETY: `Meta` fits inside the mapping at its base (checked - // above); the payload area keeps the rest of the mapping as a raw - // slice. - let payloads = unsafe { - std::ptr::slice_from_raw_parts_mut( - mem.cast::().add(payload_base), - len - payload_base, - ) - }; + let meta = NonNull::new(try_cast_aligned::<_, Meta>(mem_start)?)?; + + // The payload area is everything after the fixed-location struct. + // SAFETY: the mapping is large enough to hold it (checked above). + let payload_start = unsafe { mem_start.add(size_of::>()) }; + + let payloads = std::ptr::slice_from_raw_parts_mut(payload_start, payload_len); Some(Self { meta, payloads }) } @@ -229,6 +265,22 @@ mod tests { use super::*; + #[test] + fn slot_codec_roundtrips() { + for (offset, len) in [(0, 5), (3, 5), (u32::MAX, 1), (0, u32::MAX)] { + let len = NonZeroU32::new(len).unwrap(); + let encoded = SlotState::Committed { offset, len }.encode(); + let SlotState::Committed { offset: o, len: l } = SlotState::decode(encoded) else { + panic!("committed value decoded as unfinished"); + }; + assert!(o == offset && l == len); + } + // Zero length fields publish nothing, whatever the offset half says. + assert!(matches!(SlotState::decode(UNFINISHED), SlotState::Unfinished)); + assert!(matches!(SlotState::decode(FROZEN), SlotState::Unfinished)); + assert!(matches!(SlotState::decode(42), SlotState::Unfinished)); + } + #[test] fn meta_is_the_counters_then_the_table() { assert!(size_of::>() == (2 + 15) * size_of::()); diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs index 2aefca1af..e178c691d 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs @@ -40,7 +40,7 @@ //! writer commit CAS wins //! +-----------------------------> COMMITTED (readable) //! CLAIMED (slot 0) ---+ -//! +-----------------------------> ABORTED (ignored) +//! +-----------------------------> FROZEN (ignored) //! receiver freeze CAS wins //! ``` //! @@ -598,7 +598,7 @@ mod tests { (frames, results) }); - // Every admitted slot resolved to a whole frame or was aborted: + // Every admitted slot resolved to a whole frame or was frozen: // the receiver observed only complete payloads. let mut count = 0; for frame in &frames { @@ -658,7 +658,7 @@ mod tests { // A wildly inflated claim counter — mass claim failures or a foreign // scribble — degrades to a full-table sweep, never out-of-bounds // slot access: the committed frame survives, the untouched slots - // freeze as aborted. + // freeze as unpublishable. shm.poke_u64(0, (1 << 40) | 1); let frames = collect_frames(&shm); diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs index 46550845c..78f04d26e 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs @@ -19,47 +19,9 @@ use std::{ use super::{ AsRawSlice, - layout::{self, CLOSED, MappedLayout}, + layout::{self, CLOSED, FROZEN, MappedLayout, SlotState}, }; -/// The terminal value the freeze pass installs in an unfinished slot: the -/// aborted value of the slot codec ([`layout`]): a zero length field -/// with offset `1`, which no writer ever commits. -const ABORTED: u64 = 1; - -/// A validated payload byte range: the witness that offset arithmetic on this -/// span cannot leave the payload region. -/// -/// [`decode`] is the only constructor, so code holding a span may rely on -/// its bounds without re-checking. -#[derive(Clone, Copy, Debug)] -struct PayloadSpan { - /// Byte offset of the payload from the start of the payload region. - offset: u32, - /// Byte length of the payload. - len: u32, -} - -/// Decodes a slot value read back from shared memory as a committed -/// descriptor (the slot codec in [`layout`]). Returns `None` for any value -/// that is not one a correct writer could have committed for a payload -/// region of `payload_region_len` bytes. -const fn decode(payload_region_len: usize, bits: u64) -> Option { - // Reverse the writer's conversions: the fields are the value's two - // halves, and a zero length — an unfinished or aborted slot — is - // never a committed descriptor. - let len = (bits >> layout::LEN_SHIFT) as u32; - if len == 0 { - return None; - } - let offset = (bits & layout::OFFSET_MAX) as u32; - // Both fields are 32 bits wide, so this sum cannot overflow `usize`. - if offset as usize + len as usize > payload_region_len { - return None; - } - Some(PayloadSpan { offset, len }) -} - /// Why a channel could not be sealed into readable frames. #[derive(thiserror::Error, Clone, Copy, PartialEq, Eq, Debug)] pub enum ProtocolError { @@ -103,7 +65,7 @@ impl ShmReader { /// /// Never blocks on writers: writers admitted before the snapshot race /// per slot, and each raced slot independently ends up committed - /// (included) or aborted (excluded). Claims after the snapshot land in + /// (included) or frozen (excluded). Claims after the snapshot land in /// slots this pass never visits until the CLOSED gate — set before /// this returns — stops them. See the protocol docs at the top of this /// module. @@ -156,7 +118,7 @@ impl ShmReader { // Freeze pass: drive every admitted slot to a terminal state // and validate the committed descriptors. After this loop the // snapshot's slice of the descriptor table can no longer change - // — late writers lose their commit race against `ABORTED` — so + // — late writers lose their commit race against `FROZEN` — so // iteration re-reads the table instead of snapshotting it: // nothing is copied or allocated. for slot_index in 0..slot_count { @@ -164,25 +126,28 @@ impl ShmReader { // visible. let Err(bits) = mapped.table()[slot_index].compare_exchange( layout::UNFINISHED, - ABORTED, + FROZEN, Ordering::AcqRel, Ordering::Acquire, ) else { - // The receiver won the race: the unfinished slot is now - // aborted and stays ignored. + // The receiver won the race: the unfinished slot is + // frozen and stays ignored. continue; }; - if bits == ABORTED { - // Aborted by an earlier seal over the same region; - // still ignored. - continue; - } - // Any other terminal value must be a committed descriptor - // with a valid span; a foreign scribble fails the decode. - if decode(mapped.payloads.len(), bits).is_none() { - return Err(ProtocolError::CorruptDescriptor { slot_index }); + match SlotState::decode(bits) { + // Frozen by an earlier seal, or a scribble that + // published nothing; either way there is no frame. + SlotState::Unfinished => {} + SlotState::Committed { offset, len } => { + // The bounds check that makes the span readable; a + // span no correct writer could have committed + // fails the whole channel. + if offset as usize + len.get() as usize > mapped.payloads.len() { + return Err(ProtocolError::CorruptDescriptor { slot_index }); + } + frames += 1; + } } - frames += 1; } } @@ -236,21 +201,25 @@ impl<'a, const SLOTS: usize> Iterator for Iter<'a, SLOTS> { // carried the reader to this thread carried the freeze pass's // `Acquire` payload visibility with it (rule 3). let bits = slot.load(Ordering::Relaxed); - // `None` is an aborted slot: nothing was published. Corrupt - // values cannot appear — `seal` already failed the channel on - // them — so every decoded span is one `seal` validated. - let Some(span) = decode(self.mapped.payloads.len(), bits) else { + // An unfinished slot published nothing; out-of-bounds spans + // cannot appear — `seal` already failed the channel on them — + // but the check below keeps this `unsafe` locally justified. + let SlotState::Committed { offset, len } = SlotState::decode(bits) else { continue; }; + let (offset, len) = (offset as usize, len.get() as usize); + if offset + len > self.mapped.payloads.len() { + continue; + } self.remaining -= 1; - // SAFETY: `seal` validated the span against the payload - // region, and a committed span is immutable for the mapping's + // SAFETY: the span lies inside the payload area (checked + // above), and a committed span is immutable for the mapping's // lifetime (see the module docs above); the reader borrowed // for `'a` keeps the mapping alive and mapped. return Some(unsafe { slice::from_raw_parts( - self.mapped.payloads.cast::().cast_const().add(span.offset as usize), - span.len as usize, + self.mapped.payloads.cast::().cast_const().add(offset), + len, ) }); } @@ -274,52 +243,3 @@ impl<'a, M: AsRawSlice, const SLOTS: usize> IntoIterator for &'a ShmReader ShmWriter { ClaimError::Capacity }; - // The descriptor's 32-bit length field is the oversize check: - // refuse, before touching the counters, a frame it cannot + // The descriptor's 32-bit nonzero length field is the oversize + // check: refuse, before touching the counters, a frame it cannot // describe — the channel keeps working for every record after it. - let Ok(encoded_len) = u32::try_from(payload_len) else { + let Ok(encoded_len) = NonZeroU32::try_from(frame_size) else { return Err(report_loss()); }; // Payload bytes first, so a payload-capacity failure does not burn a @@ -148,7 +148,7 @@ impl ShmWriter { Ok(FrameMut { mapped, slot_index, - descriptor: committed(payload_offset, encoded_len), + descriptor: SlotState::Committed { offset: payload_offset, len: encoded_len }.encode(), content, }) } @@ -208,7 +208,7 @@ impl DerefMut for FrameMut<'_, SLOTS> { impl FrameMut<'_, SLOTS> { /// Commits the frame, making it visible to the receiver. /// - /// If the receiver sealed the channel and aborted this frame's slot + /// If the receiver sealed the channel and froze this frame's slot /// first, the swap fails and the frame is silently discarded: the /// record belongs to the seal race and is intentionally excluded /// either way. @@ -223,9 +223,3 @@ impl FrameMut<'_, SLOTS> { ); } } - -/// Encodes a committed descriptor (the slot codec in [`layout`]): the -/// argument types are exactly the field widths. -pub(super) fn committed(payload_offset: u32, payload_len: u32) -> u64 { - (u64::from(payload_len) << layout::LEN_SHIFT) | u64::from(payload_offset) -} From 20522b9930017742aac2602256fcfb1c9005cfc7 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sun, 16 Aug 2026 08:12:24 +0800 Subject: [PATCH 50/92] refactor(fspy-shm): net-zero refusals make the gate bit unreachable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate's correctness rested on a patience argument: nobody performs 2^63 claims over a channel's lifetime, so increments never carry into bit 63. Replace it with a state invariant: every refused claim subtracts its counter increments back, always after the gate is set — observed in the add's return, or installed by the refusal itself. Subs pair with the same claim's own adds, so the counter never drops below the successful count or any seal snapshot, and never rises past the table length plus one per claim in flight. Reaching the bit by counting would now take 2^63 simultaneous claims, which a 64-bit address space cannot host — correctness follows from the machine model the module already asserts, not from how long overflow takes. The undo needs no loop because it is contention-free by construction: a claim owns its own +1, and fetch_sub never fails. Gate-before-sub is what keeps the undone values harmless — the gate lives in the same word, so every read of a subtracted count carries the bit with it. The payload counter takes back only out-of-bounds reservations (no live span sits above those); in-bounds reservations of refused claims stay counted, never materialized and capped by the region. The one residue wait- freedom cannot erase is a writer dying between its add and its undo: one count per death, 2^63 deaths to so much as condemn the channel spuriously. Hot path unchanged: a successful claim is still two unconditional fetch_adds. Tests peek the raw counters to pin the invariant down. Co-Authored-By: Claude Fable 5 --- .../src/ipc/channel/shm_io/README.md | 16 ++++----- .../src/ipc/channel/shm_io/layout.rs | 29 +++++++++++----- .../fspy_shared/src/ipc/channel/shm_io/mod.rs | 34 +++++++++++++++---- .../src/ipc/channel/shm_io/writer.rs | 25 ++++++++++---- 4 files changed, 76 insertions(+), 28 deletions(-) diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/README.md b/crates/fspy_shared/src/ipc/channel/shm_io/README.md index 08c789bde..26436bd8c 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/README.md +++ b/crates/fspy_shared/src/ipc/channel/shm_io/README.md @@ -30,9 +30,8 @@ space, not memory: only pages that are actually written get backed. | counters | descriptor table (SLOTS slots) | payloads (the rest, grow up) | ``` -The region starts with one `repr(C)` struct: two `AtomicU64` counters, -which only ever count up, followed by one 8-byte descriptor slot per -frame. The table length is a compile-time constant the channel picks +The region starts with one `repr(C)` struct: two `AtomicU64` counters +followed by one 8-byte descriptor slot per frame. The table length is a compile-time constant the channel picks once for both ends: - the **claim counter** — how many frames were ever claimed. Bit 63 is @@ -154,11 +153,12 @@ CLAIMED (slot 0) ---+ is a `Release` write and the receiver's failed freeze is an `Acquire` read, so an observed descriptor implies fully visible payload bytes. - Once a slot is committed or frozen, nothing ever changes it again. -- Counters only grow. The receiver clamps them to the fixed capacities — - an inflated counter degrades into extra frozen slots, not corruption. - Loss is reported through the CLOSED gate: a failed claim sets it before - the writer carries on, which both marks the result incomplete and - refuses every later claim. +- The counters track the true counts: a refused claim sets the CLOSED + gate — marking the result incomplete and refusing every later claim — + and then subtracts its own increments back, so stragglers can hammer a + sealed channel forever without moving the counter toward the gate bit. + The receiver still clamps its snapshot to the fixed capacities, so even + a scribbled counter degrades into extra frozen slots, not corruption. - The bounds checks on descriptors are what make the `unsafe` reference construction correct: whether the receiver stays memory-safe never depends on another process behaving. diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs index 8857e4b4c..0767884dc 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs @@ -28,8 +28,11 @@ use std::{num::NonZeroU32, ptr::NonNull, sync::atomic::AtomicU64}; /// /// The gate must be a bit, not a sentinel value: stragglers keep /// `fetch_add`ing the counter after it is set, and an OR-ed bit survives -/// 2^63 increments, while any exact value would be destroyed by the -/// first. No realistic claim volume carries into bit 63. +/// increments, while any exact value would be destroyed by the first. +/// The bit itself is unreachable by counting: refusals are net-zero +/// (rule 1), so the counter never rises past the table length plus one +/// per claim in flight, and a 64-bit address space cannot host 2^63 +/// simultaneous claims. pub(super) const CLOSED: u64 = 1 << 63; /// The region's fixed-location part — the protocol counters and the @@ -107,12 +110,22 @@ pub(super) const FROZEN: u64 = 1; // - the **payload counter**: payload bytes ever reserved, bumped by another // wait-free `fetch_add`. // -// Failed claims leave the counters bumped; that is harmless, because the -// receiver clamps instead of trusting the counts, and committed -// descriptors carry their own offset and length, so the counters never -// locate data. The payload counter can even wrap on a long-condemned -// channel — still harmless: wrapping requires prior failures, failures -// set the gate, and the gate refuses every claim before a span is built. +// Refused claims are net-zero: once the gate is set — observed in the +// add's return, or set by the refusal itself, always gate first — they +// subtract their counter increments back. Every subtraction undoes that +// claim's own addition, so the claim counter never drops below the +// successful count or any seal snapshot, and never rises past the table +// length plus one per claim in flight: the gate bit cannot be reached by +// counting on a 64-bit machine, whose address space cannot host 2^63 +// simultaneous claims. A writer that dies between its addition and its +// undo leaves one count behind — the one residue wait-freedom cannot +// erase — and it would take 2^63 such deaths to so much as condemn the +// channel spuriously. The payload counter keeps the in-bounds +// reservations of refused claims (never materialized, and the region +// caps them); it only takes back reservations that lay beyond the +// region, which no live span can sit above. Either way the counters +// never locate data — committed descriptors carry their own offset and +// length — and the receiver clamps instead of trusting the counts. // // # Memory-ordering contract // diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs index e178c691d..c48189319 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs @@ -28,11 +28,12 @@ //! A claim is two wait-free `fetch_add`s — one reserves payload bytes, one //! reserves a descriptor slot — validated against the fixed region bounds //! from the returned old values. A failed claim sets the CLOSED gate as -//! its loss report and leaves the counters bumped, harmlessly: readers -//! clamp to the region capacities, and committed descriptors are -//! self-describing ([`layout`]), so the counters never locate data. Every -//! slot has a fixed location, so an unfinished frame can never hide a -//! later one. +//! its loss report, then takes its increments back: refusals are +//! net-zero, so the counters track the true counts and stragglers can +//! hammer a sealed channel forever without moving them. Committed +//! descriptors are self-describing ([`layout`]), so the counters never +//! locate data, and every slot has a fixed location, so an unfinished +//! frame can never hide a later one. //! //! # Frame lifecycle //! @@ -197,6 +198,16 @@ mod tests { Self { mem: Arc::new(mem), len } } + /// Reads one raw `u64` of the region, for asserting on protocol + /// state the API deliberately does not expose. + fn peek_u64(&self, byte_offset: usize) -> u64 { + // SAFETY: as for `poke_u64`. + let atomic = unsafe { + AtomicU64::from_ptr(self.as_raw_slice().cast::().add(byte_offset).cast()) + }; + atomic.load(Ordering::Relaxed) + } + /// Overwrites one raw `u64` of the region, simulating foreign-process /// corruption of protocol metadata. fn poke_u64(&self, byte_offset: usize, value: u64) { @@ -283,6 +294,9 @@ mod tests { // Larger than the payload region: the claim fails and sets the // gate, which is what tells the receiver a record was lost. assert!(!writer.try_write_frame(&vec![0u8; 2048])); + // The out-of-bounds reservation was taken back: only the four + // bytes of "test" remain counted. + assert!(shm.peek_u64(8) == 4); let frames = collect_frames(&shm); let mut iter = frames.iter(); @@ -441,6 +455,8 @@ mod tests { assert!(writer.try_write_frame(b"x")); } assert!(writer.claim_frame(1.try_into().unwrap()).unwrap_err() == ClaimError::Capacity); + // The refusal was net-zero: the gate is set, the count is intact. + assert!(shm.peek_u64(0) == (1 << 63) | 15); let frames = collect_frames(&shm); assert!(frames.iter().count() == 15); @@ -465,7 +481,13 @@ mod tests { // does not mark the channel incomplete — the operation is outside // the sealed boundary. assert!(writer.is_closed()); - assert!(writer.claim_frame(5.try_into().unwrap()).unwrap_err() == ClaimError::Closed); + let before = shm.peek_u64(0); + for _ in 0..100 { + assert!(writer.claim_frame(5.try_into().unwrap()).unwrap_err() == ClaimError::Closed); + } + // Stragglers leave no trace on the claim counter: refusals are + // net-zero, so the gate can never be carried into by counting. + assert!(shm.peek_u64(0) == before); assert!(frames.is_complete()); } diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs index 5e00a0bf4..6c3234cae 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs @@ -108,16 +108,19 @@ impl ShmWriter { return Err(report_loss()); }; // Payload bytes first, so a payload-capacity failure does not burn a - // slot. A failed reservation stays counted — overshoot is harmless - // because the counter is not what locates payloads (descriptors are) - // and a `u64` cannot realistically wrap. + // slot. let payload_start = mapped.payload_reserved().fetch_add(payload_len as u64, Ordering::Relaxed); // Checked: a foreign scribble of the counter must fail the claim, // not wrap the bound into an out-of-bounds reservation. let payload_end = payload_start.checked_add(payload_len as u64); if payload_end.is_none_or(|end| end > mapped.payloads.len() as u64) { - return Err(report_loss()); + let err = report_loss(); + // Net-zero refusal, gate first (rule 1): undo this claim's own + // reservation. It lay beyond the region, so no live span sits + // above it and the counter cannot dip below one. + mapped.payload_reserved().fetch_sub(payload_len as u64, Ordering::Relaxed); + return Err(err); } // Bounded by the capacity check: the payload region fits 32-bit // offsets. @@ -127,12 +130,22 @@ impl ShmWriter { let claims = mapped.claims().fetch_add(1, Ordering::Relaxed); if claims & CLOSED != 0 { // Not a loss: a record refused after the seal describes an - // operation performed outside the channel's boundary. + // operation performed outside the channel's boundary. Net-zero + // refusal (rule 1): the gate was observed set, so undo the + // increment — the counter holds still instead of drifting + // toward the gate bit. (The payload reservation stays: in + // bounds, never materialized, and capped by the region.) + mapped.claims().fetch_sub(1, Ordering::Relaxed); return Err(ClaimError::Closed); } let slot_index = usize::try_from(claims).expect("claim count exceeds usize"); if slot_index >= SLOTS { - return Err(report_loss()); + let err = report_loss(); + // Net-zero refusal, gate first (rule 1): once the gate is in + // the word, every later read of the counter carries it, so no + // claim can mistake the undone value for an open channel. + mapped.claims().fetch_sub(1, Ordering::Relaxed); + return Err(err); } // SAFETY: the claim reserved From 70eb9c4620d537f3a5ce637e69add84204788977 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sun, 16 Aug 2026 09:07:41 +0800 Subject: [PATCH 51/92] docs(fspy-shm): tighten the comments Shorter sentences, no arguments against designs the code no longer contains, and a few fixes the pass surfaced: the oversize comment still promised the channel keeps working after a refusal (the gate condemns it), the codec comment said header where the region has counters, the writer's struct doc pointed at an ordering contract 'above' that lives in layout, and the reader's module doc opened with a stray blank line and 'closing' where the operation is the seal. Co-Authored-By: Claude Fable 5 --- .../src/ipc/channel/shm_io/layout.rs | 173 ++++++++---------- .../fspy_shared/src/ipc/channel/shm_io/mod.rs | 59 +++--- .../src/ipc/channel/shm_io/reader.rs | 95 +++++----- .../src/ipc/channel/shm_io/writer.rs | 62 +++---- 4 files changed, 166 insertions(+), 223 deletions(-) diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs index 0767884dc..26722f7dc 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs @@ -6,33 +6,25 @@ //! | counters | descriptor table | payloads (grow up) | //! ``` //! -//! The counters and the table have compile-time shape: one `repr(C)` -//! [`Meta`] struct whose table length is a const parameter the channel -//! specifies. The payload area is simply the rest of the mapping, so the -//! whole geometry reduces to that struct's size. This module holds the -//! struct, the descriptor-slot wire format, and [`MappedLayout`]: the -//! views bound to one concrete mapping, built once -//! when an endpoint attaches. The sides themselves live in -//! [`super::writer`] and [`super::reader`]. +//! Counters and table are one `repr(C)` [`Meta`] struct whose table +//! length is a const parameter the channel specifies; the payload area is +//! the rest of the mapping, so the whole geometry is that struct's size. +//! This module holds the struct, the slot wire format, and +//! [`MappedLayout`]: the views bound to one mapping, built once at +//! attach. The sides live in [`super::writer`] and [`super::reader`]. //! -//! Overflow safety follows from one bound enforced at attach time: the -//! payload region fits `u32`, so all offsets fit the 32-bit descriptor -//! fields and all sums fit `usize` on the 64-bit targets the parent -//! module asserts. +//! Overflow safety: the payload area fits `u32` (checked at attach), so +//! all offsets fit the 32-bit descriptor fields and all sums fit `usize` +//! on the 64-bit targets the parent module asserts. use std::{num::NonZeroU32, ptr::NonNull, sync::atomic::AtomicU64}; -/// The CLOSED gate bit of the claim counter, set when the receiver seals -/// the channel and by any writer whose claim failed — the loss report -/// that also condemns the channel (rule 1 below). +/// The CLOSED gate bit of the claim counter: set when the receiver seals +/// the channel, and by any failed claim as its loss report (rule 1). /// -/// The gate must be a bit, not a sentinel value: stragglers keep -/// `fetch_add`ing the counter after it is set, and an OR-ed bit survives -/// increments, while any exact value would be destroyed by the first. -/// The bit itself is unreachable by counting: refusals are net-zero -/// (rule 1), so the counter never rises past the table length plus one -/// per claim in flight, and a 64-bit address space cannot host 2^63 -/// simultaneous claims. +/// A bit survives stragglers' `fetch_add`s, and net-zero refusals keep +/// the count at most the table length plus the claims in flight, so +/// counting can never reach it. pub(super) const CLOSED: u64 = 1 << 63; /// The region's fixed-location part — the protocol counters and the @@ -72,89 +64,68 @@ const _: () = assert!(align_of::>() == align_of::()); // disjoint: `1` is a zero length with offset `1`, which no writer commits. // Once a slot is committed or frozen, nothing ever changes it again. // -// Offsets are measured from the start of the payload area, so no -// descriptor can even name the header or the table. -// -// This defines the format both sides must agree on; encoding lives with -// the writer ([`super::writer`]) and decoding with the reader -// ([`super::reader`]). +// Offsets are measured from the start of the payload area, so a +// descriptor cannot name the counters or the table. pub(super) const UNFINISHED: u64 = 0; -/// The terminal value the seal installs in an unfinished slot: still -/// nothing published (a zero length field), but no longer zero, so a late -/// commit's compare-and-swap from [`UNFINISHED`] loses. +/// The value the seal installs in an unfinished slot: still nothing +/// published (zero length field), but nonzero, so a late commit's +/// compare-and-swap from [`UNFINISHED`] loses. pub(super) const FROZEN: u64 = 1; // --- The mapped layout and the ordering contract --------------------------- -// `MappedLayout::new` builds two typed views of the region — the -// `repr(C)` `Meta` struct (counters and descriptor table) and the untyped -// payload area as a raw slice — once, when an endpoint attaches; the -// endpoint stores them beside the mapping they point into. Every access -// after that is a plain field access or a bounds-checked index. The -// payload area stays raw because writers hold exclusive `&mut` borrows -// into it, which must not alias any shared reference. +// `MappedLayout::new` builds two typed views — the `Meta` struct and the +// raw payload area — once, at attach; the endpoint stores them beside +// the mapping they point into. The payload area stays raw because +// writers hold exclusive `&mut` borrows into it, which must not alias +// any shared reference. // // # Shared atomics // -// The region starts with two independent monotonic `AtomicU64` counters: +// Two independent monotonic `AtomicU64` counters: // // - the **claim counter**: bit 63 is the CLOSED gate, the low bits count -// claims ever attempted. Claiming is one wait-free `fetch_add`; the -// returned old value carries the claim's slot index, the gate, and — by -// comparison against the fixed table capacity — the capacity verdict. -// The gate is set by the receiver's seal and by every failed claim: -// one bit is both the loss report completeness derives from and the -// valve that stops writers spending work on a channel whose result the -// receiver must already reject. -// - the **payload counter**: payload bytes ever reserved, bumped by another -// wait-free `fetch_add`. +// claims. One wait-free `fetch_add` per claim; the returned old value +// carries the slot index, the gate, and the capacity verdict. The gate +// is set by the seal and by every failed claim: one bit is both the +// loss report and the valve that stops writers wasting work on a +// result the receiver must already reject. +// - the **payload counter**: payload bytes reserved, one `fetch_add`. // -// Refused claims are net-zero: once the gate is set — observed in the -// add's return, or set by the refusal itself, always gate first — they -// subtract their counter increments back. Every subtraction undoes that -// claim's own addition, so the claim counter never drops below the -// successful count or any seal snapshot, and never rises past the table -// length plus one per claim in flight: the gate bit cannot be reached by -// counting on a 64-bit machine, whose address space cannot host 2^63 -// simultaneous claims. A writer that dies between its addition and its -// undo leaves one count behind — the one residue wait-freedom cannot -// erase — and it would take 2^63 such deaths to so much as condemn the -// channel spuriously. The payload counter keeps the in-bounds -// reservations of refused claims (never materialized, and the region -// caps them); it only takes back reservations that lay beyond the -// region, which no live span can sit above. Either way the counters -// never locate data — committed descriptors carry their own offset and -// length — and the receiver clamps instead of trusting the counts. +// Refused claims are net-zero: with the gate set — observed, or set by +// the refusal itself, always gate first — they subtract their own +// increments back. Subs pair with the same claim's adds, so the claim +// counter never drops below the successful count or a seal snapshot, +// and never rises past the table length plus the claims in flight: +// counting cannot reach the gate bit. (A writer dying between add and +// undo strands one count — harmless short of 2^63 such deaths.) The +// payload counter takes back only out-of-bounds reservations, which no +// live span sits above; in-bounds reservations of refused claims stay +// counted, never materialized, capped by the region. Counters never +// locate data — descriptors carry their own offset and length — and the +// receiver clamps rather than trusts them. // // # Memory-ordering contract // -// Three synchronization rules cover the whole protocol: -// -// 1. **Claim versus seal** — the receiver's seal boundary is a plain -// snapshot load of the claim counter: claims ordered at or before the -// value it reads (in the counter's modification order) are in the -// snapshot; later ones receive slot indices the receiver never visits. -// Claims publish no payload data, so `Relaxed` suffices throughout. -// The CLOSED gate is not itself the boundary — it stops stragglers -// from claiming (and allocating pages) forever; any claim admitted -// between the snapshot and the gate lands beyond the snapshot and is -// never observed. Completeness rides the same modification order: a -// failed claim sets the gate as its loss report before the writer -// performs the operation whose record was lost — so the snapshot -// either sees the bit, or the loss belongs to an operation performed -// after the boundary. A writer that skipped because it saw the bit is -// covered the same way: the bit that made it skip either reaches the -// snapshot or postdates the boundary. A writer that dies before -// setting the bit never performed its operation, so nothing was -// actually lost. -// 2. **Writer commit** — the slot compare-and-swap in `FrameMut::finish` -// uses `Release`: every payload write happens-before the committed -// descriptor becomes visible. +// 1. **Claim versus seal** — the seal boundary is a plain snapshot load +// of the claim counter: claims at or before it in the counter's +// modification order are in; later ones get slot indices the receiver +// never visits. Claims publish no payload data, so `Relaxed` +// suffices. The gate is not the boundary — it only stops stragglers; +// a claim admitted between snapshot and gate lands beyond the +// snapshot and is never observed. Completeness rides the same +// modification order: a failed claim sets the gate before performing +// the operation whose record was lost, so the snapshot sees the bit +// or the loss is post-boundary; a writer that skipped on seeing the +// bit is covered the same way; one that died before setting it never +// performed its operation, so nothing was lost. +// 2. **Writer commit** — `FrameMut::finish`'s compare-and-swap uses +// `Release`: every payload write happens-before the descriptor is +// visible. // 3. **Receiver observation** — the freeze compare-and-swap in -// `ShmReader::seal` uses `Acquire` on failure: observing a committed -// descriptor also makes the payload writes it published visible, so the -// borrows `ShmReader` later hands out read settled bytes. +// `ShmReader::seal` uses `Acquire` on failure: an observed descriptor +// implies fully visible payload bytes. /// Casts to a pointer of another type, returning `None` when the pointer /// is not aligned for `U`: a stable stand-in for the still-unstable @@ -165,10 +136,10 @@ fn try_cast_aligned(ptr: *mut T) -> Option<*mut U> { if ptr.addr().is_multiple_of(align_of::()) { Some(ptr.cast()) } else { None } } -/// The typed views of the region: the fixed-location [`Meta`] struct -/// and the raw payload area. Built once when an endpoint attaches and -/// stored in it; the views stay valid because they point into the -/// mapping's stable target, not into the endpoint value. +/// The typed views of the region: the fixed [`Meta`] struct and the raw +/// payload area. Built once at attach and stored in the endpoint; valid +/// as long as the mapping, since they point into its stable target, not +/// into the endpoint value. #[derive(Clone, Copy)] pub(super) struct MappedLayout { meta: NonNull>, @@ -216,10 +187,10 @@ impl SlotState { } impl MappedLayout { - /// Builds the typed views of a shared mapping, or `None` when the - /// mapping cannot host the protocol at all: base null or not - /// `u64`-aligned, [`Meta`] not fitting inside the mapping, or the - /// payload area beyond the descriptors' 32-bit offsets. + /// Builds the typed views of a shared mapping, or `None` when it + /// cannot host the protocol: base null or unaligned, [`Meta`] not + /// fitting, or a payload area beyond the descriptors' 32-bit + /// offsets. /// /// # Safety /// @@ -231,7 +202,7 @@ impl MappedLayout { pub(super) unsafe fn new(mem: *mut [u8]) -> Option { let mem_start = mem.cast::(); let mem_len = mem.len(); - // The mapping must be large enough to hold the fixed-location struct. + // The mapping must hold the fixed struct. let payload_len = mem_len.checked_sub(size_of::>())?; // Descriptors store payload offsets in 32 bits: the conversion is // the bound on the payload area. @@ -239,15 +210,15 @@ impl MappedLayout { // The pointer conversions are the base checks: aligned, non-null. let meta = NonNull::new(try_cast_aligned::<_, Meta>(mem_start)?)?; - // The payload area is everything after the fixed-location struct. - // SAFETY: the mapping is large enough to hold it (checked above). + // The payload area is everything after the fixed struct. + // SAFETY: the mapping holds the struct (checked above). let payload_start = unsafe { mem_start.add(size_of::>()) }; let payloads = std::ptr::slice_from_raw_parts_mut(payload_start, payload_len); Some(Self { meta, payloads }) } - /// The fixed-location part of the region. + /// The fixed part of the region. const fn meta(&self) -> &Meta { // SAFETY: `new`'s contract keeps the target valid while any view // is used, and `Meta` consists entirely of atomics, so the shared diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs index c48189319..c387c3523 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs @@ -18,20 +18,18 @@ //! fixed descriptor table payloads grow up -> //! ``` //! -//! The counters and the descriptor table form one `repr(C)` struct whose -//! table length is a compile-time constant every endpoint shares (the -//! channel specifies it); the payload area is simply the rest of the -//! mapping ([`layout`]). Attaching constructs typed views of that struct -//! — two monotonic `AtomicU64` counters: claims, carrying the CLOSED -//! gate bit, and payload bytes reserved — while the payload area stays +//! Counters and table are one `repr(C)` struct whose table length is a +//! compile-time constant every endpoint shares (the channel specifies +//! it); the payload area is the rest of the mapping ([`layout`]). +//! Attaching builds typed views of the struct; the payload area stays //! untyped bytes. -//! A claim is two wait-free `fetch_add`s — one reserves payload bytes, one -//! reserves a descriptor slot — validated against the fixed region bounds -//! from the returned old values. A failed claim sets the CLOSED gate as -//! its loss report, then takes its increments back: refusals are -//! net-zero, so the counters track the true counts and stragglers can -//! hammer a sealed channel forever without moving them. Committed -//! descriptors are self-describing ([`layout`]), so the counters never +//! +//! A claim is two wait-free `fetch_add`s — one reserves payload bytes, +//! one a descriptor slot — checked against the fixed bounds from the +//! returned old values. A failed claim sets the CLOSED gate as its loss +//! report, then takes its increments back: refusals are net-zero, so +//! stragglers can hammer a sealed channel forever without moving the +//! counters. Descriptors are self-describing, so the counters never //! locate data, and every slot has a fixed location, so an unfinished //! frame can never hide a later one. //! @@ -45,30 +43,27 @@ //! receiver freeze CAS wins //! ``` //! -//! A payload becomes reachable only through its committed descriptor, and a +//! A payload is reachable only through its committed descriptor, and a //! descriptor is committed only after the payload is fully written -//! (the ordering contract below). The receiver never derives frame -//! locations from payload bytes, and the borrows [`ShmReader`] hands out cover -//! exactly the validated committed spans — immutable under the protocol, -//! and disjoint from everything a live writer may still touch (see the -//! receiver section's trust argument below). +//! ([`layout`]'s ordering contract). The receiver never derives frame +//! locations from payload bytes, and the borrows [`ShmReader`] hands out +//! cover exactly the validated committed spans — immutable, and disjoint +//! from everything a live writer may still touch. //! //! # Seal boundary //! -//! [`ShmReader::seal`]'s boundary is a snapshot of the claim counter. -//! A writer admitted before the snapshot races the freeze pass per slot and -//! its frame is either included (commit won) or ignored (abort won) — never +//! [`ShmReader::seal`]'s boundary is a snapshot of the claim counter. A +//! writer admitted before the snapshot races the freeze pass per slot — +//! its frame is included (commit won) or ignored (freeze won), never //! torn; a claim after the snapshot lands in a slot the receiver never -//! visits and is dropped, and the CLOSED gate set before the seal returns -//! stops stragglers from claiming (and materializing pages) forever. Both -//! drops are sound because writers publish a record *before* performing the -//! recorded operation: a process that died mid-frame never performed the -//! operation, and one that claimed or committed after the snapshot performs -//! it outside the channel's boundary. A record refused *before* the seal — -//! a full region, an oversized frame — sets the CLOSED gate first, so the -//! channel reports itself incomplete ([`ShmReader::is_complete`]) and -//! refuses every later claim: once one record is lost the receiver must -//! reject the result, and further records would be wasted work. +//! visits, until the CLOSED gate stops stragglers for good. Both drops +//! are sound because writers publish a record *before* performing the +//! recorded operation: a writer that died mid-frame never performed it, +//! and one that claimed or committed after the snapshot performs it +//! outside the channel's boundary. A record refused *before* the seal — +//! full region, oversized frame — sets the gate first, so the channel +//! reports itself incomplete ([`ShmReader::is_complete`]) and refuses +//! every later claim: one lost record already condemns the result. //! //! Correctness never depends on writer-side cleanup: no exit hooks, PID //! checks, heartbeats, or timeouts. diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs index 78f04d26e..e01116ac0 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs @@ -1,16 +1,13 @@ //! The reader side: seal the channel, then iterate the committed frames. //! -//! -//! Closing never waits for writers, and no payload byte is read or copied: -//! the reader keeps the mapping alive and hands out borrows of the validated -//! committed spans on demand. Those borrows are sound because of the -//! protocol, not despite it: a committed span is never written again -//! (committing consumes the writer's frame), every borrow covers exactly one -//! validated committed span, and everything a live writer may still touch — -//! counters, slots, its own claimed or abandoned spans — is disjoint from -//! every committed span. This rests on the constructor contract that the -//! region is accessed only through this protocol; a process scribbling -//! outside the protocol is outside the trust model. +//! Sealing never waits for writers, and no payload byte is read or +//! copied: the reader keeps the mapping alive and lends out each +//! committed span on demand. The borrows are sound because a committed +//! span is never written again (committing consumes the writer's frame) +//! and is disjoint from everything a live writer may still touch. This +//! rests on the attach contract that the region is accessed only through +//! this protocol; a process scribbling outside it is outside the trust +//! model. use std::{ fmt, slice, @@ -60,15 +57,13 @@ unsafe impl Send for ShmReader {} unsafe impl Sync for ShmReader {} impl ShmReader { - /// Seals the channel — no further records — and returns the - /// reader of its committed frames. + /// Seals the channel — no further records — and returns the reader + /// of its committed frames. /// /// Never blocks on writers: writers admitted before the snapshot race - /// per slot, and each raced slot independently ends up committed - /// (included) or frozen (excluded). Claims after the snapshot land in - /// slots this pass never visits until the CLOSED gate — set before - /// this returns — stops them. See the protocol docs at the top of this - /// module. + /// per slot, ending committed (included) or frozen (excluded); claims + /// after the snapshot land in slots this pass never visits, until the + /// CLOSED gate — set before this returns — stops them. /// /// # Safety /// @@ -96,31 +91,26 @@ impl ShmReader { let complete; { // The seal boundary (rule 1): claims at or before this - // snapshot are inside it, later ones land in slots this pass - // never visits. The count is clamped to the table capacity, so - // a counter inflated by failed claims (or by a foreign - // scribble) degrades to a full-table sweep, not an error. + // snapshot are in; later ones land in slots this pass never + // visits. Clamped, so an inflated counter degrades to a + // full-table sweep, not an error. let claims = mapped.claims().load(Ordering::Relaxed); slot_count = usize::try_from(claims & !CLOSED).unwrap_or(usize::MAX).min(SLOTS); - // The same load carries the completeness verdict: a gate set - // before this boundary is a failed claim's loss report — or an - // earlier seal, and a re-seal cannot vouch for records - // refused since then (rule 1). + // The same load carries the verdict: a gate set before the + // boundary is a loss report — or an earlier seal, and a + // re-seal cannot vouch for records refused since then. complete = claims & CLOSED == 0; - // Gate further claims, so stragglers stop claiming (and - // materializing pages) forever. Cheap: the creator pre-faulted - // this page where first touches are expensive. Claims racing - // between the snapshot and this gate are dropped soundly (see - // the module docs above). + // Gate further claims, so stragglers stop claiming and + // materializing pages. Claims racing in between are dropped + // soundly (rule 1). mapped.claims().fetch_or(CLOSED, Ordering::Relaxed); - // Freeze pass: drive every admitted slot to a terminal state - // and validate the committed descriptors. After this loop the - // snapshot's slice of the descriptor table can no longer change - // — late writers lose their commit race against `FROZEN` — so - // iteration re-reads the table instead of snapshotting it: - // nothing is copied or allocated. + // Freeze pass: drive every admitted slot terminal and + // validate committed descriptors. Afterwards this prefix of + // the table can never change — late commits lose to `FROZEN` + // — so iteration re-reads it: nothing copied, nothing + // allocated. for slot_index in 0..slot_count { // Rule 3: `Acquire` on failure makes a committed payload // visible. @@ -136,11 +126,10 @@ impl ShmReader { }; match SlotState::decode(bits) { // Frozen by an earlier seal, or a scribble that - // published nothing; either way there is no frame. + // published nothing: no frame either way. SlotState::Unfinished => {} SlotState::Committed { offset, len } => { - // The bounds check that makes the span readable; a - // span no correct writer could have committed + // A span no correct writer could have committed // fails the whole channel. if offset as usize + len.get() as usize > mapped.payloads.len() { return Err(ProtocolError::CorruptDescriptor { slot_index }); @@ -161,10 +150,10 @@ impl ShmReader { /// Whether every record a writer published made it in. /// - /// False when a claim failed before the seal — the region - /// was out of space, or a frame exceeded the frame limit: its record - /// was lost, and the frames under-report what writers went on to do. - /// Consumers that need completeness must reject them. + /// False when a claim failed before the seal — out of space, or an + /// oversized frame: the record was lost, and the frames under-report + /// what writers went on to do. Consumers that need completeness must + /// reject them. #[must_use] pub const fn is_complete(&self) -> bool { self.complete @@ -196,14 +185,13 @@ impl<'a, const SLOTS: usize> Iterator for Iter<'a, SLOTS> { fn next(&mut self) -> Option { while let Some((slot, rest)) = self.table.split_first() { self.table = rest; - // The slot is terminal (`seal` froze it), so this plain load - // reads the same value the freeze pass saw; the transfer that - // carried the reader to this thread carried the freeze pass's - // `Acquire` payload visibility with it (rule 3). + // The slot is terminal, so this plain load reads what the + // freeze pass saw; whatever carried the reader to this thread + // carried the freeze pass's `Acquire` visibility too (rule 3). let bits = slot.load(Ordering::Relaxed); - // An unfinished slot published nothing; out-of-bounds spans - // cannot appear — `seal` already failed the channel on them — - // but the check below keeps this `unsafe` locally justified. + // Unfinished published nothing. Out-of-bounds cannot appear + // — `seal` failed the channel on it — but checking keeps the + // `unsafe` below locally justified. let SlotState::Committed { offset, len } = SlotState::decode(bits) else { continue; }; @@ -212,9 +200,8 @@ impl<'a, const SLOTS: usize> Iterator for Iter<'a, SLOTS> { continue; } self.remaining -= 1; - // SAFETY: the span lies inside the payload area (checked - // above), and a committed span is immutable for the mapping's - // lifetime (see the module docs above); the reader borrowed + // SAFETY: the span is in bounds (checked above) and + // immutable for the mapping's lifetime; the reader borrowed // for `'a` keeps the mapping alive and mapped. return Some(unsafe { slice::from_raw_parts( diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs index 6c3234cae..d4c8115df 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs @@ -15,9 +15,9 @@ use super::{ /// A concurrent shared-memory frame writer. /// -/// Safe to use across threads and processes at the same time: frames are -/// reserved with atomic operations, filled in uniquely owned payload spans, -/// and published with an atomic commit (see the ordering contract above). +/// Safe to use across threads and processes at once: frames are reserved +/// atomically, filled in uniquely owned payload spans, and published with +/// an atomic commit (the ordering contract in [`layout`]). pub struct ShmWriter { mapped: MappedLayout, /// Owns the region the views point into. Declared after them: fields @@ -77,33 +77,28 @@ impl ShmWriter { self.mapped.claims().load(Ordering::Relaxed) & CLOSED != 0 } - /// Claims a frame of exactly `frame_size` bytes. + /// Claims a frame of exactly `frame_size` bytes. Wait-free: two + /// `fetch_add`s, no retry loop (rule 1). /// /// The frame is invisible to the receiver until [`FrameMut::finish`] - /// commits it. Dropping the frame without finishing abandons the claim: - /// the receiver ignores the slot, exactly as if the writer had died. - /// Frames larger than `u32::MAX` bytes are refused as - /// [`ClaimError::Capacity`]. - /// - /// Wait-free: two `fetch_add`s, no retry loop (rule 1). A claim that - /// does not fit fails after setting the CLOSED gate: the receiver - /// learns a record was lost, and later claims are refused — their - /// records would ride a result the receiver must already reject. + /// commits it; dropping it instead abandons the claim, and the + /// receiver ignores the slot exactly as if the writer had died. A + /// claim that does not fit — the region is full, or `frame_size` + /// exceeds `u32::MAX` — fails as [`ClaimError::Capacity`] after + /// setting the CLOSED gate. pub fn claim_frame(&self, frame_size: NonZeroUsize) -> Result, ClaimError> { let mapped = self.mapped; let payload_len = frame_size.get(); - // Reports that this claim's record was lost, before the writer - // moves on (rule 1): the gate makes the receiver report the - // channel incomplete, and condemns further claims. + // The loss report (rule 1): the gate marks the result incomplete + // and condemns further claims. let report_loss = || { mapped.claims().fetch_or(CLOSED, Ordering::Relaxed); ClaimError::Capacity }; - // The descriptor's 32-bit nonzero length field is the oversize - // check: refuse, before touching the counters, a frame it cannot - // describe — the channel keeps working for every record after it. + // The descriptor's nonzero 32-bit length field is the oversize + // check; no counter was touched, so nothing to undo. let Ok(encoded_len) = NonZeroU32::try_from(frame_size) else { return Err(report_loss()); }; @@ -116,9 +111,8 @@ impl ShmWriter { let payload_end = payload_start.checked_add(payload_len as u64); if payload_end.is_none_or(|end| end > mapped.payloads.len() as u64) { let err = report_loss(); - // Net-zero refusal, gate first (rule 1): undo this claim's own - // reservation. It lay beyond the region, so no live span sits - // above it and the counter cannot dip below one. + // Net-zero (rule 1), gate first: undo the reservation. It lay + // beyond the region, so no live span sits above it. mapped.payload_reserved().fetch_sub(payload_len as u64, Ordering::Relaxed); return Err(err); } @@ -129,30 +123,26 @@ impl ShmWriter { let claims = mapped.claims().fetch_add(1, Ordering::Relaxed); if claims & CLOSED != 0 { - // Not a loss: a record refused after the seal describes an - // operation performed outside the channel's boundary. Net-zero - // refusal (rule 1): the gate was observed set, so undo the - // increment — the counter holds still instead of drifting - // toward the gate bit. (The payload reservation stays: in - // bounds, never materialized, and capped by the region.) + // Not a loss: a record refused after the seal is outside the + // channel's boundary. Net-zero (rule 1): the gate was + // observed, undo the increment. (The payload reservation + // stays: in bounds, never materialized, capped by the + // region.) mapped.claims().fetch_sub(1, Ordering::Relaxed); return Err(ClaimError::Closed); } let slot_index = usize::try_from(claims).expect("claim count exceeds usize"); if slot_index >= SLOTS { let err = report_loss(); - // Net-zero refusal, gate first (rule 1): once the gate is in - // the word, every later read of the counter carries it, so no - // claim can mistake the undone value for an open channel. + // Net-zero (rule 1), gate first: with the bit in the word, no + // later read mistakes the undone count for an open channel. mapped.claims().fetch_sub(1, Ordering::Relaxed); return Err(err); } - // SAFETY: the claim reserved - // `[payload_start, payload_start + payload_len)` — inside the - // payload region by the capacity check above — exclusively for this - // frame: other writers reserve disjoint (if byte-adjacent) spans, - // and the receiver never reads a payload before observing its + // SAFETY: the claim reserved this span — in bounds by the + // capacity check — exclusively: other writers reserve disjoint + // spans, and the receiver reads no payload before observing its // committed descriptor, which `finish` publishes only when it // consumes this borrow. let content = unsafe { From eafdf807ac14ec480e36bcf039a6ce8eb56f2963 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sun, 16 Aug 2026 11:08:03 +0800 Subject: [PATCH 52/92] refactor(fspy-shm): exact width conversions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Derive the payload counter's operand from the checked `NonZeroU32` frame size, so widening it is an infallible `u64::from` rather than a cast, and give each quantity in `claim_frame` one name that narrows in type as its bounds are established. Store the payload area as `NonNull` plus the `u32` length the attach check already proves, instead of a `*mut [u8]` whose slice-ness every use site discarded. Bounds checks then stay in 32 bits with `checked_add`, assuming nothing about the target's pointer width. The one conversion that does assume it — counter value to slot index — is a function carrying its own static assert, which replaces the module-scope one so the assert sits in the code it licenses. Also rewrite the comments throughout in plainer words. Co-Authored-By: Claude Fable 5 --- .../src/ipc/channel/shm_io/layout.rs | 157 ++++++++++-------- .../fspy_shared/src/ipc/channel/shm_io/mod.rs | 115 +++++++------ .../src/ipc/channel/shm_io/reader.rs | 143 ++++++++-------- .../src/ipc/channel/shm_io/writer.rs | 149 +++++++++-------- 4 files changed, 297 insertions(+), 267 deletions(-) diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs index 26722f7dc..266cd5417 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs @@ -10,26 +10,27 @@ //! length is a const parameter the channel specifies; the payload area is //! the rest of the mapping, so the whole geometry is that struct's size. //! This module holds the struct, the slot wire format, and -//! [`MappedLayout`]: the views bound to one mapping, built once at -//! attach. The sides live in [`super::writer`] and [`super::reader`]. +//! [`MappedLayout`]: where the parts of one mapping are, worked out once +//! at attach. The sides live in [`super::writer`] and [`super::reader`]. //! -//! Overflow safety: the payload area fits `u32` (checked at attach), so -//! all offsets fit the 32-bit descriptor fields and all sums fit `usize` -//! on the 64-bit targets the parent module asserts. +//! Overflow safety: the payload area is never longer than `u32::MAX` +//! bytes (checked at attach), so an offset and a length always fit a +//! descriptor's 32-bit fields, and bounds checks add them in 32 bits. use std::{num::NonZeroU32, ptr::NonNull, sync::atomic::AtomicU64}; /// The CLOSED gate bit of the claim counter: set when the receiver seals /// the channel, and by any failed claim as its loss report (rule 1). /// -/// A bit survives stragglers' `fetch_add`s, and net-zero refusals keep -/// the count at most the table length plus the claims in flight, so -/// counting can never reach it. +/// A bit, not a value to compare against: it survives the `fetch_add` of +/// a writer that arrives late. And a refused claim puts back what it +/// added, so the count stays at or below one per slot plus the claims +/// still running — it can never climb into this bit. pub(super) const CLOSED: u64 = 1 << 63; -/// The region's fixed-location part — the protocol counters and the -/// descriptor table — as one `repr(C)` struct, which must start zeroed. -/// The payload area is simply the rest of the mapping. +/// The part of the region that is always in the same place — the +/// counters and the descriptor table — as one `repr(C)` struct, which +/// must start zeroed. The payload area is the rest of the mapping. #[repr(C)] pub(super) struct Meta { /// Bit 63 is the CLOSED gate; the low bits count claims ever attempted. @@ -40,8 +41,8 @@ pub(super) struct Meta { pub(super) table: [AtomicU64; SLOTS], } -// The `u64`-aligned mapping base is cast to `&Meta`; nothing in it may -// raise the alignment. +// The mapping starts at a `u64`-aligned address and is cast to `&Meta`, +// so no field in `Meta` may need more alignment than that. const _: () = assert!(align_of::>() == align_of::()); // --- The descriptor slot codec --------------------------------------------- @@ -59,13 +60,13 @@ const _: () = assert!(align_of::>() == align_of::()); // | `1` | Frozen by the seal: never publishable again | // | length field nonzero | Committed: offset and length of the payload | // -// Committed lengths are nonzero (a zero-length frame is never claimed), so -// a committed value's length field is nonzero and the three states are -// disjoint: `1` is a zero length with offset `1`, which no writer commits. -// Once a slot is committed or frozen, nothing ever changes it again. +// A claimed frame is never zero-length, so a committed value always has a +// nonzero length field and no slot value can mean two of these at once: +// `1` is a zero length with offset `1`, which no writer ever commits. Once +// a slot is committed or frozen, nothing ever changes it again. // -// Offsets are measured from the start of the payload area, so a -// descriptor cannot name the counters or the table. +// Offsets are counted from the start of the payload area, so a descriptor +// can never point into the counters or the table. pub(super) const UNFINISHED: u64 = 0; @@ -75,36 +76,38 @@ pub(super) const UNFINISHED: u64 = 0; pub(super) const FROZEN: u64 = 1; // --- The mapped layout and the ordering contract --------------------------- -// `MappedLayout::new` builds two typed views — the `Meta` struct and the -// raw payload area — once, at attach; the endpoint stores them beside -// the mapping they point into. The payload area stays raw because -// writers hold exclusive `&mut` borrows into it, which must not alias -// any shared reference. +// `MappedLayout::new` works out both pointers once, at attach: one to the +// `Meta` struct, one to the payload area. The endpoint keeps them next to +// the mapping they point into. The payload pointer stays raw because +// writers hand out `&mut` slices into it, which must not overlap a shared +// reference. // // # Shared atomics // // Two independent monotonic `AtomicU64` counters: // // - the **claim counter**: bit 63 is the CLOSED gate, the low bits count -// claims. One wait-free `fetch_add` per claim; the returned old value -// carries the slot index, the gate, and the capacity verdict. The gate -// is set by the seal and by every failed claim: one bit is both the -// loss report and the valve that stops writers wasting work on a -// result the receiver must already reject. +// claims. One wait-free `fetch_add` per claim, and the old value it +// returns says everything the writer needs: which slot it got, whether +// the channel is closed, and whether the table is full. The seal sets +// the gate, and so does every failed claim: the one bit both reports +// the loss and stops later writers doing work for a result the +// receiver must already reject. // - the **payload counter**: payload bytes reserved, one `fetch_add`. // -// Refused claims are net-zero: with the gate set — observed, or set by -// the refusal itself, always gate first — they subtract their own -// increments back. Subs pair with the same claim's adds, so the claim -// counter never drops below the successful count or a seal snapshot, -// and never rises past the table length plus the claims in flight: -// counting cannot reach the gate bit. (A writer dying between add and -// undo strands one count — harmless short of 2^63 such deaths.) The -// payload counter takes back only out-of-bounds reservations, which no -// live span sits above; in-bounds reservations of refused claims stay -// counted, never materialized, capped by the region. Counters never -// locate data — descriptors carry their own offset and length — and the -// receiver clamps rather than trusts them. +// A refused claim puts back what it added, always after the gate is set +// — either it saw the gate, or it set the gate itself. Each subtraction +// pairs with that same claim's addition, so the claim counter never +// falls below the number of successful claims, nor below what a seal +// already saw, and never climbs past one per slot plus the claims still +// running: it cannot count up into the gate bit. (A writer that dies +// between adding and putting back leaves one count behind — harmless +// unless it happens 2^63 times.) The payload counter takes back only +// reservations that ran off the end, which no live frame sits above; a +// refused claim that did fit keeps its bytes counted, and no one ever +// writes there. Neither counter says where data is — each descriptor +// carries its own offset and length — and the receiver clamps them +// rather than trusting them. // // # Memory-ordering contract // @@ -112,12 +115,13 @@ pub(super) const FROZEN: u64 = 1; // of the claim counter: claims at or before it in the counter's // modification order are in; later ones get slot indices the receiver // never visits. Claims publish no payload data, so `Relaxed` -// suffices. The gate is not the boundary — it only stops stragglers; -// a claim admitted between snapshot and gate lands beyond the -// snapshot and is never observed. Completeness rides the same +// suffices. The gate is not the boundary — it only stops late +// writers; a claim that gets in between the snapshot and the gate +// lands past the snapshot, where the receiver never looks. Completeness rides the same // modification order: a failed claim sets the gate before performing -// the operation whose record was lost, so the snapshot sees the bit -// or the loss is post-boundary; a writer that skipped on seeing the +// the operation whose record was lost, so either the snapshot sees +// the bit or the loss happened after the boundary; a writer that +// skipped on seeing the // bit is covered the same way; one that died before setting it never // performed its operation, so nothing was lost. // 2. **Writer commit** — `FrameMut::finish`'s compare-and-swap uses @@ -127,6 +131,18 @@ pub(super) const FROZEN: u64 = 1; // `ShmReader::seal` uses `Acquire` on failure: an observed descriptor // implies fully visible payload bytes. +/// Converts a counter value to a slot index. +/// +/// Never loses bits: the assert lets only targets whose `usize` is 64 +/// bits — the width of the counters — build this module. That assert is +/// the only reason the cast below is safe, so it sits inside the +/// function rather than at module scope. +#[expect(clippy::cast_possible_truncation, reason = "the assert allows only equal widths")] +pub(super) const fn to_usize(value: u64) -> usize { + const { assert!(size_of::() == size_of::(), "requires a 64-bit target") }; + value as usize +} + /// Casts to a pointer of another type, returning `None` when the pointer /// is not aligned for `U`: a stable stand-in for the still-unstable /// [`<*mut T>::try_cast_aligned`][std]. @@ -136,14 +152,20 @@ fn try_cast_aligned(ptr: *mut T) -> Option<*mut U> { if ptr.addr().is_multiple_of(align_of::()) { Some(ptr.cast()) } else { None } } -/// The typed views of the region: the fixed [`Meta`] struct and the raw -/// payload area. Built once at attach and stored in the endpoint; valid -/// as long as the mapping, since they point into its stable target, not -/// into the endpoint value. +/// Where the two parts of the region are: the fixed [`Meta`] struct and +/// the payload area. Worked out once at attach and kept in the endpoint. +/// The pointers stay valid as long as the mapping does, because they +/// point into the mapped memory, not into the endpoint holding them. #[derive(Clone, Copy)] pub(super) struct MappedLayout { meta: NonNull>, - pub(super) payloads: *mut [u8], + /// Start of the payload area. A raw pointer, not a reference: + /// writers hand out `&mut` slices into it, which must not overlap a + /// shared reference. + pub(super) payloads: NonNull, + /// Length of the payload area. A `u32`, the width of a descriptor's + /// offset and length, so bounds checks need no conversion. + pub(super) payload_len: u32, } /// A decoded descriptor slot (the codec above). @@ -187,41 +209,40 @@ impl SlotState { } impl MappedLayout { - /// Builds the typed views of a shared mapping, or `None` when it - /// cannot host the protocol: base null or unaligned, [`Meta`] not - /// fitting, or a payload area beyond the descriptors' 32-bit - /// offsets. + /// Locates the parts of a shared mapping, or returns `None` when the + /// mapping cannot hold the protocol: a null or misaligned start, too + /// little room for [`Meta`], or a payload area too long for a 32-bit + /// offset to reach. /// /// # Safety /// /// - `mem` must be valid for reads and writes, and its address stable, - /// for as long as the returned views (and any copy of them) are + /// for as long as the returned pointers (and any copy of them) are /// used. /// - The memory must have been zero-initialized when the region was /// created, and accessed only through this protocol since. pub(super) unsafe fn new(mem: *mut [u8]) -> Option { let mem_start = mem.cast::(); - let mem_len = mem.len(); // The mapping must hold the fixed struct. - let payload_len = mem_len.checked_sub(size_of::>())?; - // Descriptors store payload offsets in 32 bits: the conversion is - // the bound on the payload area. - u32::try_from(payload_len).ok()?; - // The pointer conversions are the base checks: aligned, non-null. + let payload_len = mem.len().checked_sub(size_of::>())?; + // A descriptor holds a 32-bit offset, so the payload area can be + // no longer than a `u32`. Keeping the converted value is what lets + // later bounds checks stay in 32 bits. + let payload_len = u32::try_from(payload_len).ok()?; + // These two conversions are the checks on the start address: + // aligned for `Meta`, and not null. let meta = NonNull::new(try_cast_aligned::<_, Meta>(mem_start)?)?; // The payload area is everything after the fixed struct. // SAFETY: the mapping holds the struct (checked above). - let payload_start = unsafe { mem_start.add(size_of::>()) }; - - let payloads = std::ptr::slice_from_raw_parts_mut(payload_start, payload_len); - Some(Self { meta, payloads }) + let payloads = NonNull::new(unsafe { mem_start.add(size_of::>()) })?; + Some(Self { meta, payloads, payload_len }) } /// The fixed part of the region. const fn meta(&self) -> &Meta { - // SAFETY: `new`'s contract keeps the target valid while any view - // is used, and `Meta` consists entirely of atomics, so the shared + // SAFETY: `new`'s contract keeps the memory valid while any + // pointer is used, and `Meta` is all atomics, so the shared // borrow is valid even while other threads and processes access // the same memory through them. unsafe { self.meta.as_ref() } diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs index c387c3523..cfc0aa5af 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs @@ -1,9 +1,10 @@ //! A crash-tolerant, nonblocking frame channel in a shared memory region. //! -//! Multiple writer processes append variable-length frames concurrently; one +//! Many writer processes append variable-length frames at once; one //! receiver closes the channel and collects every committed frame without -//! waiting for any writer. A process may die at any instruction — mid-claim, -//! mid-write, pre-commit — and only its own unfinished frame is lost. +//! waiting for any writer. A process may die at any instruction — while +//! claiming, while writing, just before committing — and the only thing +//! lost is its own unfinished frame. //! //! `README.md` in this directory tells the whole story in plain words and //! indexes the modules. @@ -19,19 +20,19 @@ //! ``` //! //! Counters and table are one `repr(C)` struct whose table length is a -//! compile-time constant every endpoint shares (the channel specifies -//! it); the payload area is the rest of the mapping ([`layout`]). -//! Attaching builds typed views of the struct; the payload area stays -//! untyped bytes. +//! compile-time constant both sides share (the channel picks it); the +//! payload area is the rest of the mapping ([`layout`]). Attaching works +//! out where the struct and the payload area start; the payload area +//! stays plain bytes. //! //! A claim is two wait-free `fetch_add`s — one reserves payload bytes, -//! one a descriptor slot — checked against the fixed bounds from the -//! returned old values. A failed claim sets the CLOSED gate as its loss -//! report, then takes its increments back: refusals are net-zero, so -//! stragglers can hammer a sealed channel forever without moving the -//! counters. Descriptors are self-describing, so the counters never -//! locate data, and every slot has a fixed location, so an unfinished -//! frame can never hide a later one. +//! one a descriptor slot — and the old values they return are what the +//! writer checks against the region's fixed size. A failed claim sets the +//! CLOSED gate to report the loss, then puts its increments back, so late +//! writers can hammer a sealed channel forever without moving the +//! counters. Each descriptor carries its own offset and length, so the +//! counters never say where data is, and every slot sits at a fixed +//! place, so an unfinished frame can never hide a later one. //! //! # Frame lifecycle //! @@ -43,30 +44,31 @@ //! receiver freeze CAS wins //! ``` //! -//! A payload is reachable only through its committed descriptor, and a -//! descriptor is committed only after the payload is fully written -//! ([`layout`]'s ordering contract). The receiver never derives frame -//! locations from payload bytes, and the borrows [`ShmReader`] hands out -//! cover exactly the validated committed spans — immutable, and disjoint -//! from everything a live writer may still touch. +//! The only way to reach a payload is through its committed descriptor, +//! and a descriptor is committed only after the payload is fully written +//! ([`layout`]'s ordering contract). The receiver never works out where a +//! frame is by reading payload bytes, and the borrows [`ShmReader`] hands +//! out cover exactly the committed spans it checked — nothing writes to +//! them any more, and they never overlap what a live writer may touch. //! //! # Seal boundary //! -//! [`ShmReader::seal`]'s boundary is a snapshot of the claim counter. A -//! writer admitted before the snapshot races the freeze pass per slot — -//! its frame is included (commit won) or ignored (freeze won), never -//! torn; a claim after the snapshot lands in a slot the receiver never -//! visits, until the CLOSED gate stops stragglers for good. Both drops -//! are sound because writers publish a record *before* performing the -//! recorded operation: a writer that died mid-frame never performed it, -//! and one that claimed or committed after the snapshot performs it -//! outside the channel's boundary. A record refused *before* the seal — -//! full region, oversized frame — sets the gate first, so the channel -//! reports itself incomplete ([`ShmReader::is_complete`]) and refuses -//! every later claim: one lost record already condemns the result. +//! [`ShmReader::seal`] draws its line by taking a snapshot of the claim +//! counter. A writer that got in before the snapshot races the freeze +//! pass for its own slot — its frame is either kept (commit won) or +//! skipped (freeze won), never half-read; a claim taken after the +//! snapshot lands in a slot the receiver never looks at, until the CLOSED +//! gate stops late writers for good. Dropping either is safe because a +//! writer writes its record *before* doing what the record describes: one +//! that died mid-frame never did it, and one that claimed or committed +//! after the snapshot does it after the receiver stopped collecting. A +//! record refused *before* the seal — no room, oversized frame — sets the +//! gate first, so the channel reports itself incomplete +//! ([`ShmReader::is_complete`]) and refuses every later claim: one lost +//! record already ruins the result. //! -//! Correctness never depends on writer-side cleanup: no exit hooks, PID -//! checks, heartbeats, or timeouts. +//! None of this needs writers to clean up after themselves: no exit +//! hooks, PID checks, heartbeats, or timeouts. mod layout; mod reader; @@ -87,14 +89,6 @@ pub use reader::ShmReader; pub use writer::ClaimError; pub use writer::ShmWriter; -// The region arithmetic in `layout` relies on `usize` accommodating sums of -// 32-bit-bounded quantities, and the descriptor protocol on native 64-bit -// atomics. -const _: () = assert!( - size_of::() >= size_of::(), - "the shared-memory frame protocol requires a 64-bit target" -); - /// A trait to borrow a raw memory region. pub trait AsRawSlice { fn as_raw_slice(&self) -> *mut [u8]; @@ -125,19 +119,19 @@ impl AsRawSlice for &M { /// Same contract as [`ShmWriter::new`]. #[cfg(target_os = "linux")] pub unsafe fn pre_fault(mem: &impl AsRawSlice) { - // Best-effort: a region that cannot host the protocol needs no + // Best effort: a region that cannot hold the protocol needs no // warm-up — attaching to it will fail anyway. // SAFETY: forwarded from this function's contract. let Some(mapped) = (unsafe { MappedLayout::::new(mem.as_raw_slice()) }) else { return; }; // A compare-exchange of zero with zero on the claim counter: on an - // untouched region it performs a real write — allocating the first - // block of a sparse backing file — without changing protocol state. If - // a claim got there first, the page is already backed and the failed - // exchange changes nothing. (An `or` of zero would not do: the - // compiler may lower it to a plain load, which materializes only a - // hole page without allocating the block.) + // untouched region it is a real write — which makes the file system + // allocate the first block of the sparse file — while leaving the + // counter as it was. If a claim got there first, the block already + // exists and the failed exchange changes nothing. (An `or` of zero + // would not do: the compiler may turn it into a plain load, which + // maps an empty page without allocating a block for it.) let _ = mapped.claims().compare_exchange(0, 0, Ordering::Relaxed, Ordering::Relaxed); } @@ -308,8 +302,9 @@ mod tests { assert!(writer.try_write_frame(b"kept")); // No descriptor can describe a frame this long: the claim is - // refused and sets the gate, condemning later claims — their - // records would ride a result the receiver must already reject. + // refused and sets the gate, which shuts out later claims — their + // records would ride on a result the receiver must already + // reject. let oversized = ((u32::MAX as usize) + 1).try_into().unwrap(); assert!(matches!(writer.claim_frame(oversized), Err(ClaimError::Capacity))); assert!(writer.is_closed()); @@ -450,7 +445,8 @@ mod tests { assert!(writer.try_write_frame(b"x")); } assert!(writer.claim_frame(1.try_into().unwrap()).unwrap_err() == ClaimError::Capacity); - // The refusal was net-zero: the gate is set, the count is intact. + // The refusal put back what it added: the gate is set, the count + // is unchanged. assert!(shm.peek_u64(0) == (1 << 63) | 15); let frames = collect_frames(&shm); @@ -472,16 +468,17 @@ mod tests { assert!(iter.next() == None); assert!(frames.is_complete()); - // The seal set the gate: a straggler's claim fails cleanly and - // does not mark the channel incomplete — the operation is outside - // the sealed boundary. + // The seal set the gate: a late writer's claim fails cleanly and + // does not mark the channel incomplete — that record belongs + // after the receiver stopped collecting. assert!(writer.is_closed()); let before = shm.peek_u64(0); for _ in 0..100 { assert!(writer.claim_frame(5.try_into().unwrap()).unwrap_err() == ClaimError::Closed); } - // Stragglers leave no trace on the claim counter: refusals are - // net-zero, so the gate can never be carried into by counting. + // Late writers leave no trace on the claim counter: every refusal + // puts back what it added, so counting can never reach the gate + // bit. assert!(shm.peek_u64(0) == before); assert!(frames.is_complete()); } @@ -615,8 +612,8 @@ mod tests { (frames, results) }); - // Every admitted slot resolved to a whole frame or was frozen: - // the receiver observed only complete payloads. + // Every slot before the boundary ended up either a whole frame or + // frozen: the receiver saw only complete payloads. let mut count = 0; for frame in &frames { count += 1; diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs index e01116ac0..4c4d931f3 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs @@ -1,13 +1,14 @@ //! The reader side: seal the channel, then iterate the committed frames. //! //! Sealing never waits for writers, and no payload byte is read or -//! copied: the reader keeps the mapping alive and lends out each -//! committed span on demand. The borrows are sound because a committed -//! span is never written again (committing consumes the writer's frame) -//! and is disjoint from everything a live writer may still touch. This -//! rests on the attach contract that the region is accessed only through -//! this protocol; a process scribbling outside it is outside the trust -//! model. +//! copied: the reader keeps the mapping alive and hands out each +//! committed span on demand. Those borrows are safe because nothing +//! writes to a committed span again (committing uses up the writer's +//! frame) and it never overlaps what a live writer may still touch. +//! All of this assumes the promise made at attach — that the region is +//! touched only through this protocol. A process that scribbles on it +//! some other way breaks that promise, and this code does not defend +//! against it. use std::{ fmt, slice, @@ -16,31 +17,32 @@ use std::{ use super::{ AsRawSlice, - layout::{self, CLOSED, FROZEN, MappedLayout, SlotState}, + layout::{self, CLOSED, FROZEN, MappedLayout, SlotState, to_usize}, }; /// Why a channel could not be sealed into readable frames. #[derive(thiserror::Error, Clone, Copy, PartialEq, Eq, Debug)] pub enum ProtocolError { - /// The mapping cannot host the protocol at all (see + /// The mapping cannot hold the protocol at all (see /// [`MappedLayout::new`]). #[error("the shared-memory region cannot host the channel")] UnsupportedRegion, - /// A descriptor that no correct writer could have committed: the - /// region was corrupted, and its frames are unusable. + /// A descriptor no correct writer could have written: something + /// corrupted the region, and its frames cannot be used. #[error("corrupt shared-memory frame descriptor at slot {slot_index}")] CorruptDescriptor { slot_index: usize }, } /// A reader over the committed frames of a sealed channel, serving them /// straight out of the mapping, which stays alive inside this value and -/// is released when the reader drops. It holds no buffer: iteration -/// re-reads the frozen descriptor table, so closing allocates nothing. +/// is released when the reader drops. It holds no buffer of its own: +/// iterating re-reads the frozen descriptor table, so sealing a channel +/// allocates nothing. pub struct ShmReader { - /// The layout mapped onto the owned region. + /// Where the parts of the owned region are. mapped: MappedLayout, - /// Owns the region the views point into. Declared after them: fields - /// drop in order, and what borrows must die before what is borrowed. + /// Owns the region the pointers point into. Declared after them: + /// fields drop in order, and the borrower must go first. _mem: M, /// Length of the frozen prefix of the descriptor table. slot_count: usize, @@ -49,9 +51,10 @@ pub struct ShmReader { complete: bool, } -// SAFETY: the reader reads only the header atomics, frozen slots, and -// immutable committed spans; the stored views point into the mapping's -// stable, independently owned target, not into the reader value itself. +// SAFETY: the reader reads only the counters, frozen slots, and committed +// spans that nothing writes to any more; the stored pointers point into +// the mapped memory, which is owned separately and does not move, not +// into the reader itself. unsafe impl Send for ShmReader {} // SAFETY: see the `Send` impl. unsafe impl Sync for ShmReader {} @@ -60,10 +63,11 @@ impl ShmReader { /// Seals the channel — no further records — and returns the reader /// of its committed frames. /// - /// Never blocks on writers: writers admitted before the snapshot race - /// per slot, ending committed (included) or frozen (excluded); claims - /// after the snapshot land in slots this pass never visits, until the - /// CLOSED gate — set before this returns — stops them. + /// Never waits for writers. A writer that got in before the snapshot + /// races this pass for its own slot and ends up either committed + /// (kept) or frozen (skipped); a claim taken after the snapshot lands + /// in a slot this pass never looks at, until the CLOSED gate — set + /// before this returns — stops the claims for good. /// /// # Safety /// @@ -76,13 +80,14 @@ impl ShmReader { /// /// # Errors /// - /// [`ProtocolError`]: the mapping cannot host the protocol, or its - /// metadata could not have been produced by a correct writer — the - /// region was corrupted and its frames are unusable. + /// [`ProtocolError`]: the mapping cannot hold the protocol, or it + /// contains something no correct writer could have written — the + /// region was corrupted and its frames cannot be used. pub unsafe fn seal(mem: M) -> Result { // SAFETY: forwarded from this function's contract, which keeps the - // region valid for the reader's lifetime — and so for every use of - // the views, which are stored in and dropped with the reader. + // region valid for as long as the reader lives — and so for every + // use of the pointers, which are stored in the reader and dropped + // with it. let Some(mapped) = (unsafe { MappedLayout::new(mem.as_raw_slice()) }) else { return Err(ProtocolError::UnsupportedRegion); }; @@ -91,29 +96,30 @@ impl ShmReader { let complete; { // The seal boundary (rule 1): claims at or before this - // snapshot are in; later ones land in slots this pass never - // visits. Clamped, so an inflated counter degrades to a - // full-table sweep, not an error. + // snapshot are in, later ones land in slots this pass never + // looks at. Clamped, so a counter reading higher than the + // table just means sweeping the whole table, not an error. let claims = mapped.claims().load(Ordering::Relaxed); - slot_count = usize::try_from(claims & !CLOSED).unwrap_or(usize::MAX).min(SLOTS); - // The same load carries the verdict: a gate set before the - // boundary is a loss report — or an earlier seal, and a - // re-seal cannot vouch for records refused since then. + slot_count = to_usize(claims & !CLOSED).min(SLOTS); + // The same load answers the other question: a gate already + // set before the boundary means a record was lost — or that + // someone sealed earlier, and a second seal cannot promise + // anything about records refused in between. complete = claims & CLOSED == 0; - // Gate further claims, so stragglers stop claiming and - // materializing pages. Claims racing in between are dropped - // soundly (rule 1). + // Shut the gate, so late writers stop claiming slots and + // touching new pages. A claim that slips in between is + // dropped safely (rule 1). mapped.claims().fetch_or(CLOSED, Ordering::Relaxed); - // Freeze pass: drive every admitted slot terminal and - // validate committed descriptors. Afterwards this prefix of - // the table can never change — late commits lose to `FROZEN` - // — so iteration re-reads it: nothing copied, nothing - // allocated. + // Freeze pass: give every slot up to the boundary its final + // value, and check the committed ones. Afterwards this part + // of the table can never change — a late commit loses to + // `FROZEN` — so iterating just re-reads it, copying and + // allocating nothing. for slot_index in 0..slot_count { - // Rule 3: `Acquire` on failure makes a committed payload - // visible. + // Rule 3: `Acquire` on failure means that if this slot + // is committed, its payload bytes are all visible. let Err(bits) = mapped.table()[slot_index].compare_exchange( layout::UNFINISHED, FROZEN, @@ -125,13 +131,14 @@ impl ShmReader { continue; }; match SlotState::decode(bits) { - // Frozen by an earlier seal, or a scribble that - // published nothing: no frame either way. + // Frozen by an earlier seal, or scribbled on without + // publishing anything: no frame either way. SlotState::Unfinished => {} SlotState::Committed { offset, len } => { - // A span no correct writer could have committed + // A span no correct writer could have written // fails the whole channel. - if offset as usize + len.get() as usize > mapped.payloads.len() { + let end = offset.checked_add(len.get()); + if end.is_none_or(|end| end > mapped.payload_len) { return Err(ProtocolError::CorruptDescriptor { slot_index }); } frames += 1; @@ -150,10 +157,10 @@ impl ShmReader { /// Whether every record a writer published made it in. /// - /// False when a claim failed before the seal — out of space, or an - /// oversized frame: the record was lost, and the frames under-report - /// what writers went on to do. Consumers that need completeness must - /// reject them. + /// False when a claim failed before the seal — no room left, or an + /// oversized frame. That record is gone, so the frames say less than + /// the writers actually did. Anyone who needs the full picture must + /// throw them away. #[must_use] pub const fn is_complete(&self) -> bool { self.complete @@ -171,9 +178,9 @@ impl fmt::Debug for ShmReader { /// Iterator over a [`ShmReader`]'s committed frames, in claim order. pub struct Iter<'a, const SLOTS: usize> { - /// The layout the spans decode against and point into. + /// Where the payload area is, for turning descriptors into spans. mapped: MappedLayout, - /// The not-yet-visited part of the table's frozen prefix. + /// The frozen slots this iterator has not reached yet. table: &'a [AtomicU64], /// Committed frames not yet yielded. remaining: usize, @@ -185,28 +192,28 @@ impl<'a, const SLOTS: usize> Iterator for Iter<'a, SLOTS> { fn next(&mut self) -> Option { while let Some((slot, rest)) = self.table.split_first() { self.table = rest; - // The slot is terminal, so this plain load reads what the - // freeze pass saw; whatever carried the reader to this thread - // carried the freeze pass's `Acquire` visibility too (rule 3). + // The slot can never change again, so this plain load reads + // what the freeze pass saw; whatever brought the reader to + // this thread brought that pass's `Acquire` along (rule 3). let bits = slot.load(Ordering::Relaxed); - // Unfinished published nothing. Out-of-bounds cannot appear - // — `seal` failed the channel on it — but checking keeps the - // `unsafe` below locally justified. + // An unfinished slot published nothing. A span outside the + // region cannot appear here — `seal` fails the channel on + // one — but checking lets the `unsafe` below stand on its + // own. let SlotState::Committed { offset, len } = SlotState::decode(bits) else { continue; }; - let (offset, len) = (offset as usize, len.get() as usize); - if offset + len > self.mapped.payloads.len() { + if offset.checked_add(len.get()).is_none_or(|end| end > self.mapped.payload_len) { continue; } self.remaining -= 1; - // SAFETY: the span is in bounds (checked above) and - // immutable for the mapping's lifetime; the reader borrowed - // for `'a` keeps the mapping alive and mapped. + // SAFETY: the span is inside the region (checked above) and + // nothing writes to it any more; the reader borrowed for `'a` + // keeps the mapping alive. return Some(unsafe { slice::from_raw_parts( - self.mapped.payloads.cast::().cast_const().add(offset), - len, + self.mapped.payloads.add(offset as usize).as_ptr().cast_const(), + len.get() as usize, ) }); } diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs index d4c8115df..377e4da96 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs @@ -10,25 +10,25 @@ use std::{ use super::{ AsRawSlice, - layout::{self, CLOSED, MappedLayout, SlotState}, + layout::{self, CLOSED, MappedLayout, SlotState, to_usize}, }; /// A concurrent shared-memory frame writer. /// -/// Safe to use across threads and processes at once: frames are reserved -/// atomically, filled in uniquely owned payload spans, and published with -/// an atomic commit (the ordering contract in [`layout`]). +/// Safe to use from many threads and processes at once: each frame is +/// reserved atomically, filled in a span no one else can touch, and +/// published with one atomic write (the ordering contract in [`layout`]). pub struct ShmWriter { mapped: MappedLayout, - /// Owns the region the views point into. Declared after them: fields - /// drop in order, and what borrows must die before what is borrowed. + /// Owns the region the pointers point into. Declared after them: + /// fields drop in order, and the borrower must go first. _mem: M, } // SAFETY: the writer touches the region only through the protocol's -// atomics, which synchronize access from any thread; the stored views -// point into the mapping's stable, independently owned target, not into -// the writer value itself. +// atomics, which synchronize access from any thread; the stored pointers +// point into the mapped memory, which is owned separately and does not +// move, not into the writer itself. unsafe impl Send for ShmWriter {} // SAFETY: see the `Send` impl; the writer's shared-reference API is // internally synchronized by the protocol. @@ -38,23 +38,23 @@ unsafe impl Sync for ShmWriter {} #[derive(thiserror::Error, Clone, Copy, PartialEq, Eq, Debug)] pub enum ClaimError { /// The CLOSED gate was set: the receiver sealed the channel, or an - /// earlier failed claim condemned it. Skipping the record is sound - /// either way — it is outside the receiver's boundary, or the same - /// bit already makes the receiver report the channel incomplete. + /// earlier claim failed and shut it down. Dropping the record is + /// right either way — the receiver had already stopped collecting, + /// or that same bit already tells it the frames are incomplete. #[error("the channel has been closed")] Closed, - /// The claim was refused for space: the region was full, or the frame - /// was larger than the `u32::MAX`-byte frame limit. The loss is - /// already recorded — this claim set the CLOSED gate — so the channel - /// will report itself incomplete and refuse further claims. + /// There was no room: the region is full, or the frame is longer than + /// the `u32::MAX`-byte limit. The loss is already recorded — this + /// claim set the CLOSED gate — so the channel now reports itself + /// incomplete and refuses every later claim. #[error("no space left in the shared-memory region")] Capacity, } impl ShmWriter { - /// Creates a writer backed by a shared-memory region, or `None` when - /// the region cannot host the protocol (see [`MappedLayout::new`]) — - /// a truncated or foreign file, for a sender that did not create it. + /// Creates a writer on a shared-memory region, or `None` when the + /// region cannot hold the protocol (see [`MappedLayout::new`]) — a + /// truncated or unrelated file, for a sender that did not create it. /// /// # Safety /// @@ -64,15 +64,15 @@ impl ShmWriter { /// accessed only through this protocol since. pub unsafe fn new(mem: M) -> Option { // SAFETY: forwarded from this function's contract, which keeps the - // region valid and protocol-governed for the writer's lifetime — - // and so for every use of the views, which are stored in and - // dropped with the writer. + // region valid, and used only by this protocol, for as long as the + // writer lives — and so for every use of the pointers, which are + // stored in the writer and dropped with it. let mapped = unsafe { MappedLayout::new(mem.as_raw_slice()) }?; Some(Self { mapped, _mem: mem }) } /// Whether the CLOSED gate is set: the receiver sealed the channel, - /// or an earlier failed claim condemned it. + /// or an earlier claim failed and shut it down. pub fn is_closed(&self) -> bool { self.mapped.claims().load(Ordering::Relaxed) & CLOSED != 0 } @@ -80,78 +80,84 @@ impl ShmWriter { /// Claims a frame of exactly `frame_size` bytes. Wait-free: two /// `fetch_add`s, no retry loop (rule 1). /// - /// The frame is invisible to the receiver until [`FrameMut::finish`] - /// commits it; dropping it instead abandons the claim, and the + /// The receiver cannot see the frame until [`FrameMut::finish`] + /// commits it; dropping it instead gives the claim up, and the /// receiver ignores the slot exactly as if the writer had died. A - /// claim that does not fit — the region is full, or `frame_size` - /// exceeds `u32::MAX` — fails as [`ClaimError::Capacity`] after - /// setting the CLOSED gate. + /// claim that does not fit — the region is full, or `frame_size` is + /// over `u32::MAX` — fails as [`ClaimError::Capacity`] after setting + /// the CLOSED gate. pub fn claim_frame(&self, frame_size: NonZeroUsize) -> Result, ClaimError> { let mapped = self.mapped; - let payload_len = frame_size.get(); - // The loss report (rule 1): the gate marks the result incomplete - // and condemns further claims. + // The loss report (rule 1): the gate marks the frames incomplete + // and shuts the channel down for later claims. let report_loss = || { mapped.claims().fetch_or(CLOSED, Ordering::Relaxed); ClaimError::Capacity }; - // The descriptor's nonzero 32-bit length field is the oversize - // check; no counter was touched, so nothing to undo. - let Ok(encoded_len) = NonZeroU32::try_from(frame_size) else { + // A frame too long for the descriptor's 32-bit length field cannot + // be described, so this conversion is the oversize check. No + // counter has moved yet, so there is nothing to undo. + let Ok(frame_size) = NonZeroU32::try_from(frame_size) else { return Err(report_loss()); }; + // The payload space this claim takes, as the counter's `u64`. + // Widening a 32-bit size never loses anything. + let reservation = u64::from(frame_size.get()); // Payload bytes first, so a payload-capacity failure does not burn a // slot. - let payload_start = - mapped.payload_reserved().fetch_add(payload_len as u64, Ordering::Relaxed); - // Checked: a foreign scribble of the counter must fail the claim, - // not wrap the bound into an out-of-bounds reservation. - let payload_end = payload_start.checked_add(payload_len as u64); - if payload_end.is_none_or(|end| end > mapped.payloads.len() as u64) { + let payload_offset = mapped.payload_reserved().fetch_add(reservation, Ordering::Relaxed); + // Checked: if something else scribbled on the counter, the claim + // must fail rather than wrap around into a span outside the + // region. + let payload_end = payload_offset.checked_add(reservation); + if payload_end.is_none_or(|end| end > u64::from(mapped.payload_len)) { let err = report_loss(); - // Net-zero (rule 1), gate first: undo the reservation. It lay - // beyond the region, so no live span sits above it. - mapped.payload_reserved().fetch_sub(payload_len as u64, Ordering::Relaxed); + // Put it back (rule 1), gate first. The reservation ran off + // the end of the region, so no live frame sits above it. + mapped.payload_reserved().fetch_sub(reservation, Ordering::Relaxed); return Err(err); } - // Bounded by the capacity check: the payload region fits 32-bit - // offsets. - let payload_offset = u32::try_from(payload_start).expect("bounded by the payload region"); - let payload_start = payload_offset as usize; + // In range by the check above, and the payload area is short + // enough for 32-bit offsets. + let payload_offset = u32::try_from(payload_offset).expect("bounded by the payload region"); let claims = mapped.claims().fetch_add(1, Ordering::Relaxed); if claims & CLOSED != 0 { - // Not a loss: a record refused after the seal is outside the - // channel's boundary. Net-zero (rule 1): the gate was - // observed, undo the increment. (The payload reservation - // stays: in bounds, never materialized, capped by the - // region.) + // Not a loss: the receiver had already stopped collecting + // when this record was refused. Put the count back (rule 1) + // — the gate was already set. (The payload reservation + // stays: it is inside the region, nothing was written + // there, and the region caps how much can pile up.) mapped.claims().fetch_sub(1, Ordering::Relaxed); return Err(ClaimError::Closed); } - let slot_index = usize::try_from(claims).expect("claim count exceeds usize"); + let slot_index = to_usize(claims); if slot_index >= SLOTS { let err = report_loss(); - // Net-zero (rule 1), gate first: with the bit in the word, no - // later read mistakes the undone count for an open channel. + // Put the count back (rule 1), gate first: with the bit + // already set, no later reader can mistake the lowered count + // for an open channel. mapped.claims().fetch_sub(1, Ordering::Relaxed); return Err(err); } - // SAFETY: the claim reserved this span — in bounds by the - // capacity check — exclusively: other writers reserve disjoint - // spans, and the receiver reads no payload before observing its - // committed descriptor, which `finish` publishes only when it - // consumes this borrow. + // SAFETY: the claim reserved this span for itself, and the check + // above put it inside the region. Other writers reserve spans + // that never overlap, and the receiver reads no payload until it + // sees the committed descriptor, which `finish` writes only by + // taking this borrow. let content = unsafe { - slice::from_raw_parts_mut(mapped.payloads.cast::().add(payload_start), payload_len) + slice::from_raw_parts_mut( + mapped.payloads.add(payload_offset as usize).as_ptr(), + frame_size.get() as usize, + ) }; Ok(FrameMut { mapped, slot_index, - descriptor: SlotState::Committed { offset: payload_offset, len: encoded_len }.encode(), + descriptor: SlotState::Committed { offset: payload_offset, len: frame_size }.encode(), content, }) } @@ -172,12 +178,12 @@ impl ShmWriter { /// An exclusively owned, claimed-but-unpublished frame. /// -/// [`FrameMut::finish`] commits the frame; it is the only way to make the -/// payload visible to the receiver. Dropping the frame instead abandons -/// the claim: the slot stays unfinished and the receiver ignores it, -/// exactly as if the writer had died there. A writer that abandons a frame -/// and still performs the operation it described steps outside the usage -/// contract — records are published before the recorded operation. +/// [`FrameMut::finish`] commits the frame; it is the only way to show the +/// payload to the receiver. Dropping the frame gives the claim up: the +/// slot stays unfinished and the receiver ignores it, exactly as if the +/// writer had died there. A writer that drops a frame and still performs +/// the operation it described breaks the rule this channel is built on — +/// write the record first, then do the thing it records. pub struct FrameMut<'a, const SLOTS: usize> { mapped: MappedLayout, slot_index: usize, @@ -211,10 +217,9 @@ impl DerefMut for FrameMut<'_, SLOTS> { impl FrameMut<'_, SLOTS> { /// Commits the frame, making it visible to the receiver. /// - /// If the receiver sealed the channel and froze this frame's slot - /// first, the swap fails and the frame is silently discarded: the - /// record belongs to the seal race and is intentionally excluded - /// either way. + /// If the receiver sealed the channel and froze this slot first, the + /// swap fails and the frame is dropped without a sound: this record + /// raced the seal, and is meant to be left out either way. pub fn finish(self) { // Rule 2: `Release` orders every payload write before the // descriptor. From 199dc4494cbeb6f86c88852d654ddb749fc0d45d Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sun, 16 Aug 2026 11:26:46 +0800 Subject: [PATCH 53/92] refactor(fspy-shm): one bounds check, one cast Make the narrowing part of the capacity test rather than an assertion after it: an offset past what a descriptor can hold and a span running off the end are the same event, so they share one refusal path and the `expect` on `u32::try_from` is gone. No panic path is left in the protocol. The test itself moves to `MappedLayout::holds_span`, which the writer and the seal both call, so `payload_len` goes back to private. Drop the reader's per-frame re-check. `seal` validates every descriptor in the frozen prefix and the slots can never change after it, so the `unsafe` in `Iter` names that invariant instead of re-deriving it for every frame. Widen `to_usize` to `impl Into` so the 32-bit descriptor fields go through it too: the bound admits exactly the integers that reach `u64` losslessly, and the assert inside covers the rest. The only `as` left in the module is the one that function guards. Co-Authored-By: Claude Fable 5 --- .../src/ipc/channel/shm_io/layout.rs | 41 ++++++++++++------- .../fspy_shared/src/ipc/channel/shm_io/mod.rs | 6 ++- .../src/ipc/channel/shm_io/reader.rs | 24 +++++------ .../src/ipc/channel/shm_io/writer.rs | 29 ++++++------- 4 files changed, 55 insertions(+), 45 deletions(-) diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs index 266cd5417..d600a46f6 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs @@ -117,13 +117,13 @@ pub(super) const FROZEN: u64 = 1; // never visits. Claims publish no payload data, so `Relaxed` // suffices. The gate is not the boundary — it only stops late // writers; a claim that gets in between the snapshot and the gate -// lands past the snapshot, where the receiver never looks. Completeness rides the same -// modification order: a failed claim sets the gate before performing -// the operation whose record was lost, so either the snapshot sees -// the bit or the loss happened after the boundary; a writer that -// skipped on seeing the -// bit is covered the same way; one that died before setting it never -// performed its operation, so nothing was lost. +// lands past the snapshot, where the receiver never looks. +// Completeness rides the same modification order: a failed claim sets +// the gate before performing the operation whose record was lost, so +// either the snapshot sees the bit or the loss happened after the +// boundary; a writer that skipped on seeing the bit is covered the +// same way; one that died before setting it never performed its +// operation, so nothing was lost. // 2. **Writer commit** — `FrameMut::finish`'s compare-and-swap uses // `Release`: every payload write happens-before the descriptor is // visible. @@ -131,16 +131,17 @@ pub(super) const FROZEN: u64 = 1; // `ShmReader::seal` uses `Acquire` on failure: an observed descriptor // implies fully visible payload bytes. -/// Converts a counter value to a slot index. +/// Converts an integer into a `usize`. /// -/// Never loses bits: the assert lets only targets whose `usize` is 64 -/// bits — the width of the counters — build this module. That assert is -/// the only reason the cast below is safe, so it sits inside the -/// function rather than at module scope. +/// Never loses bits: the argument has to fit a `u64`, which is what the +/// bound says, and the assert lets only targets whose `usize` is 64 bits +/// build this module. That assert is the only reason the cast is safe, +/// so it sits here rather than at module scope — and this is the only +/// `as` in the protocol. #[expect(clippy::cast_possible_truncation, reason = "the assert allows only equal widths")] -pub(super) const fn to_usize(value: u64) -> usize { +pub(super) fn to_usize(value: impl Into) -> usize { const { assert!(size_of::() == size_of::(), "requires a 64-bit target") }; - value as usize + value.into() as usize } /// Casts to a pointer of another type, returning `None` when the pointer @@ -165,7 +166,7 @@ pub(super) struct MappedLayout { pub(super) payloads: NonNull, /// Length of the payload area. A `u32`, the width of a descriptor's /// offset and length, so bounds checks need no conversion. - pub(super) payload_len: u32, + payload_len: u32, } /// A decoded descriptor slot (the codec above). @@ -262,6 +263,16 @@ impl MappedLayout { pub(super) const fn table(&self) -> &[AtomicU64] { &self.meta().table } + + /// Whether `len` bytes at `offset` lie inside the payload area. Both + /// sides ask this: the writer about the span it just reserved, the + /// reader about every descriptor it decodes. + pub(super) const fn holds_span(&self, offset: u32, len: u32) -> bool { + match offset.checked_add(len) { + Some(end) => end <= self.payload_len, + None => false, + } + } } #[cfg(test)] diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs index cfc0aa5af..748fbeee0 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs @@ -305,7 +305,7 @@ mod tests { // refused and sets the gate, which shuts out later claims — their // records would ride on a result the receiver must already // reject. - let oversized = ((u32::MAX as usize) + 1).try_into().unwrap(); + let oversized = (layout::to_usize(u32::MAX) + 1).try_into().unwrap(); assert!(matches!(writer.claim_frame(oversized), Err(ClaimError::Capacity))); assert!(writer.is_closed()); assert!(!writer.try_write_frame(b"refused")); @@ -698,7 +698,9 @@ mod tests { } let misaligned_shm = Misaligned(MockedShm::alloc(1024)); - assert!(misaligned_shm.as_raw_slice().cast::() as usize % align_of::() != 0); + assert!( + !misaligned_shm.as_raw_slice().cast::().addr().is_multiple_of(align_of::()) + ); // SAFETY: the wrapped allocation is valid; only its alignment is // deliberately wrong. diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs index 4c4d931f3..3ae0498ce 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs @@ -137,8 +137,7 @@ impl ShmReader { SlotState::Committed { offset, len } => { // A span no correct writer could have written // fails the whole channel. - let end = offset.checked_add(len.get()); - if end.is_none_or(|end| end > mapped.payload_len) { + if !mapped.holds_span(offset, len.get()) { return Err(ProtocolError::CorruptDescriptor { slot_index }); } frames += 1; @@ -196,24 +195,21 @@ impl<'a, const SLOTS: usize> Iterator for Iter<'a, SLOTS> { // what the freeze pass saw; whatever brought the reader to // this thread brought that pass's `Acquire` along (rule 3). let bits = slot.load(Ordering::Relaxed); - // An unfinished slot published nothing. A span outside the - // region cannot appear here — `seal` fails the channel on - // one — but checking lets the `unsafe` below stand on its - // own. + // An unfinished slot published nothing. let SlotState::Committed { offset, len } = SlotState::decode(bits) else { continue; }; - if offset.checked_add(len.get()).is_none_or(|end| end > self.mapped.payload_len) { - continue; - } self.remaining -= 1; - // SAFETY: the span is inside the region (checked above) and - // nothing writes to it any more; the reader borrowed for `'a` - // keeps the mapping alive. + // SAFETY: `seal` checked this descriptor against the payload + // area and would have failed the channel had it not fit, and + // the slot has been frozen ever since — so the load above + // returns the value it checked. Nothing writes to a committed + // span any more, and the reader borrowed for `'a` keeps the + // mapping alive. return Some(unsafe { slice::from_raw_parts( - self.mapped.payloads.add(offset as usize).as_ptr().cast_const(), - len.get() as usize, + self.mapped.payloads.add(to_usize(offset)).as_ptr().cast_const(), + to_usize(len.get()), ) }); } diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs index 377e4da96..17e86a062 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs @@ -107,21 +107,22 @@ impl ShmWriter { let reservation = u64::from(frame_size.get()); // Payload bytes first, so a payload-capacity failure does not burn a // slot. - let payload_offset = mapped.payload_reserved().fetch_add(reservation, Ordering::Relaxed); - // Checked: if something else scribbled on the counter, the claim - // must fail rather than wrap around into a span outside the - // region. - let payload_end = payload_offset.checked_add(reservation); - if payload_end.is_none_or(|end| end > u64::from(mapped.payload_len)) { + let payload_start = mapped.payload_reserved().fetch_add(reservation, Ordering::Relaxed); + // The claim needs an offset a descriptor can hold and a frame that + // ends inside the payload area. Both are checked, so if something + // else scribbled on the counter the claim fails rather than + // wrapping around into a span outside the region. + let Some(payload_offset) = u32::try_from(payload_start) + .ok() + .filter(|&offset| mapped.holds_span(offset, frame_size.get())) + else { let err = report_loss(); - // Put it back (rule 1), gate first. The reservation ran off - // the end of the region, so no live frame sits above it. + // Put it back (rule 1), gate first. Whichever check failed, + // the reservation ran past the end of the region, so no live + // frame sits above it. mapped.payload_reserved().fetch_sub(reservation, Ordering::Relaxed); return Err(err); - } - // In range by the check above, and the payload area is short - // enough for 32-bit offsets. - let payload_offset = u32::try_from(payload_offset).expect("bounded by the payload region"); + }; let claims = mapped.claims().fetch_add(1, Ordering::Relaxed); if claims & CLOSED != 0 { @@ -150,8 +151,8 @@ impl ShmWriter { // taking this borrow. let content = unsafe { slice::from_raw_parts_mut( - mapped.payloads.add(payload_offset as usize).as_ptr(), - frame_size.get() as usize, + mapped.payloads.add(to_usize(payload_offset)).as_ptr(), + to_usize(frame_size.get()), ) }; Ok(FrameMut { From 9578fadf8155678a56aebe9c47ee0614e984908e Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sun, 16 Aug 2026 21:35:04 +0800 Subject: [PATCH 54/92] refactor(fspy-shm): seal without walking the table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sealing used to sweep every claimed slot, freezing each with a compare-and-swap so the table could never change again. That pass is gone: a seal is now a snapshot load and a gate bit, costing the same whether the channel holds one frame or ten million. Three things follow. `FrameMut::finish` becomes a plain `Release` store — the writer that claimed a slot is the only one that ever writes it, so no read-modify-write is needed on the write path. The reader loads each descriptor with `Acquire` rather than leaning on the freeze pass's ordering. And the FROZEN slot value disappears, leaving two states: zero, or committed. The reader is no longer a fixed snapshot. It reads the table when asked, so a writer still filling a frame when the line was drawn may show up in a later read and not an earlier one. Both answers are truthful — that record's operation had not happened when the receiver stopped collecting. Sealing a channel whose gate is already set now fails instead of handing back frames labelled incomplete. A lost record and an earlier seal both mean the frames are not all of them, and fspy already threw such traces away, so that check moves down into `seal` and `is_complete` goes away. Refused claims no longer put their increments back, so both counters only climb. That costs nothing: neither counter says where data is, and once the gate is set no claim reaches the point of taking a span. Counting alone would need 2^63 claims to reach the gate bit, and would only make the channel refuse records and fail the seal. Co-Authored-By: Claude Fable 5 --- crates/fspy/src/ipc.rs | 16 +- crates/fspy_shared/src/ipc/channel/mod.rs | 10 +- .../src/ipc/channel/shm_io/README.md | 87 ++++---- .../src/ipc/channel/shm_io/layout.rs | 84 +++----- .../fspy_shared/src/ipc/channel/shm_io/mod.rs | 192 ++++++----------- .../src/ipc/channel/shm_io/reader.rs | 204 +++++++----------- .../src/ipc/channel/shm_io/writer.rs | 99 ++++----- 7 files changed, 271 insertions(+), 421 deletions(-) diff --git a/crates/fspy/src/ipc.rs b/crates/fspy/src/ipc.rs index 4220483ad..b617374f9 100644 --- a/crates/fspy/src/ipc.rs +++ b/crates/fspy/src/ipc.rs @@ -26,19 +26,11 @@ impl TryFrom for ChannelAccesses { /// [`fspy_shared::ipc::channel::Receiver::close`]) — and its work is /// bounded by the number of reported records, so it runs inline. /// - /// Fails when a record was lost before close or the shared-memory - /// metadata was corrupted. Failing here — instead of returning a - /// silently short trace — keeps the tracking result trustworthy for - /// caching. + /// Fails when a record was lost before close, which is what keeps the + /// tracking result trustworthy for caching: closing hands back frames + /// only when they are all of them. fn try_from(receiver: Receiver) -> io::Result { - let frames = receiver.close()?; - if !frames.is_complete() { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "file-access trace is incomplete: a tracked process lost a record", - )); - } - Ok(Self { frames }) + Ok(Self { frames: receiver.close()? }) } } diff --git a/crates/fspy_shared/src/ipc/channel/mod.rs b/crates/fspy_shared/src/ipc/channel/mod.rs index 0fbe4ec95..2c38a65ce 100644 --- a/crates/fspy_shared/src/ipc/channel/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/mod.rs @@ -294,8 +294,10 @@ impl Receiver { /// /// # Errors /// - /// Fails only when the shared-memory metadata was corrupted (a protocol - /// impossibility for correct senders); the trace is then unusable. + /// Fails when a record was lost before the close — a sender ran out of + /// room, or tried to send something too large — and when the region + /// cannot hold the protocol at all. Either way there is no complete + /// set of records, so none are handed back. pub fn close(self) -> io::Result { let Self { _keeper: keeper, mapping } = self; // Remove the backing file first so no new process attaches while the @@ -348,7 +350,6 @@ mod tests { let frames = receiver.close().unwrap(); assert!(frames.iter().next().unwrap() == &[4, 2]); - assert!(frames.is_complete()); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -370,7 +371,6 @@ mod tests { assert!(received_frame == &[4, 2]); assert!(iter.next().is_none()); - assert!(frames.is_complete()); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -412,7 +412,6 @@ mod tests { let frames = receiver.close().unwrap(); assert!(frames.iter().next().unwrap() == &[4, 2]); - assert!(frames.is_complete()); assert!( sender.writer.claim_frame(NonZeroUsize::new(2).unwrap()).unwrap_err() @@ -447,6 +446,5 @@ mod tests { frames.iter().map(|frame| from_utf8(frame).unwrap().parse::().unwrap()).collect(); received_values.sort_unstable(); assert!(received_values == (0u16..200).collect::>()); - assert!(frames.is_complete()); } } diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/README.md b/crates/fspy_shared/src/ipc/channel/shm_io/README.md index 26436bd8c..0eb1a57a6 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/README.md +++ b/crates/fspy_shared/src/ipc/channel/shm_io/README.md @@ -10,8 +10,8 @@ Three requirements shaped everything here: 2. **A writer may outlive the channel.** The receiver must never wait for writers; sealing is immediate. 3. **The receiver must know whether it got everything.** Either the frames - hold every record writers published, or they are flagged incomplete. - Never a silently short result. + hold every record writers published, or sealing fails and hands back + nothing. Never a silently short result. Simpler designs fail these. A lock that writers hold while active proves "no writers left" only as long as every writer manages the lock correctly — @@ -63,8 +63,9 @@ Three steps: fact and sets the CLOSED gate before the writer moves on. 2. **Fill.** The writer serializes into its payload span. The span is exclusively its own; nobody else knows it exists yet. -3. **Commit.** One compare-and-swap flips the frame's slot from zero to a - descriptor holding the payload's offset and length. Before this swap the +3. **Commit.** One store puts a descriptor holding the payload's offset + and length into the frame's slot. Only the writer that claimed the slot + ever writes it, so no compare-and-swap is needed. Before the store the receiver cannot see the frame at all; after it, the frame is visible and its payload never changes again. @@ -96,9 +97,8 @@ stop or crash the program doing the work. The loss is not silent. Before moving on, the failed claim sets the CLOSED gate — the same bit the receiver sets when it seals. When the -receiver seals the channel it reads the bit once; if it was already -set, `is_complete` returns false, and a reader that needs the full -picture knows to throw the result away. +receiver seals the channel it reads the bit once; if it was already set, +sealing fails and no frames are handed out at all. Setting the bit before moving on matters for the same reason committing a record before acting does. If the receiver's read misses the bit, the @@ -108,9 +108,9 @@ include. And a writer that dies before setting the bit never performed its action, so nothing was actually lost. Because the bit is also the gate, the first lost record closes the -channel: every later claim is refused. That refusal costs nothing — -`is_complete` is already false, so the result must be thrown away, and -any further records would ride a result nobody can use. +channel: every later claim is refused. That refusal costs nothing — the +result is already doomed, so any further records would only be added to +something nobody can use. One more limit: a single frame holds at most `u32::MAX` bytes, because a descriptor cannot describe more. Such a claim is refused — and reported — @@ -121,28 +121,27 @@ the same way. The receiver seals the channel once: 1. **Snapshot** the claim counter with a plain load. This is the boundary: - claims at or before it are in, later ones are not. + claims at or before it are in, later ones are not. If the CLOSED bit is + already set, a record was lost or someone sealed earlier, and the seal + fails right here — a partial set of frames is never handed out. 2. **Gate** further claims by setting the CLOSED bit, so stragglers stop. -3. **Freeze** every slot in the snapshot: a compare-and-swap flips zero to - FROZEN. If the slot was already committed, the swap fails and the frame - is kept. Exactly one side wins each slot, and either way the slot - never changes again. -4. **Validate** each committed descriptor's bounds. A descriptor no correct - writer could produce fails the whole channel — never a panic, never an - out-of-bounds read. + +That is the whole of it: two loads and one bit. No slot is touched, so +sealing costs the same whether the channel holds one frame or ten million. The result, `ShmReader`, owns the mapping and lends out one `&[u8]` per -committed span, straight from shared memory — no copy. The borrows are +committed span, straight from shared memory — no copy. It reads the table +when asked, so a writer that was still filling a frame when the line was +drawn may show up in a later read and not an earlier one. The borrows are sound because a committed span is never written again and is disjoint from everything a live straggler may still touch. The mapping is released when the reader is dropped. ```text - writer's commit CAS wins - +------------------------------> COMMITTED (readable) -CLAIMED (slot 0) ---+ - +------------------------------> FROZEN (ignored) - receiver's freeze CAS wins + writer finishes the frame +CLAIMED (slot 0) -------------------------------> COMMITTED (readable) + | + +-- writer dies or gives the claim up ---> stays zero (ignored) ``` ## Why this is sound, in one list @@ -150,29 +149,29 @@ CLAIMED (slot 0) ---+ - Finding frames never involves reading payload bytes; every slot has a fixed place. A half-written payload can never be mistaken for metadata. - A payload is reachable only through its committed descriptor. The commit - is a `Release` write and the receiver's failed freeze is an `Acquire` - read, so an observed descriptor implies fully visible payload bytes. -- Once a slot is committed or frozen, nothing ever changes it again. -- The counters track the true counts: a refused claim sets the CLOSED - gate — marking the result incomplete and refusing every later claim — - and then subtracts its own increments back, so stragglers can hammer a - sealed channel forever without moving the counter toward the gate bit. - The receiver still clamps its snapshot to the fixed capacities, so even - a scribbled counter degrades into extra frozen slots, not corruption. -- The bounds checks on descriptors are what make the `unsafe` reference - construction correct: whether the receiver stays memory-safe never - depends on another process behaving. -- Whether the bytes are _right_ does trust the other processes to follow - the protocol — one that scribbles random memory is outside the model. - That trust is why the receiver can read frames straight out of shared - memory, with no copies and no checksums. + is a `Release` write and the receiver's load is an `Acquire` read, so a + descriptor the receiver sees brings its payload bytes with it. +- One writer owns each slot and writes it once, so a slot goes from zero to + committed and never changes again. +- No counter has to be exact: a refused claim sets the CLOSED gate — + failing the seal and refusing every later claim — + and leaves its increments where they are. Both counters only ever climb, + which costs nothing: neither one says where data is. The receiver still + clamps its snapshot to the fixed capacities, so even a scribbled counter + just means walking extra empty slots. +- A committed descriptor names the span its writer reserved and checked, + so the receiver builds its borrows from it without re-checking. That + trusts the other processes to follow the protocol — one that scribbles + random memory is outside the model — and it is why the receiver can read + frames straight out of shared memory, with no copies and no checksums. ## Performance notes -- Claiming is two atomic adds; committing is one CAS. Nothing retries. -- Sealing costs one pass over the claimed slots. Nothing is copied and - nothing is allocated — the whole module is allocation-free; the reader - re-reads the frozen table to iterate. +- Claiming is two atomic adds; committing is one store. Nothing retries, + and no operation on the write path is a read-modify-write of a slot. +- Sealing is two loads and one bit, whatever the channel holds. Nothing is + copied and nothing is allocated — the whole module is allocation-free; + the reader reads the table to iterate. - On Linux, the first touch of the sparse backing file can cost milliseconds on journalling filesystems (it is the fault path, not block allocation — `fallocate` does not help). Creators should run `pre_fault` diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs index d600a46f6..c7211474e 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs @@ -22,10 +22,10 @@ use std::{num::NonZeroU32, ptr::NonNull, sync::atomic::AtomicU64}; /// The CLOSED gate bit of the claim counter: set when the receiver seals /// the channel, and by any failed claim as its loss report (rule 1). /// -/// A bit, not a value to compare against: it survives the `fetch_add` of -/// a writer that arrives late. And a refused claim puts back what it -/// added, so the count stays at or below one per slot plus the claims -/// still running — it can never climb into this bit. +/// A bit, not a value to compare against, so it survives the `fetch_add` +/// of a writer that arrives late. Counting alone would need 2^63 claims +/// to reach it, and if it ever did the gate would simply read as set: no +/// more records, and the seal fails. Wrong, but on the safe side. pub(super) const CLOSED: u64 = 1 << 63; /// The part of the region that is always in the same place — the @@ -56,25 +56,17 @@ const _: () = assert!(align_of::>() == align_of::()); // // | Value | State | // | --------------------- | ----------------------------------------------- | -// | `0` | Unfinished: slot reserved, nothing published | -// | `1` | Frozen by the seal: never publishable again | +// | `0` | Unfinished: slot claimed, nothing published yet | // | length field nonzero | Committed: offset and length of the payload | // // A claimed frame is never zero-length, so a committed value always has a -// nonzero length field and no slot value can mean two of these at once: -// `1` is a zero length with offset `1`, which no writer ever commits. Once -// a slot is committed or frozen, nothing ever changes it again. +// nonzero length field and can never be read as the zero a fresh slot +// starts at. One writer owns each slot and writes it once, so a slot goes +// from zero to committed and never changes again. // // Offsets are counted from the start of the payload area, so a descriptor // can never point into the counters or the table. -pub(super) const UNFINISHED: u64 = 0; - -/// The value the seal installs in an unfinished slot: still nothing -/// published (zero length field), but nonzero, so a late commit's -/// compare-and-swap from [`UNFINISHED`] loses. -pub(super) const FROZEN: u64 = 1; - // --- The mapped layout and the ordering contract --------------------------- // `MappedLayout::new` works out both pointers once, at attach: one to the // `Meta` struct, one to the payload area. The endpoint keeps them next to @@ -95,19 +87,13 @@ pub(super) const FROZEN: u64 = 1; // receiver must already reject. // - the **payload counter**: payload bytes reserved, one `fetch_add`. // -// A refused claim puts back what it added, always after the gate is set -// — either it saw the gate, or it set the gate itself. Each subtraction -// pairs with that same claim's addition, so the claim counter never -// falls below the number of successful claims, nor below what a seal -// already saw, and never climbs past one per slot plus the claims still -// running: it cannot count up into the gate bit. (A writer that dies -// between adding and putting back leaves one count behind — harmless -// unless it happens 2^63 times.) The payload counter takes back only -// reservations that ran off the end, which no live frame sits above; a -// refused claim that did fit keeps its bytes counted, and no one ever -// writes there. Neither counter says where data is — each descriptor -// carries its own offset and length — and the receiver clamps them -// rather than trusting them. +// Both counters only ever climb, and a refused claim leaves its +// increment behind. That costs nothing: neither counter says where data +// is — each descriptor carries its own offset and length — so an +// inflated counter cannot point anything at the wrong bytes. The +// receiver clamps its snapshot to the table length rather than trusting +// it, and once the gate is set no claim ever reaches the point of taking +// a span, whatever the payload counter has climbed to. // // # Memory-ordering contract // @@ -124,12 +110,12 @@ pub(super) const FROZEN: u64 = 1; // boundary; a writer that skipped on seeing the bit is covered the // same way; one that died before setting it never performed its // operation, so nothing was lost. -// 2. **Writer commit** — `FrameMut::finish`'s compare-and-swap uses -// `Release`: every payload write happens-before the descriptor is -// visible. -// 3. **Receiver observation** — the freeze compare-and-swap in -// `ShmReader::seal` uses `Acquire` on failure: an observed descriptor -// implies fully visible payload bytes. +// 2. **Writer commit** — `FrameMut::finish` stores the descriptor with +// `Release`: every payload write happens-before it can be seen. The +// writer that claimed the slot is the only one that ever writes it, so +// a plain store is enough. +// 3. **Receiver read** — `Iter` loads each descriptor with `Acquire`, so +// a descriptor it sees brings the payload bytes with it. /// Converts an integer into a `usize`. /// @@ -165,25 +151,26 @@ pub(super) struct MappedLayout { /// shared reference. pub(super) payloads: NonNull, /// Length of the payload area. A `u32`, the width of a descriptor's - /// offset and length, so bounds checks need no conversion. - payload_len: u32, + /// offset and length, so the writer's bounds check needs no + /// conversion. + pub(super) payload_len: u32, } /// A decoded descriptor slot (the codec above). #[derive(Clone, Copy)] pub(super) enum SlotState { /// Nothing is published in the slot: the writer has not finished it - /// yet, died or abandoned it before finishing, or the seal froze it - /// ([`FROZEN`]) so it never can be. The receiver ignores such slots. + /// yet, or died or gave it up before finishing. The receiver ignores + /// such slots. Unfinished, /// A payload is committed: the receiver may read its span, once /// checked against the payload area's bounds. Committed { /// Byte offset of the payload from the start of the payload region. offset: u32, - /// Byte length of the payload. Nonzero, so that a committed value - /// can never collide with [`UNFINISHED`] or [`FROZEN`] — the - /// offset alone could be zero. + /// Byte length of the payload. Nonzero, so a committed value is + /// never the zero a fresh slot starts at — the offset alone could + /// be zero. len: NonZeroU32, }, } @@ -263,16 +250,6 @@ impl MappedLayout { pub(super) const fn table(&self) -> &[AtomicU64] { &self.meta().table } - - /// Whether `len` bytes at `offset` lie inside the payload area. Both - /// sides ask this: the writer about the span it just reserved, the - /// reader about every descriptor it decodes. - pub(super) const fn holds_span(&self, offset: u32, len: u32) -> bool { - match offset.checked_add(len) { - Some(end) => end <= self.payload_len, - None => false, - } - } } #[cfg(test)] @@ -292,8 +269,7 @@ mod tests { assert!(o == offset && l == len); } // Zero length fields publish nothing, whatever the offset half says. - assert!(matches!(SlotState::decode(UNFINISHED), SlotState::Unfinished)); - assert!(matches!(SlotState::decode(FROZEN), SlotState::Unfinished)); + assert!(matches!(SlotState::decode(0), SlotState::Unfinished)); assert!(matches!(SlotState::decode(42), SlotState::Unfinished)); } diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs index 748fbeee0..5a25ca5cb 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs @@ -28,44 +28,44 @@ //! A claim is two wait-free `fetch_add`s — one reserves payload bytes, //! one a descriptor slot — and the old values they return are what the //! writer checks against the region's fixed size. A failed claim sets the -//! CLOSED gate to report the loss, then puts its increments back, so late -//! writers can hammer a sealed channel forever without moving the -//! counters. Each descriptor carries its own offset and length, so the +//! CLOSED gate to report the loss and leaves its increments where they +//! are: both counters only ever climb. Each descriptor carries its own +//! offset and length, so the //! counters never say where data is, and every slot sits at a fixed //! place, so an unfinished frame can never hide a later one. //! //! # Frame lifecycle //! //! ```text -//! writer commit CAS wins -//! +-----------------------------> COMMITTED (readable) -//! CLAIMED (slot 0) ---+ -//! +-----------------------------> FROZEN (ignored) -//! receiver freeze CAS wins +//! writer finishes the frame +//! CLAIMED (slot 0) ---------------------------------> COMMITTED (readable) +//! | +//! +---- writer dies or gives the claim up ---> stays zero (ignored) //! ``` //! //! The only way to reach a payload is through its committed descriptor, //! and a descriptor is committed only after the payload is fully written //! ([`layout`]'s ordering contract). The receiver never works out where a //! frame is by reading payload bytes, and the borrows [`ShmReader`] hands -//! out cover exactly the committed spans it checked — nothing writes to +//! out cover exactly the spans their writers reserved — nothing writes to //! them any more, and they never overlap what a live writer may touch. //! //! # Seal boundary //! //! [`ShmReader::seal`] draws its line by taking a snapshot of the claim -//! counter. A writer that got in before the snapshot races the freeze -//! pass for its own slot — its frame is either kept (commit won) or -//! skipped (freeze won), never half-read; a claim taken after the -//! snapshot lands in a slot the receiver never looks at, until the CLOSED -//! gate stops late writers for good. Dropping either is safe because a -//! writer writes its record *before* doing what the record describes: one -//! that died mid-frame never did it, and one that claimed or committed -//! after the snapshot does it after the receiver stopped collecting. A -//! record refused *before* the seal — no room, oversized frame — sets the -//! gate first, so the channel reports itself incomplete -//! ([`ShmReader::is_complete`]) and refuses every later claim: one lost -//! record already ruins the result. +//! counter and then shutting the gate. It walks no slots: a frame is read +//! if its descriptor is there when the reader looks at it, so a writer +//! still filling a frame when the line was drawn may land on either side. +//! A claim taken after the snapshot lands in a slot the reader never +//! reaches. Both are safe because a writer writes its record *before* +//! doing what the record describes: one that died mid-frame never did it, +//! and one that claimed or committed after the snapshot does it after the +//! receiver stopped collecting. +//! +//! A record refused *before* the seal — no room, oversized frame — sets +//! the gate first, so every later claim is refused and the seal itself +//! fails: one lost record already ruins the result, and a partial set of +//! frames is never handed out. //! //! None of this needs writers to clean up after themselves: no exit //! hooks, PID checks, heartbeats, or timeouts. @@ -81,9 +81,10 @@ use std::sync::atomic::Ordering; use fspy_shm::Mapping; #[cfg(target_os = "linux")] use layout::MappedLayout; -// Only tests name the error types; production matches on `Ok`/`Err` alone. +// Only tests name the error types; production reports them through +// `Display` and matches on `Ok`/`Err` alone. #[cfg(test)] -pub use reader::ProtocolError; +pub use reader::SealError; pub use reader::ShmReader; #[cfg(test)] pub use writer::ClaimError; @@ -241,7 +242,6 @@ mod tests { assert!(iter.next().unwrap() == b"world"); assert!(iter.next().unwrap() == b"this is a test"); assert!(iter.next() == None); - assert!(frames.is_complete()); } #[test] @@ -273,7 +273,7 @@ mod tests { } #[test] - fn full_region_marks_the_channel_incomplete() { + fn full_region_fails_the_seal() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }.unwrap(); @@ -283,19 +283,19 @@ mod tests { // Larger than the payload region: the claim fails and sets the // gate, which is what tells the receiver a record was lost. assert!(!writer.try_write_frame(&vec![0u8; 2048])); - // The out-of-bounds reservation was taken back: only the four - // bytes of "test" remain counted. - assert!(shm.peek_u64(8) == 4); + // The refused reservation stays counted: four bytes of "test" + // plus the 2048 that did not fit. + assert!(shm.peek_u64(8) == 4 + 2048); - let frames = collect_frames(&shm); - let mut iter = frames.iter(); - assert!(iter.next().unwrap() == b"test"); - assert!(iter.next() == None); - assert!(!frames.is_complete()); + // "test" did land, but a lost record means the frames are not all + // of them, so the seal hands back none of them. + // SAFETY: see `collect_frames`. + let sealed = unsafe { ShmReader::<_, S>::seal(shm) }; + assert!(sealed.unwrap_err() == SealError::Closed); } #[test] - fn oversized_frame_is_refused_and_marks_incomplete() { + fn oversized_frame_is_refused_and_fails_the_seal() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }.unwrap(); @@ -310,11 +310,9 @@ mod tests { assert!(writer.is_closed()); assert!(!writer.try_write_frame(b"refused")); - let frames = collect_frames(&shm); - let mut iter = frames.iter(); - assert!(iter.next().unwrap() == b"kept"); - assert!(iter.next() == None); - assert!(!frames.is_complete()); + // SAFETY: see `collect_frames`. + let sealed = unsafe { ShmReader::<_, S>::seal(shm) }; + assert!(sealed.unwrap_err() == SealError::Closed); } #[test] @@ -336,7 +334,6 @@ mod tests { assert!(iter.next().unwrap() == b"bar"); assert!(iter.next() == None); // Death loses no performed operation, so the channel stays complete. - assert!(frames.is_complete()); } #[test] @@ -360,7 +357,6 @@ mod tests { assert!(iter.next().unwrap() == b"foo"); assert!(iter.next().unwrap() == b"bar"); assert!(iter.next() == None); - assert!(frames.is_complete()); } #[test] @@ -389,7 +385,6 @@ mod tests { assert!(iter.next().unwrap() == b"foo"); assert!(iter.next().unwrap() == b"bar"); assert!(iter.next() == None); - assert!(frames.is_complete()); } #[test] @@ -407,7 +402,6 @@ mod tests { let mut iter = frames.iter(); assert!(iter.next().unwrap() == b"foo"); assert!(iter.next() == None); - assert!(frames.is_complete()); } #[cfg(target_os = "linux")] @@ -431,11 +425,10 @@ mod tests { assert!(iter.next().unwrap() == b"foo"); assert!(iter.next().unwrap() == b"bar"); assert!(iter.next() == None); - assert!(frames.is_complete()); } #[test] - fn slot_capacity_failure_marks_incomplete() { + fn slot_capacity_failure_fails_the_seal() { // A 1024-byte region has a 15-slot table; the 16th claim must fail // on the slot side while payload space remains. let shm = MockedShm::alloc(1024); @@ -445,13 +438,14 @@ mod tests { assert!(writer.try_write_frame(b"x")); } assert!(writer.claim_frame(1.try_into().unwrap()).unwrap_err() == ClaimError::Capacity); - // The refusal put back what it added: the gate is set, the count - // is unchanged. - assert!(shm.peek_u64(0) == (1 << 63) | 15); + // The refused claim leaves its increment behind, and sets the + // gate: sixteen claims counted, fifteen of them in slots. + assert!(shm.peek_u64(0) == (1 << 63) | 16); - let frames = collect_frames(&shm); - assert!(frames.iter().count() == 15); - assert!(!frames.is_complete()); + // Fifteen frames landed, but the sixteenth was lost. + // SAFETY: see `collect_frames`. + let sealed = unsafe { ShmReader::<_, S>::seal(shm) }; + assert!(sealed.unwrap_err() == SealError::Closed); } #[test] @@ -466,7 +460,6 @@ mod tests { let mut iter = frames.iter(); assert!(iter.next().unwrap() == b"foo"); assert!(iter.next() == None); - assert!(frames.is_complete()); // The seal set the gate: a late writer's claim fails cleanly and // does not mark the channel incomplete — that record belongs @@ -476,15 +469,12 @@ mod tests { for _ in 0..100 { assert!(writer.claim_frame(5.try_into().unwrap()).unwrap_err() == ClaimError::Closed); } - // Late writers leave no trace on the claim counter: every refusal - // puts back what it added, so counting can never reach the gate - // bit. - assert!(shm.peek_u64(0) == before); - assert!(frames.is_complete()); + // Each refusal still counted its claim, and the gate stayed set. + assert!(shm.peek_u64(0) == before + 100); } #[test] - fn commit_after_abort_publishes_nothing() { + fn commit_after_seal_shows_up_in_a_later_read() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }.unwrap(); @@ -492,20 +482,22 @@ mod tests { let mut frame = writer.claim_frame(5.try_into().unwrap()).unwrap(); frame.copy_from_slice(b"late!"); - // The receiver closes while the frame is unfinished and aborts it. + // The receiver seals while the frame is still unfinished: nothing + // to show yet, and nothing lost either — the writer has not + // performed the operation this record describes. let frames = collect_frames(&shm); assert!(frames.iter().count() == 0); - assert!(frames.is_complete()); - // The late commit loses the race silently. + // The reader reads the table when asked, so a commit that lands + // after the seal shows up in the very same reader. frame.finish(); + assert!(frames.iter().count() == 1); - // A second seal still reads no frames — and reports incomplete: - // the gate was set by the first seal, and a re-seal cannot vouch - // for records refused since then. - let frames = collect_frames(&shm); - assert!(frames.iter().count() == 0); - assert!(!frames.is_complete()); + // A second seal fails: the first one set the gate, and a re-seal + // cannot say what was refused since then. + // SAFETY: see `collect_frames`. + let sealed = unsafe { ShmReader::<_, S>::seal(shm) }; + assert!(sealed.unwrap_err() == SealError::Closed); } #[test] @@ -540,7 +532,6 @@ mod tests { assert!(frame == b"hello" || frame == b"foo" || frame == b"this is a test"); } assert!(count == 120); - assert!(frames.is_complete()); } #[test] @@ -560,18 +551,13 @@ mod tests { } }); - let frames = collect_frames(&shm); - let mut count = 0; - for frame in &frames { - count += 1; - let frame = BStr::new(frame); - assert!(frame == b"hello" || frame == b"foo" || frame == b"this is a test"); - } - // Some writes must have succeeded (the table holds 15 slots), some - // must have failed on capacity; the failures poison completeness. - assert!(count > 5); - assert!(count < 120); - assert!(!frames.is_complete()); + // The table holds 15 slots and 120 writes were attempted, so some + // had to fail on capacity — and one failure is enough to fail the + // seal. The writers all survived it. + assert!(writer.is_closed()); + // SAFETY: see `collect_frames`. + let sealed = unsafe { ShmReader::<_, S>::seal(shm) }; + assert!(sealed.unwrap_err() == SealError::Closed); } #[test] @@ -612,54 +598,20 @@ mod tests { (frames, results) }); - // Every slot before the boundary ended up either a whole frame or - // frozen: the receiver saw only complete payloads. + // Every frame the reader yields is whole: a descriptor becomes + // visible only after its payload is written. let mut count = 0; for frame in &frames { count += 1; assert!(frame == b"hello"); } - // Only commits that lost the freeze race may be missing, and no - // frame can appear that was never finished. + // Claims taken after the boundary are never read, so the count can + // fall short of what the writers wrote — but nothing can appear + // that was never finished. let written: usize = results.into_iter().sum(); assert!(count <= written); // Writers either finished or cleanly observed `Closed`; nothing was // abandoned, so completeness holds. - assert!(frames.is_complete()); - } - - #[test] - fn corrupt_committed_descriptor_is_a_protocol_error() { - let shm = MockedShm::alloc(1024); - // SAFETY: see `single_thread_basic`. - let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }.unwrap(); - assert!(writer.try_write_frame(b"hello")); - - // Point slot 0 at a span escaping the payload region. - let bogus_len = 8u64; - let bogus_offset = 1020u64; - // Slot 0 sits right after the two counters. - shm.poke_u64(16, (bogus_len << 32) | bogus_offset); - - // SAFETY: see `collect_frames`. - let result = unsafe { ShmReader::<_, S>::seal(shm) }; - assert!(result.unwrap_err() == ProtocolError::CorruptDescriptor { slot_index: 0 }); - } - - #[test] - fn corrupt_oversized_descriptor_is_a_protocol_error() { - let shm = MockedShm::alloc(1024); - // SAFETY: see `single_thread_basic`. - let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }.unwrap(); - assert!(writer.try_write_frame(b"hello")); - - // A length field far beyond anything this region can hold. - // Slot 0 sits right after the two counters. - shm.poke_u64(16, (1 << 62) | (8u64 << 32) | 8); - - // SAFETY: see `collect_frames`. - let result = unsafe { ShmReader::<_, S>::seal(shm) }; - assert!(result.unwrap_err() == ProtocolError::CorruptDescriptor { slot_index: 0 }); } #[test] @@ -764,7 +716,6 @@ mod tests { // SAFETY: the mapping is a valid shared-memory region created zeroed // and accessed only through the protocol. let frames = unsafe { ShmReader::<_, S>::seal(mapping) }.unwrap(); - assert!(frames.is_complete()); let collected = frames.iter().map(BStr::new).collect::>(); assert!(collected.len() == CHILD_COUNT * FRAME_COUNT_EACH_CHILD); for child_index in 0..CHILD_COUNT { @@ -838,6 +789,5 @@ mod tests { assert!(iter.next() == None); // The killed writer left only an unfinished slot; the counters // stayed within their limits, so the channel is complete. - assert!(frames.is_complete()); } } diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs index 3ae0498ce..b29a81ede 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs @@ -1,14 +1,17 @@ //! The reader side: seal the channel, then iterate the committed frames. //! -//! Sealing never waits for writers, and no payload byte is read or -//! copied: the reader keeps the mapping alive and hands out each +//! Sealing is two loads and one bit: it never walks the table and never +//! waits for writers. Reading is just as cheap — no payload byte is read +//! or copied. The reader keeps the mapping alive and hands out each //! committed span on demand. Those borrows are safe because nothing //! writes to a committed span again (committing uses up the writer's //! frame) and it never overlaps what a live writer may still touch. -//! All of this assumes the promise made at attach — that the region is -//! touched only through this protocol. A process that scribbles on it -//! some other way breaks that promise, and this code does not defend -//! against it. +//! +//! A span's offset and length come from the writer that reserved them, +//! and nothing here re-checks them. That rests on the promise made at +//! attach — that the region is touched only through this protocol. A +//! process that scribbles on it some other way breaks that promise, and +//! this code does not defend against it. use std::{ fmt, slice, @@ -17,57 +20,61 @@ use std::{ use super::{ AsRawSlice, - layout::{self, CLOSED, FROZEN, MappedLayout, SlotState, to_usize}, + layout::{CLOSED, MappedLayout, SlotState, to_usize}, }; -/// Why a channel could not be sealed into readable frames. +/// Why a channel could not be sealed. #[derive(thiserror::Error, Clone, Copy, PartialEq, Eq, Debug)] -pub enum ProtocolError { +pub enum SealError { /// The mapping cannot hold the protocol at all (see /// [`MappedLayout::new`]). #[error("the shared-memory region cannot host the channel")] UnsupportedRegion, - /// A descriptor no correct writer could have written: something - /// corrupted the region, and its frames cannot be used. - #[error("corrupt shared-memory frame descriptor at slot {slot_index}")] - CorruptDescriptor { slot_index: usize }, + /// The channel was already closed when the seal ran: a claim had + /// failed — no room left, or an oversized frame — or someone sealed it + /// earlier and this seal cannot say what was refused since. Either + /// way the frames are not all of them, so there are none to hand out. + #[error("the shared-memory channel was closed before it was sealed")] + Closed, } /// A reader over the committed frames of a sealed channel, serving them /// straight out of the mapping, which stays alive inside this value and /// is released when the reader drops. It holds no buffer of its own: -/// iterating re-reads the frozen descriptor table, so sealing a channel -/// allocates nothing. +/// iterating reads the descriptor table, so sealing a channel allocates +/// nothing and touches nothing. pub struct ShmReader { /// Where the parts of the owned region are. mapped: MappedLayout, /// Owns the region the pointers point into. Declared after them: /// fields drop in order, and the borrower must go first. _mem: M, - /// Length of the frozen prefix of the descriptor table. + /// How many slots the seal admitted: iteration stops there. slot_count: usize, - /// Committed frames in that prefix. - frames: usize, - complete: bool, } -// SAFETY: the reader reads only the counters, frozen slots, and committed -// spans that nothing writes to any more; the stored pointers point into -// the mapped memory, which is owned separately and does not move, not -// into the reader itself. +// SAFETY: the reader only loads counters and descriptors atomically, and +// reads committed spans that nothing writes to any more; the stored +// pointers point into the mapped memory, which is owned separately and +// does not move, not into the reader itself. unsafe impl Send for ShmReader {} // SAFETY: see the `Send` impl. unsafe impl Sync for ShmReader {} impl ShmReader { - /// Seals the channel — no further records — and returns the reader - /// of its committed frames. + /// Seals the channel — no further records — and returns the reader of + /// its committed frames. + /// + /// A reader exists only for a channel that kept everything: if a claim + /// had already failed, or the channel was already sealed, there is no + /// complete set of records to read and this fails instead. /// - /// Never waits for writers. A writer that got in before the snapshot - /// races this pass for its own slot and ends up either committed - /// (kept) or frozen (skipped); a claim taken after the snapshot lands - /// in a slot this pass never looks at, until the CLOSED gate — set - /// before this returns — stops the claims for good. + /// Never waits for writers, and never walks the table: it takes the + /// snapshot that fixes how far iteration goes, then shuts the gate so + /// no further claim is taken. A claim from before the snapshot that + /// commits later shows up if it lands before the read that looks for + /// it; one taken after the snapshot lands in a slot iteration never + /// reaches. /// /// # Safety /// @@ -80,98 +87,53 @@ impl ShmReader { /// /// # Errors /// - /// [`ProtocolError`]: the mapping cannot hold the protocol, or it - /// contains something no correct writer could have written — the - /// region was corrupted and its frames cannot be used. - pub unsafe fn seal(mem: M) -> Result { + /// [`SealError`]: the mapping cannot hold the protocol, or the channel + /// was already closed before this call. + pub unsafe fn seal(mem: M) -> Result { // SAFETY: forwarded from this function's contract, which keeps the // region valid for as long as the reader lives — and so for every // use of the pointers, which are stored in the reader and dropped // with it. let Some(mapped) = (unsafe { MappedLayout::new(mem.as_raw_slice()) }) else { - return Err(ProtocolError::UnsupportedRegion); + return Err(SealError::UnsupportedRegion); }; - let slot_count; - let mut frames = 0; - let complete; - { - // The seal boundary (rule 1): claims at or before this - // snapshot are in, later ones land in slots this pass never - // looks at. Clamped, so a counter reading higher than the - // table just means sweeping the whole table, not an error. - let claims = mapped.claims().load(Ordering::Relaxed); - slot_count = to_usize(claims & !CLOSED).min(SLOTS); - // The same load answers the other question: a gate already - // set before the boundary means a record was lost — or that - // someone sealed earlier, and a second seal cannot promise - // anything about records refused in between. - complete = claims & CLOSED == 0; - - // Shut the gate, so late writers stop claiming slots and - // touching new pages. A claim that slips in between is - // dropped safely (rule 1). - mapped.claims().fetch_or(CLOSED, Ordering::Relaxed); - - // Freeze pass: give every slot up to the boundary its final - // value, and check the committed ones. Afterwards this part - // of the table can never change — a late commit loses to - // `FROZEN` — so iterating just re-reads it, copying and - // allocating nothing. - for slot_index in 0..slot_count { - // Rule 3: `Acquire` on failure means that if this slot - // is committed, its payload bytes are all visible. - let Err(bits) = mapped.table()[slot_index].compare_exchange( - layout::UNFINISHED, - FROZEN, - Ordering::AcqRel, - Ordering::Acquire, - ) else { - // The receiver won the race: the unfinished slot is - // frozen and stays ignored. - continue; - }; - match SlotState::decode(bits) { - // Frozen by an earlier seal, or scribbled on without - // publishing anything: no frame either way. - SlotState::Unfinished => {} - SlotState::Committed { offset, len } => { - // A span no correct writer could have written - // fails the whole channel. - if !mapped.holds_span(offset, len.get()) { - return Err(ProtocolError::CorruptDescriptor { slot_index }); - } - frames += 1; - } - } - } + + // The seal boundary (rule 1): claims at or before this snapshot + // are in, later ones land in slots iteration never reaches. + let claims = mapped.claims().load(Ordering::Relaxed); + // The same load says whether anything was lost. The gate is + // already set: either a claim failed — and rule 1 puts that loss + // before this boundary — or someone sealed earlier and this seal + // cannot say what was refused since. No complete set to read. + if claims & CLOSED != 0 { + return Err(SealError::Closed); } + // Clamped, so a counter reading higher than the table just means + // walking the whole table, not an error. + let slot_count = to_usize(claims).min(SLOTS); - Ok(Self { mapped, _mem: mem, slot_count, frames, complete }) + // Shut the gate, so late writers stop claiming slots and touching + // new pages. A claim that slips in between is dropped safely + // (rule 1). + mapped.claims().fetch_or(CLOSED, Ordering::Relaxed); + + Ok(Self { mapped, _mem: mem, slot_count }) } /// Iterates over the committed frames in claim order. + /// + /// Reads the descriptor table as it goes, so a writer that was still + /// filling a frame when the channel was sealed may appear in a later + /// call and not an earlier one. Everything it yields is a whole frame + /// whose writer finished it. pub fn iter(&self) -> Iter<'_, SLOTS> { self.into_iter() } - - /// Whether every record a writer published made it in. - /// - /// False when a claim failed before the seal — no room left, or an - /// oversized frame. That record is gone, so the frames say less than - /// the writers actually did. Anyone who needs the full picture must - /// throw them away. - #[must_use] - pub const fn is_complete(&self) -> bool { - self.complete - } } impl fmt::Debug for ShmReader { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("ShmReader") - .field("frames", &self.frames) - .field("complete", &self.complete) - .finish_non_exhaustive() + f.debug_struct("ShmReader").field("slots", &self.slot_count).finish_non_exhaustive() } } @@ -179,10 +141,8 @@ impl fmt::Debug for ShmReader { pub struct Iter<'a, const SLOTS: usize> { /// Where the payload area is, for turning descriptors into spans. mapped: MappedLayout, - /// The frozen slots this iterator has not reached yet. + /// The admitted slots this iterator has not reached yet. table: &'a [AtomicU64], - /// Committed frames not yet yielded. - remaining: usize, } impl<'a, const SLOTS: usize> Iterator for Iter<'a, SLOTS> { @@ -191,21 +151,19 @@ impl<'a, const SLOTS: usize> Iterator for Iter<'a, SLOTS> { fn next(&mut self) -> Option { while let Some((slot, rest)) = self.table.split_first() { self.table = rest; - // The slot can never change again, so this plain load reads - // what the freeze pass saw; whatever brought the reader to - // this thread brought that pass's `Acquire` along (rule 3). - let bits = slot.load(Ordering::Relaxed); + // Rule 3: `Acquire`, so a descriptor this load sees brings + // its payload bytes with it. + let bits = slot.load(Ordering::Acquire); // An unfinished slot published nothing. let SlotState::Committed { offset, len } = SlotState::decode(bits) else { continue; }; - self.remaining -= 1; - // SAFETY: `seal` checked this descriptor against the payload - // area and would have failed the channel had it not fit, and - // the slot has been frozen ever since — so the load above - // returns the value it checked. Nothing writes to a committed - // span any more, and the reader borrowed for `'a` keeps the - // mapping alive. + // SAFETY: a committed descriptor names the span its writer + // reserved inside the payload area, and the attach contract + // says nothing but this protocol writes the region — so these + // are the bits a writer put here. Nothing writes to a + // committed span any more, and the reader borrowed for `'a` + // keeps the mapping alive. return Some(unsafe { slice::from_raw_parts( self.mapped.payloads.add(to_usize(offset)).as_ptr().cast_const(), @@ -217,7 +175,9 @@ impl<'a, const SLOTS: usize> Iterator for Iter<'a, SLOTS> { } fn size_hint(&self) -> (usize, Option) { - (self.remaining, Some(self.remaining)) + // One frame per remaining slot at most; how many are committed is + // only known by reading them. + (0, Some(self.table.len())) } } @@ -226,10 +186,6 @@ impl<'a, M: AsRawSlice, const SLOTS: usize> IntoIterator for &'a ShmReader Iter<'a, SLOTS> { - Iter { - mapped: self.mapped, - table: &self.mapped.table()[..self.slot_count], - remaining: self.frames, - } + Iter { mapped: self.mapped, table: &self.mapped.table()[..self.slot_count] } } } diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs index 17e86a062..0cd84a2ed 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs @@ -1,23 +1,23 @@ //! The writer side: claim a frame, fill it, finish it. use std::{ - fmt, num::{NonZeroU32, NonZeroUsize}, ops::{Deref, DerefMut}, slice, - sync::atomic::Ordering, + sync::atomic::{AtomicU64, Ordering}, }; use super::{ AsRawSlice, - layout::{self, CLOSED, MappedLayout, SlotState, to_usize}, + layout::{CLOSED, MappedLayout, SlotState, to_usize}, }; /// A concurrent shared-memory frame writer. /// /// Safe to use from many threads and processes at once: each frame is /// reserved atomically, filled in a span no one else can touch, and -/// published with one atomic write (the ordering contract in [`layout`]). +/// published with one atomic write (the ordering contract in +/// [`super::layout`]). pub struct ShmWriter { mapped: MappedLayout, /// Owns the region the pointers point into. Declared after them: @@ -39,14 +39,14 @@ unsafe impl Sync for ShmWriter {} pub enum ClaimError { /// The CLOSED gate was set: the receiver sealed the channel, or an /// earlier claim failed and shut it down. Dropping the record is - /// right either way — the receiver had already stopped collecting, - /// or that same bit already tells it the frames are incomplete. + /// right either way — the receiver had already stopped collecting, or + /// that same bit already makes the seal fail. #[error("the channel has been closed")] Closed, /// There was no room: the region is full, or the frame is longer than /// the `u32::MAX`-byte limit. The loss is already recorded — this - /// claim set the CLOSED gate — so the channel now reports itself - /// incomplete and refuses every later claim. + /// claim set the CLOSED gate — so sealing the channel will fail and + /// every later claim is refused. #[error("no space left in the shared-memory region")] Capacity, } @@ -97,8 +97,7 @@ impl ShmWriter { }; // A frame too long for the descriptor's 32-bit length field cannot - // be described, so this conversion is the oversize check. No - // counter has moved yet, so there is nothing to undo. + // be described, so this conversion is the oversize check. let Ok(frame_size) = NonZeroU32::try_from(frame_size) else { return Err(report_loss()); }; @@ -108,40 +107,21 @@ impl ShmWriter { // Payload bytes first, so a payload-capacity failure does not burn a // slot. let payload_start = mapped.payload_reserved().fetch_add(reservation, Ordering::Relaxed); - // The claim needs an offset a descriptor can hold and a frame that - // ends inside the payload area. Both are checked, so if something - // else scribbled on the counter the claim fails rather than - // wrapping around into a span outside the region. - let Some(payload_offset) = u32::try_from(payload_start) - .ok() - .filter(|&offset| mapped.holds_span(offset, frame_size.get())) + let Some(payload_offset) = + fitted_offset(payload_start, frame_size.get(), mapped.payload_len) else { - let err = report_loss(); - // Put it back (rule 1), gate first. Whichever check failed, - // the reservation ran past the end of the region, so no live - // frame sits above it. - mapped.payload_reserved().fetch_sub(reservation, Ordering::Relaxed); - return Err(err); + return Err(report_loss()); }; let claims = mapped.claims().fetch_add(1, Ordering::Relaxed); if claims & CLOSED != 0 { - // Not a loss: the receiver had already stopped collecting - // when this record was refused. Put the count back (rule 1) - // — the gate was already set. (The payload reservation - // stays: it is inside the region, nothing was written - // there, and the region caps how much can pile up.) - mapped.claims().fetch_sub(1, Ordering::Relaxed); + // Not a loss: the receiver had already stopped collecting when + // this record was refused. return Err(ClaimError::Closed); } let slot_index = to_usize(claims); if slot_index >= SLOTS { - let err = report_loss(); - // Put the count back (rule 1), gate first: with the bit - // already set, no later reader can mistake the lowered count - // for an open channel. - mapped.claims().fetch_sub(1, Ordering::Relaxed); - return Err(err); + return Err(report_loss()); } // SAFETY: the claim reserved this span for itself, and the check @@ -156,9 +136,9 @@ impl ShmWriter { ) }; Ok(FrameMut { - mapped, - slot_index, - descriptor: SlotState::Committed { offset: payload_offset, len: frame_size }.encode(), + slot: &self.mapped.table()[slot_index], + slot_to_commit: SlotState::Committed { offset: payload_offset, len: frame_size } + .encode(), content, }) } @@ -177,6 +157,17 @@ impl ShmWriter { } } +/// The offset of a `len`-byte frame reserved at `start`, or `None` when it +/// does not fit: the start must be small enough for a descriptor's 32-bit +/// offset, and the frame must end inside the payload area. Both are +/// checked, so if something else scribbled on the counter the claim fails +/// rather than wrapping around into a span outside the region. +fn fitted_offset(start: u64, len: u32, payload_len: u32) -> Option { + let offset = u32::try_from(start).ok()?; + let end = offset.checked_add(len)?; + (end <= payload_len).then_some(offset) +} + /// An exclusively owned, claimed-but-unpublished frame. /// /// [`FrameMut::finish`] commits the frame; it is the only way to show the @@ -185,22 +176,13 @@ impl ShmWriter { /// writer had died there. A writer that drops a frame and still performs /// the operation it described breaks the rule this channel is built on — /// write the record first, then do the thing it records. +#[derive(Debug)] pub struct FrameMut<'a, const SLOTS: usize> { - mapped: MappedLayout, - slot_index: usize, - descriptor: u64, + slot: &'a AtomicU64, + slot_to_commit: u64, content: &'a mut [u8], } -impl fmt::Debug for FrameMut<'_, SLOTS> { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("FrameMut") - .field("slot_index", &self.slot_index) - .field("len", &self.content.len()) - .finish_non_exhaustive() - } -} - impl Deref for FrameMut<'_, SLOTS> { type Target = [u8]; @@ -218,17 +200,14 @@ impl DerefMut for FrameMut<'_, SLOTS> { impl FrameMut<'_, SLOTS> { /// Commits the frame, making it visible to the receiver. /// - /// If the receiver sealed the channel and froze this slot first, the - /// swap fails and the frame is dropped without a sound: this record - /// raced the seal, and is meant to be left out either way. + /// A receiver that already sealed the channel may or may not show this + /// frame: it reads the table when asked, so what it reports depends on + /// whether the descriptor is there yet. Either answer is truthful — + /// the operation this record describes had not happened when the + /// receiver drew its line. pub fn finish(self) { // Rule 2: `Release` orders every payload write before the - // descriptor. - let _ = self.mapped.table()[self.slot_index].compare_exchange( - layout::UNFINISHED, - self.descriptor, - Ordering::Release, - Ordering::Relaxed, - ); + // descriptor. This writer owns the slot, so a store is enough. + self.slot.store(self.slot_to_commit, Ordering::Release); } } From f0a19e20787b740ba7df7f6b477ea50072960b80 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sun, 16 Aug 2026 21:35:12 +0800 Subject: [PATCH 55/92] fix(fspy): restore the SHM_CAPACITY import on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `windows/mod.rs` uses `SHM_CAPACITY` but stopped importing it when the `OwnedReceiverLockGuard` import was rewritten in d91b2fb8, so the Windows build of the crate has not compiled since. Unix was unaffected — its own module imports the constant separately. Co-Authored-By: Claude Fable 5 --- crates/fspy/src/windows/mod.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/fspy/src/windows/mod.rs b/crates/fspy/src/windows/mod.rs index 3ac99361e..a79606e73 100644 --- a/crates/fspy/src/windows/mod.rs +++ b/crates/fspy/src/windows/mod.rs @@ -21,7 +21,10 @@ use winapi::{ use winsafe::co::{CP, WC}; use crate::{ - ChildTermination, TrackedChild, command::Command, error::SpawnError, ipc::ChannelAccesses, + ChildTermination, TrackedChild, + command::Command, + error::SpawnError, + ipc::{ChannelAccesses, SHM_CAPACITY}, }; const INTERPOSE_CDYLIB: Artifact = From 268c6e8bc75e675187441994fb6cc9672d4ab4e0 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sun, 16 Aug 2026 21:49:32 +0800 Subject: [PATCH 56/92] refactor(fspy-shm): name the payload pointer, drop the dead parameters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `MappedLayout.payloads` becomes `payload_start`: it is one pointer, and the name now pairs with `payload_len` instead of suggesting a slice. `Iter` keeps that pointer alone rather than the whole `MappedLayout`, which is all it needs to turn a descriptor into a span — and that leaves `SLOTS` unused on both `Iter` and `FrameMut`, so both drop it. Unused const parameters compile quietly, unlike unused type parameters, so nothing flagged them until the fields shrank. `iter` builds the iterator and `into_iter` delegates to it, rather than the other way round. Co-Authored-By: Claude Fable 5 --- .../src/ipc/channel/shm_io/layout.rs | 6 ++-- .../src/ipc/channel/shm_io/reader.rs | 29 +++++++++++-------- .../src/ipc/channel/shm_io/writer.rs | 12 ++++---- 3 files changed, 26 insertions(+), 21 deletions(-) diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs index c7211474e..923083f09 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs @@ -149,7 +149,7 @@ pub(super) struct MappedLayout { /// Start of the payload area. A raw pointer, not a reference: /// writers hand out `&mut` slices into it, which must not overlap a /// shared reference. - pub(super) payloads: NonNull, + pub(super) payload_start: NonNull, /// Length of the payload area. A `u32`, the width of a descriptor's /// offset and length, so the writer's bounds check needs no /// conversion. @@ -223,8 +223,8 @@ impl MappedLayout { // The payload area is everything after the fixed struct. // SAFETY: the mapping holds the struct (checked above). - let payloads = NonNull::new(unsafe { mem_start.add(size_of::>()) })?; - Some(Self { meta, payloads, payload_len }) + let payload_start = NonNull::new(unsafe { mem_start.add(size_of::>()) })?; + Some(Self { meta, payload_start, payload_len }) } /// The fixed part of the region. diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs index b29a81ede..7388edc26 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs @@ -14,7 +14,9 @@ //! this code does not defend against it. use std::{ - fmt, slice, + fmt, + ptr::NonNull, + slice, sync::atomic::{AtomicU64, Ordering}, }; @@ -126,8 +128,11 @@ impl ShmReader { /// filling a frame when the channel was sealed may appear in a later /// call and not an earlier one. Everything it yields is a whole frame /// whose writer finished it. - pub fn iter(&self) -> Iter<'_, SLOTS> { - self.into_iter() + pub fn iter(&self) -> Iter<'_> { + Iter { + payload_start: self.mapped.payload_start, + table: &self.mapped.table()[..self.slot_count], + } } } @@ -138,14 +143,14 @@ impl fmt::Debug for ShmReader { } /// Iterator over a [`ShmReader`]'s committed frames, in claim order. -pub struct Iter<'a, const SLOTS: usize> { +pub struct Iter<'a> { /// Where the payload area is, for turning descriptors into spans. - mapped: MappedLayout, + payload_start: NonNull, /// The admitted slots this iterator has not reached yet. table: &'a [AtomicU64], } -impl<'a, const SLOTS: usize> Iterator for Iter<'a, SLOTS> { +impl<'a> Iterator for Iter<'a> { type Item = &'a [u8]; fn next(&mut self) -> Option { @@ -153,9 +158,9 @@ impl<'a, const SLOTS: usize> Iterator for Iter<'a, SLOTS> { self.table = rest; // Rule 3: `Acquire`, so a descriptor this load sees brings // its payload bytes with it. - let bits = slot.load(Ordering::Acquire); + let slot_value = slot.load(Ordering::Acquire); // An unfinished slot published nothing. - let SlotState::Committed { offset, len } = SlotState::decode(bits) else { + let SlotState::Committed { offset, len } = SlotState::decode(slot_value) else { continue; }; // SAFETY: a committed descriptor names the span its writer @@ -166,7 +171,7 @@ impl<'a, const SLOTS: usize> Iterator for Iter<'a, SLOTS> { // keeps the mapping alive. return Some(unsafe { slice::from_raw_parts( - self.mapped.payloads.add(to_usize(offset)).as_ptr().cast_const(), + self.payload_start.add(to_usize(offset)).as_ptr().cast_const(), to_usize(len.get()), ) }); @@ -182,10 +187,10 @@ impl<'a, const SLOTS: usize> Iterator for Iter<'a, SLOTS> { } impl<'a, M: AsRawSlice, const SLOTS: usize> IntoIterator for &'a ShmReader { - type IntoIter = Iter<'a, SLOTS>; + type IntoIter = Iter<'a>; type Item = &'a [u8]; - fn into_iter(self) -> Iter<'a, SLOTS> { - Iter { mapped: self.mapped, table: &self.mapped.table()[..self.slot_count] } + fn into_iter(self) -> Iter<'a> { + self.iter() } } diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs index 0cd84a2ed..8553d8adc 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs @@ -86,7 +86,7 @@ impl ShmWriter { /// claim that does not fit — the region is full, or `frame_size` is /// over `u32::MAX` — fails as [`ClaimError::Capacity`] after setting /// the CLOSED gate. - pub fn claim_frame(&self, frame_size: NonZeroUsize) -> Result, ClaimError> { + pub fn claim_frame(&self, frame_size: NonZeroUsize) -> Result, ClaimError> { let mapped = self.mapped; // The loss report (rule 1): the gate marks the frames incomplete @@ -131,7 +131,7 @@ impl ShmWriter { // taking this borrow. let content = unsafe { slice::from_raw_parts_mut( - mapped.payloads.add(to_usize(payload_offset)).as_ptr(), + mapped.payload_start.add(to_usize(payload_offset)).as_ptr(), to_usize(frame_size.get()), ) }; @@ -177,13 +177,13 @@ fn fitted_offset(start: u64, len: u32, payload_len: u32) -> Option { /// the operation it described breaks the rule this channel is built on — /// write the record first, then do the thing it records. #[derive(Debug)] -pub struct FrameMut<'a, const SLOTS: usize> { +pub struct FrameMut<'a> { slot: &'a AtomicU64, slot_to_commit: u64, content: &'a mut [u8], } -impl Deref for FrameMut<'_, SLOTS> { +impl Deref for FrameMut<'_> { type Target = [u8]; fn deref(&self) -> &Self::Target { @@ -191,13 +191,13 @@ impl Deref for FrameMut<'_, SLOTS> { } } -impl DerefMut for FrameMut<'_, SLOTS> { +impl DerefMut for FrameMut<'_> { fn deref_mut(&mut self) -> &mut Self::Target { self.content } } -impl FrameMut<'_, SLOTS> { +impl FrameMut<'_> { /// Commits the frame, making it visible to the receiver. /// /// A receiver that already sealed the channel may or may not show this From 4480826c8f0b482be5450f4fd1cf312326c85339 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sun, 16 Aug 2026 22:52:15 +0800 Subject: [PATCH 57/92] refactor(fspy-shm): the reader keeps no layout and no SLOTS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ShmReader` stored a `MappedLayout` and a slot count, which forced a const parameter onto a type that had no use for it once built. It now keeps the two things it actually reads — where the payload area starts, and the slots the seal admitted — so the type is plain `ShmReader` and `SLOTS` moves to `seal`, which is the only place that needs it. The admitted slots are held as a raw pointer, since a reference would need a lifetime the reader cannot name. Reading through it later is safe because every byte of the metadata struct sits inside an `AtomicU64`, so a writer storing a descriptor never invalidates the borrow — spelled out at both ends, because it stops holding the day that struct gains a field that is not an atomic. `channel::Frames` becomes `channel::FrameReader`: the value reads frames out of the mapping, it does not hold a collection of them. Co-Authored-By: Claude Fable 5 --- crates/fspy/src/ipc.rs | 4 +- crates/fspy_shared/src/ipc/channel/mod.rs | 14 +++--- .../fspy_shared/src/ipc/channel/shm_io/mod.rs | 28 +++++------ .../src/ipc/channel/shm_io/reader.rs | 47 ++++++++++++------- 4 files changed, 53 insertions(+), 40 deletions(-) diff --git a/crates/fspy/src/ipc.rs b/crates/fspy/src/ipc.rs index b617374f9..7c4137fb9 100644 --- a/crates/fspy/src/ipc.rs +++ b/crates/fspy/src/ipc.rs @@ -2,7 +2,7 @@ use std::io; use fspy_shared::ipc::{ PathAccess, - channel::{Frames, Receiver}, + channel::{FrameReader, Receiver}, }; // Shared memory region size: the channel's fixed descriptor table plus @@ -12,7 +12,7 @@ pub const SHM_CAPACITY: usize = 4 * 1024 * 1024 * 1024; /// The path accesses a run reported through the IPC channel. pub struct ChannelAccesses { - frames: Frames, + frames: FrameReader, } impl TryFrom for ChannelAccesses { diff --git a/crates/fspy_shared/src/ipc/channel/mod.rs b/crates/fspy_shared/src/ipc/channel/mod.rs index 2c38a65ce..ef654eaa0 100644 --- a/crates/fspy_shared/src/ipc/channel/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/mod.rs @@ -23,9 +23,9 @@ use shm_io::{ShmReader, ShmWriter}; /// address space until slots are actually touched. const SLOTS: usize = 1 << 26; -/// The committed frames of a closed channel; borrows the shared mapping, -/// which stays alive (and mapped) until this value drops. -pub type Frames = shm_io::ShmReader; +/// Reads the committed frames of a sealed channel; borrows the shared +/// mapping, which stays alive (and mapped) until this value drops. +pub type FrameReader = shm_io::ShmReader; use uuid::Uuid; use wincode::{SchemaRead, SchemaWrite, Serialize as _, config::DefaultConfig}; @@ -283,14 +283,14 @@ unsafe impl Sync for Receiver {} impl Receiver { /// Closes the channel and returns every committed frame, borrowed from - /// the shared mapping that moves into the returned [`Frames`]. + /// the shared mapping that moves into the returned [`FrameReader`]. /// /// Never blocks on senders: new claims are rejected from this point on, /// unfinished frames are atomically aborted, and committed frames become /// readable in place. A sender process that is still alive keeps /// running; anything it reports after this point is outside the /// channel's boundary by design. The mapping is released when the - /// returned [`Frames`] drops. + /// returned [`FrameReader`] drops. /// /// # Errors /// @@ -298,7 +298,7 @@ impl Receiver { /// room, or tried to send something too large — and when the region /// cannot hold the protocol at all. Either way there is no complete /// set of records, so none are handed back. - pub fn close(self) -> io::Result { + pub fn close(self) -> io::Result { let Self { _keeper: keeper, mapping } = self; // Remove the backing file first so no new process attaches while the // channel closes. @@ -306,7 +306,7 @@ impl Receiver { // SAFETY: `mapping` was created zero-initialized by `channel`, its // address is stable and independently owned, and all attached // processes access it only through the `shm_io` protocol. - unsafe { ShmReader::<_, SLOTS>::seal(mapping) } + unsafe { ShmReader::seal::(mapping) } .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err)) } } diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs index 5a25ca5cb..f4140bd6d 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs @@ -30,9 +30,9 @@ //! writer checks against the region's fixed size. A failed claim sets the //! CLOSED gate to report the loss and leaves its increments where they //! are: both counters only ever climb. Each descriptor carries its own -//! offset and length, so the -//! counters never say where data is, and every slot sits at a fixed -//! place, so an unfinished frame can never hide a later one. +//! offset and length, so the counters never say where data is, and every +//! slot sits at a fixed place, so an unfinished frame can never hide a +//! later one. //! //! # Frame lifecycle //! @@ -220,10 +220,10 @@ mod tests { /// The table length most tests use; regions add payload room on top. const S: usize = 15; - fn collect_frames(shm: &MockedShm) -> ShmReader { + fn collect_frames(shm: &MockedShm) -> ShmReader { // SAFETY: `MockedShm` provides a stable, zero-initialized allocation // accessed only through the protocol. - unsafe { ShmReader::seal(shm.clone()) }.unwrap() + unsafe { ShmReader::seal::(shm.clone()) }.unwrap() } #[test] @@ -290,7 +290,7 @@ mod tests { // "test" did land, but a lost record means the frames are not all // of them, so the seal hands back none of them. // SAFETY: see `collect_frames`. - let sealed = unsafe { ShmReader::<_, S>::seal(shm) }; + let sealed = unsafe { ShmReader::seal::(shm) }; assert!(sealed.unwrap_err() == SealError::Closed); } @@ -311,7 +311,7 @@ mod tests { assert!(!writer.try_write_frame(b"refused")); // SAFETY: see `collect_frames`. - let sealed = unsafe { ShmReader::<_, S>::seal(shm) }; + let sealed = unsafe { ShmReader::seal::(shm) }; assert!(sealed.unwrap_err() == SealError::Closed); } @@ -444,7 +444,7 @@ mod tests { // Fifteen frames landed, but the sixteenth was lost. // SAFETY: see `collect_frames`. - let sealed = unsafe { ShmReader::<_, S>::seal(shm) }; + let sealed = unsafe { ShmReader::seal::(shm) }; assert!(sealed.unwrap_err() == SealError::Closed); } @@ -496,7 +496,7 @@ mod tests { // A second seal fails: the first one set the gate, and a re-seal // cannot say what was refused since then. // SAFETY: see `collect_frames`. - let sealed = unsafe { ShmReader::<_, S>::seal(shm) }; + let sealed = unsafe { ShmReader::seal::(shm) }; assert!(sealed.unwrap_err() == SealError::Closed); } @@ -524,7 +524,7 @@ mod tests { }); // SAFETY: see `collect_frames`. - let frames = unsafe { ShmReader::<_, S>::seal(shm) }.unwrap(); + let frames = unsafe { ShmReader::seal::(shm) }.unwrap(); let mut count = 0; for frame in &frames { count += 1; @@ -556,7 +556,7 @@ mod tests { // seal. The writers all survived it. assert!(writer.is_closed()); // SAFETY: see `collect_frames`. - let sealed = unsafe { ShmReader::<_, S>::seal(shm) }; + let sealed = unsafe { ShmReader::seal::(shm) }; assert!(sealed.unwrap_err() == SealError::Closed); } @@ -593,7 +593,7 @@ mod tests { barrier.wait(); // SAFETY: see `collect_frames`. - let frames = unsafe { ShmReader::<_, S>::seal(shm.clone()) }.unwrap(); + let frames = unsafe { ShmReader::seal::(shm.clone()) }.unwrap(); let results = writers.map(|writer| writer.join().unwrap()); (frames, results) }); @@ -715,7 +715,7 @@ mod tests { // SAFETY: the mapping is a valid shared-memory region created zeroed // and accessed only through the protocol. - let frames = unsafe { ShmReader::<_, S>::seal(mapping) }.unwrap(); + let frames = unsafe { ShmReader::seal::(mapping) }.unwrap(); let collected = frames.iter().map(BStr::new).collect::>(); assert!(collected.len() == CHILD_COUNT * FRAME_COUNT_EACH_CHILD); for child_index in 0..CHILD_COUNT { @@ -783,7 +783,7 @@ mod tests { assert!(writer.try_write_frame(b"alive")); // SAFETY: see `real_shm_across_processes`. - let frames = unsafe { ShmReader::<_, S>::seal(mapping) }.unwrap(); + let frames = unsafe { ShmReader::seal::(mapping) }.unwrap(); let mut iter = frames.iter(); assert!(iter.next().unwrap() == b"alive"); assert!(iter.next() == None); diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs index 7388edc26..3cab25503 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs @@ -45,25 +45,25 @@ pub enum SealError { /// is released when the reader drops. It holds no buffer of its own: /// iterating reads the descriptor table, so sealing a channel allocates /// nothing and touches nothing. -pub struct ShmReader { - /// Where the parts of the owned region are. - mapped: MappedLayout, +pub struct ShmReader { + /// Where the payload area is, for turning descriptors into spans. + payload_start: NonNull, + /// The slots the seal admitted, in claim order. + table: NonNull<[AtomicU64]>, /// Owns the region the pointers point into. Declared after them: /// fields drop in order, and the borrower must go first. _mem: M, - /// How many slots the seal admitted: iteration stops there. - slot_count: usize, } // SAFETY: the reader only loads counters and descriptors atomically, and // reads committed spans that nothing writes to any more; the stored // pointers point into the mapped memory, which is owned separately and // does not move, not into the reader itself. -unsafe impl Send for ShmReader {} +unsafe impl Send for ShmReader {} // SAFETY: see the `Send` impl. -unsafe impl Sync for ShmReader {} +unsafe impl Sync for ShmReader {} -impl ShmReader { +impl ShmReader { /// Seals the channel — no further records — and returns the reader of /// its committed frames. /// @@ -91,12 +91,12 @@ impl ShmReader { /// /// [`SealError`]: the mapping cannot hold the protocol, or the channel /// was already closed before this call. - pub unsafe fn seal(mem: M) -> Result { + pub unsafe fn seal(mem: M) -> Result { // SAFETY: forwarded from this function's contract, which keeps the // region valid for as long as the reader lives — and so for every // use of the pointers, which are stored in the reader and dropped // with it. - let Some(mapped) = (unsafe { MappedLayout::new(mem.as_raw_slice()) }) else { + let Some(mapped) = (unsafe { MappedLayout::::new(mem.as_raw_slice()) }) else { return Err(SealError::UnsupportedRegion); }; @@ -119,7 +119,15 @@ impl ShmReader { // (rule 1). mapped.claims().fetch_or(CLOSED, Ordering::Relaxed); - Ok(Self { mapped, _mem: mem, slot_count }) + // The admitted slots, kept as a raw pointer so the reader needs + // no lifetime and no `SLOTS`. + // SAFETY of every later read through it: it points into the + // mapping this reader owns, and every byte of `Meta` is inside an + // `AtomicU64`, so a writer storing a descriptor never invalidates + // it. That last part stops holding if `Meta` ever gains a field + // that is not an atomic. + let table = NonNull::from_ref(&mapped.table()[..slot_count]); + Ok(Self { payload_start: mapped.payload_start, table, _mem: mem }) } /// Iterates over the committed frames in claim order. @@ -128,17 +136,22 @@ impl ShmReader { /// filling a frame when the channel was sealed may appear in a later /// call and not an earlier one. Everything it yields is a whole frame /// whose writer finished it. - pub fn iter(&self) -> Iter<'_> { + pub const fn iter(&self) -> Iter<'_> { Iter { - payload_start: self.mapped.payload_start, - table: &self.mapped.table()[..self.slot_count], + payload_start: self.payload_start, + // SAFETY: the slots live in the mapping this reader owns, and + // every byte of them is inside an `AtomicU64`, so writers + // storing descriptors through their own pointers never + // invalidate this borrow. It lasts no longer than `&self`, + // and so no longer than the mapping. + table: unsafe { self.table.as_ref() }, } } } -impl fmt::Debug for ShmReader { +impl fmt::Debug for ShmReader { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("ShmReader").field("slots", &self.slot_count).finish_non_exhaustive() + f.debug_struct("ShmReader").field("slots", &self.table.len()).finish_non_exhaustive() } } @@ -186,7 +199,7 @@ impl<'a> Iterator for Iter<'a> { } } -impl<'a, M: AsRawSlice, const SLOTS: usize> IntoIterator for &'a ShmReader { +impl<'a, M: AsRawSlice> IntoIterator for &'a ShmReader { type IntoIter = Iter<'a>; type Item = &'a [u8]; From 9911f84f9a1fadce41e2df9f39717ff24aca92c5 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sun, 16 Aug 2026 22:56:21 +0800 Subject: [PATCH 58/92] refactor(fspy-shm): mark the frame iterator fused MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Iter::next` returns `None` only once its slice of slots is empty, and an empty slice never grows back, so the iterator is naturally fused. Saying so lets adapters drop their guard logic. Open the layout module's shared vocabulary up to plain `pub`. Reach is unchanged either way — `shm_io` is a private module inside `channel` — so this is about how the source reads. Co-Authored-By: Claude Fable 5 --- .../src/ipc/channel/shm_io/layout.rs | 32 +++++++++---------- .../src/ipc/channel/shm_io/reader.rs | 3 ++ 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs index 923083f09..da5b36892 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs @@ -26,19 +26,19 @@ use std::{num::NonZeroU32, ptr::NonNull, sync::atomic::AtomicU64}; /// of a writer that arrives late. Counting alone would need 2^63 claims /// to reach it, and if it ever did the gate would simply read as set: no /// more records, and the seal fails. Wrong, but on the safe side. -pub(super) const CLOSED: u64 = 1 << 63; +pub const CLOSED: u64 = 1 << 63; /// The part of the region that is always in the same place — the /// counters and the descriptor table — as one `repr(C)` struct, which /// must start zeroed. The payload area is the rest of the mapping. #[repr(C)] -pub(super) struct Meta { +pub struct Meta { /// Bit 63 is the CLOSED gate; the low bits count claims ever attempted. - pub(super) claims: AtomicU64, + pub claims: AtomicU64, /// Payload bytes ever reserved, including by failed claims. - pub(super) payload_reserved: AtomicU64, + pub payload_reserved: AtomicU64, /// One descriptor slot per frame. - pub(super) table: [AtomicU64; SLOTS], + pub table: [AtomicU64; SLOTS], } // The mapping starts at a `u64`-aligned address and is cast to `&Meta`, @@ -125,7 +125,7 @@ const _: () = assert!(align_of::>() == align_of::()); /// so it sits here rather than at module scope — and this is the only /// `as` in the protocol. #[expect(clippy::cast_possible_truncation, reason = "the assert allows only equal widths")] -pub(super) fn to_usize(value: impl Into) -> usize { +pub fn to_usize(value: impl Into) -> usize { const { assert!(size_of::() == size_of::(), "requires a 64-bit target") }; value.into() as usize } @@ -144,21 +144,21 @@ fn try_cast_aligned(ptr: *mut T) -> Option<*mut U> { /// The pointers stay valid as long as the mapping does, because they /// point into the mapped memory, not into the endpoint holding them. #[derive(Clone, Copy)] -pub(super) struct MappedLayout { +pub struct MappedLayout { meta: NonNull>, /// Start of the payload area. A raw pointer, not a reference: /// writers hand out `&mut` slices into it, which must not overlap a /// shared reference. - pub(super) payload_start: NonNull, + pub payload_start: NonNull, /// Length of the payload area. A `u32`, the width of a descriptor's /// offset and length, so the writer's bounds check needs no /// conversion. - pub(super) payload_len: u32, + pub payload_len: u32, } /// A decoded descriptor slot (the codec above). #[derive(Clone, Copy)] -pub(super) enum SlotState { +pub enum SlotState { /// Nothing is published in the slot: the writer has not finished it /// yet, or died or gave it up before finishing. The receiver ignores /// such slots. @@ -178,7 +178,7 @@ pub(super) enum SlotState { impl SlotState { /// Decodes a slot value into its state. The two processes sharing a /// value run on one machine, so native byte order is fine. - pub(super) const fn decode(slot_value: u64) -> Self { + pub const fn decode(slot_value: u64) -> Self { let [offset, len] = bytemuck::must_cast::(slot_value); let Some(len) = NonZeroU32::new(len) else { return Self::Unfinished; @@ -187,7 +187,7 @@ impl SlotState { } /// Encodes this state as a slot value; the inverse of [`Self::decode`]. - pub(super) const fn encode(self) -> u64 { + pub const fn encode(self) -> u64 { let [offset, len] = match self { Self::Unfinished => [0, 0], Self::Committed { offset, len } => [offset, len.get()], @@ -209,7 +209,7 @@ impl MappedLayout { /// used. /// - The memory must have been zero-initialized when the region was /// created, and accessed only through this protocol since. - pub(super) unsafe fn new(mem: *mut [u8]) -> Option { + pub unsafe fn new(mem: *mut [u8]) -> Option { let mem_start = mem.cast::(); // The mapping must hold the fixed struct. let payload_len = mem.len().checked_sub(size_of::>())?; @@ -237,17 +237,17 @@ impl MappedLayout { } /// The claim counter. - pub(super) const fn claims(&self) -> &AtomicU64 { + pub const fn claims(&self) -> &AtomicU64 { &self.meta().claims } /// The payload counter. - pub(super) const fn payload_reserved(&self) -> &AtomicU64 { + pub const fn payload_reserved(&self) -> &AtomicU64 { &self.meta().payload_reserved } /// The descriptor table. - pub(super) const fn table(&self) -> &[AtomicU64] { + pub const fn table(&self) -> &[AtomicU64] { &self.meta().table } } diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs index 3cab25503..1008b0ecb 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs @@ -15,6 +15,7 @@ use std::{ fmt, + iter::FusedIterator, ptr::NonNull, slice, sync::atomic::{AtomicU64, Ordering}, @@ -199,6 +200,8 @@ impl<'a> Iterator for Iter<'a> { } } +impl FusedIterator for Iter<'_> {} + impl<'a, M: AsRawSlice> IntoIterator for &'a ShmReader { type IntoIter = Iter<'a>; type Item = &'a [u8]; From beb784bcd12556898677a7f9ab98558226779bee Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sun, 16 Aug 2026 23:02:23 +0800 Subject: [PATCH 59/92] fix(fspy): drop the unused wincode dependency `fspy_client_unix` stopped using wincode when its hand-rolled record-send sequence was replaced by `Sender::send`, but the dependency stayed behind and `cargo shear` has been failing CI on this branch since. Co-Authored-By: Claude Fable 5 --- crates/fspy_client_unix/Cargo.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/fspy_client_unix/Cargo.toml b/crates/fspy_client_unix/Cargo.toml index a7e42e124..c0b9da26a 100644 --- a/crates/fspy_client_unix/Cargo.toml +++ b/crates/fspy_client_unix/Cargo.toml @@ -14,7 +14,6 @@ libc = { workspace = true } nix = { workspace = true, features = ["fs"] } fspy_nostd = { workspace = true } fspy_nostd_alloc = { workspace = true } -wincode = { workspace = true } [target.'cfg(all(target_os = "linux", not(target_env = "musl")))'.dependencies] itoa = { workspace = true } From ca732962a173d884e94c7cb0f1f9fd4380e90e0a Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sun, 16 Aug 2026 23:03:07 +0800 Subject: [PATCH 60/92] refactor(fspy-shm): counters give up rather than wrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both counters were bare `fetch_add`s that could, in theory, come back around. Each is now a `try_update` loop that refuses the claim instead. The payload counter is the one that could do real harm: a wrapped value hands out an offset inside the region that a live frame already owns, and two writers get `&mut` over the same bytes. It now stops short of `u64::MAX`. The claim counter shares its word with the CLOSED gate, so a carry would close the channel with nobody having lost a record — the count now stops one short of the bit. The same condition covers the ordinary refusal: a gate that is already set reads as above the ceiling, so one comparison rejects both. Neither overflow is reachable in practice — the payload counter is bounded by the region until the gate is set, and the claim count needs 2^63 increments — but "unreachable by counting" is a better argument than "nobody would count that far". One behavior improves as a side effect: a claim that finds the gate set no longer counts itself, because the loop gives up before writing. Late writers now leave the counter untouched. Co-Authored-By: Claude Fable 5 --- .../src/ipc/channel/shm_io/layout.rs | 39 ++++++++++--------- .../fspy_shared/src/ipc/channel/shm_io/mod.rs | 19 +++++---- .../src/ipc/channel/shm_io/writer.rs | 37 +++++++++++++----- 3 files changed, 60 insertions(+), 35 deletions(-) diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs index da5b36892..c2e0559cb 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs @@ -22,10 +22,9 @@ use std::{num::NonZeroU32, ptr::NonNull, sync::atomic::AtomicU64}; /// The CLOSED gate bit of the claim counter: set when the receiver seals /// the channel, and by any failed claim as its loss report (rule 1). /// -/// A bit, not a value to compare against, so it survives the `fetch_add` -/// of a writer that arrives late. Counting alone would need 2^63 claims -/// to reach it, and if it ever did the gate would simply read as set: no -/// more records, and the seal fails. Wrong, but on the safe side. +/// A bit, not a value to compare against, so it survives the increment of +/// a writer that arrives late. Counting can never reach it either: a +/// claim gives up rather than carry the count into this bit. pub const CLOSED: u64 = 1 << 63; /// The part of the region that is always in the same place — the @@ -79,21 +78,25 @@ const _: () = assert!(align_of::>() == align_of::()); // Two independent monotonic `AtomicU64` counters: // // - the **claim counter**: bit 63 is the CLOSED gate, the low bits count -// claims. One wait-free `fetch_add` per claim, and the old value it -// returns says everything the writer needs: which slot it got, whether -// the channel is closed, and whether the table is full. The seal sets -// the gate, and so does every failed claim: the one bit both reports -// the loss and stops later writers doing work for a result the -// receiver must already reject. -// - the **payload counter**: payload bytes reserved, one `fetch_add`. +// claims. One increment per claim, and the old value it returns says +// everything the writer needs: which slot it got, whether the channel +// is closed, and whether the table is full. The seal sets the gate, and +// so does every failed claim: the one bit both reports the loss and +// stops later writers doing work for a result the receiver must +// already reject. +// - the **payload counter**: payload bytes reserved, one increment. // -// Both counters only ever climb, and a refused claim leaves its -// increment behind. That costs nothing: neither counter says where data -// is — each descriptor carries its own offset and length — so an -// inflated counter cannot point anything at the wrong bytes. The -// receiver clamps its snapshot to the table length rather than trusting -// it, and once the gate is set no claim ever reaches the point of taking -// a span, whatever the payload counter has climbed to. +// Each increment is a compare-and-swap loop that gives up rather than +// wrap: the claim count stops one short of the gate bit, and the payload +// counter stops short of `u64::MAX`. A wrapped counter would be the one +// way either could do harm — a payload counter that wrapped would hand +// out an offset another frame already owns, and a claim count that +// carried would close the channel with nobody having lost a record. Short +// of that, both only climb, and a refused claim leaves its increment +// behind: neither counter says where data is — each descriptor carries +// its own offset and length — so an inflated one points nothing at the +// wrong bytes. The receiver clamps its snapshot to the table length +// rather than trusting it. // // # Memory-ordering contract // diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs index f4140bd6d..6d5eb637c 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs @@ -25,11 +25,14 @@ //! out where the struct and the payload area start; the payload area //! stays plain bytes. //! -//! A claim is two wait-free `fetch_add`s — one reserves payload bytes, -//! one a descriptor slot — and the old values they return are what the -//! writer checks against the region's fixed size. A failed claim sets the -//! CLOSED gate to report the loss and leaves its increments where they -//! are: both counters only ever climb. Each descriptor carries its own +//! A claim is two atomic increments — one reserves payload bytes, one a +//! descriptor slot — and the old values they return are what the writer +//! checks against the region's fixed size. Each increment is a +//! compare-and-swap loop that gives up rather than wrap, so neither +//! counter can ever come back around to a value that is already in use. A +//! failed claim sets the CLOSED gate to report the loss and leaves its +//! increments where they are: both counters only ever climb. Each +//! descriptor carries its own //! offset and length, so the counters never say where data is, and every //! slot sits at a fixed place, so an unfinished frame can never hide a //! later one. @@ -469,8 +472,10 @@ mod tests { for _ in 0..100 { assert!(writer.claim_frame(5.try_into().unwrap()).unwrap_err() == ClaimError::Closed); } - // Each refusal still counted its claim, and the gate stayed set. - assert!(shm.peek_u64(0) == before + 100); + // A claim that sees the gate never counts itself: the + // compare-and-swap gives up instead of incrementing, so late + // writers leave no trace on the counter. + assert!(shm.peek_u64(0) == before); } #[test] diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs index 8553d8adc..03a3cddb8 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs @@ -77,8 +77,8 @@ impl ShmWriter { self.mapped.claims().load(Ordering::Relaxed) & CLOSED != 0 } - /// Claims a frame of exactly `frame_size` bytes. Wait-free: two - /// `fetch_add`s, no retry loop (rule 1). + /// Claims a frame of exactly `frame_size` bytes: two compare-and-swap + /// loops, one per counter (rule 1). /// /// The receiver cannot see the frame until [`FrameMut::finish`] /// commits it; dropping it instead gives the claim up, and the @@ -105,20 +105,37 @@ impl ShmWriter { // Widening a 32-bit size never loses anything. let reservation = u64::from(frame_size.get()); // Payload bytes first, so a payload-capacity failure does not burn a - // slot. - let payload_start = mapped.payload_reserved().fetch_add(reservation, Ordering::Relaxed); + // slot. The add refuses to wrap rather than wrapping: an overflowed + // counter would hand out an offset inside the region that another + // frame already owns. + let Ok(payload_start) = mapped.payload_reserved().try_update( + Ordering::Relaxed, + Ordering::Relaxed, + |reserved| reserved.checked_add(reservation), + ) else { + return Err(report_loss()); + }; let Some(payload_offset) = fitted_offset(payload_start, frame_size.get(), mapped.payload_len) else { return Err(report_loss()); }; - let claims = mapped.claims().fetch_add(1, Ordering::Relaxed); - if claims & CLOSED != 0 { - // Not a loss: the receiver had already stopped collecting when - // this record was refused. - return Err(ClaimError::Closed); - } + // The claim counter shares its word with the gate, so the count + // stops one short of it rather than carrying into it. That one + // condition covers both refusals: a gate already set is above the + // ceiling too. + let claims = + match mapped.claims().try_update(Ordering::Relaxed, Ordering::Relaxed, |claims| { + (claims < CLOSED - 1).then(|| claims + 1) + }) { + Ok(claims) => claims, + // Not a loss: the receiver had already stopped collecting when + // this record was refused. + Err(claims) if claims & CLOSED != 0 => return Err(ClaimError::Closed), + // The count reached its ceiling, which takes 2^63 claims. + Err(_) => return Err(report_loss()), + }; let slot_index = to_usize(claims); if slot_index >= SLOTS { return Err(report_loss()); From 7968ab409754a2084731c1b856cfb3a3103bab88 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sun, 16 Aug 2026 23:05:38 +0800 Subject: [PATCH 61/92] fix(fspy): update Cargo.lock for the dropped dependency Removing wincode from `fspy_client_unix` left the lock file listing it, which fails `cargo clippy --locked` in CI. Also note in `claim_frame` why the increment stays inside the closure: a set gate over a maxed count is exactly `u64::MAX`, so the eager `then_some` form would add to it and overflow. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 1 - crates/fspy_shared/src/ipc/channel/shm_io/writer.rs | 4 +++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 00513ba67..35ce5bdd0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1306,7 +1306,6 @@ dependencies = [ "itoa", "libc", "nix 0.31.2", - "wincode", ] [[package]] diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs index 03a3cddb8..dee53ffd2 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs @@ -124,7 +124,9 @@ impl ShmWriter { // The claim counter shares its word with the gate, so the count // stops one short of it rather than carrying into it. That one // condition covers both refusals: a gate already set is above the - // ceiling too. + // ceiling too. The add has to stay inside the closure — a set gate + // over a maxed count is `u64::MAX`, which `then_some` would add to + // eagerly and overflow. let claims = match mapped.claims().try_update(Ordering::Relaxed, Ordering::Relaxed, |claims| { (claims < CLOSED - 1).then(|| claims + 1) From b84ea5c633ad4bf87c43ec3b1c7f2ce5681a6169 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sun, 16 Aug 2026 23:29:00 +0800 Subject: [PATCH 62/92] Revert "refactor(fspy-shm): counters give up rather than wrap" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding a contended row to the benchmark priced what the loops actually cost, and it is 2.4x to 2.7x per claim at four to eight threads — the two-thread rows had shown nothing, which is why they looked free. Neither wrap they guarded against can be reached. The payload counter can only pass the region once a claim has already failed, and that failure sets the gate; every later claim is then refused at the claim counter, which is read after the reservation and before any span is built, so a wrapped payload counter never gets to name an offset. The claim count needs 2^63 increments to reach the gate bit — thousands of years at the rate claims measure, for a channel that lives one command — and a gate that read as set by accident would only fail the seal, which is the cautious answer rather than a wrong one. The reasoning now sits beside the counters instead of in the code. Co-Authored-By: Claude Fable 5 --- .../src/ipc/channel/shm_io/layout.rs | 33 +++++++++------- .../fspy_shared/src/ipc/channel/shm_io/mod.rs | 19 ++++----- .../src/ipc/channel/shm_io/writer.rs | 39 +++++-------------- 3 files changed, 37 insertions(+), 54 deletions(-) diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs index c2e0559cb..ac7a38750 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs @@ -23,8 +23,11 @@ use std::{num::NonZeroU32, ptr::NonNull, sync::atomic::AtomicU64}; /// the channel, and by any failed claim as its loss report (rule 1). /// /// A bit, not a value to compare against, so it survives the increment of -/// a writer that arrives late. Counting can never reach it either: a -/// claim gives up rather than carry the count into this bit. +/// a writer that arrives late. Counting cannot realistically reach it: at +/// the rate claims are measured to run, 2^63 of them take thousands of +/// years, and a channel lives for one command. If it ever did happen the +/// gate would read as set — no more records, and the seal fails, which is +/// the cautious answer rather than a wrong one. pub const CLOSED: u64 = 1 << 63; /// The part of the region that is always in the same place — the @@ -86,17 +89,21 @@ const _: () = assert!(align_of::>() == align_of::()); // already reject. // - the **payload counter**: payload bytes reserved, one increment. // -// Each increment is a compare-and-swap loop that gives up rather than -// wrap: the claim count stops one short of the gate bit, and the payload -// counter stops short of `u64::MAX`. A wrapped counter would be the one -// way either could do harm — a payload counter that wrapped would hand -// out an offset another frame already owns, and a claim count that -// carried would close the channel with nobody having lost a record. Short -// of that, both only climb, and a refused claim leaves its increment -// behind: neither counter says where data is — each descriptor carries -// its own offset and length — so an inflated one points nothing at the -// wrong bytes. The receiver clamps its snapshot to the table length -// rather than trusting it. +// Both counters only ever climb, and a refused claim leaves its increment +// behind. That costs nothing: neither counter says where data is — each +// descriptor carries its own offset and length — so an inflated one +// points nothing at the wrong bytes. The receiver clamps its snapshot to +// the table length rather than trusting it. +// +// Neither add is guarded against wrapping, because neither wrap can be +// reached. The payload counter can only pass the region once a claim has +// failed, which sets the gate — and from then on every claim is refused +// at the claim counter, which is read after the reservation and before +// any span is built, so a wrapped payload counter never gets to name an +// offset. The claim count reaching the gate bit needs 2^63 claims, which +// is thousands of years at the rate they are measured to run; a channel +// lives for one command, and a gate that read as set by accident would +// only make the seal fail. // // # Memory-ordering contract // diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs index 6d5eb637c..f4140bd6d 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs @@ -25,14 +25,11 @@ //! out where the struct and the payload area start; the payload area //! stays plain bytes. //! -//! A claim is two atomic increments — one reserves payload bytes, one a -//! descriptor slot — and the old values they return are what the writer -//! checks against the region's fixed size. Each increment is a -//! compare-and-swap loop that gives up rather than wrap, so neither -//! counter can ever come back around to a value that is already in use. A -//! failed claim sets the CLOSED gate to report the loss and leaves its -//! increments where they are: both counters only ever climb. Each -//! descriptor carries its own +//! A claim is two wait-free `fetch_add`s — one reserves payload bytes, +//! one a descriptor slot — and the old values they return are what the +//! writer checks against the region's fixed size. A failed claim sets the +//! CLOSED gate to report the loss and leaves its increments where they +//! are: both counters only ever climb. Each descriptor carries its own //! offset and length, so the counters never say where data is, and every //! slot sits at a fixed place, so an unfinished frame can never hide a //! later one. @@ -472,10 +469,8 @@ mod tests { for _ in 0..100 { assert!(writer.claim_frame(5.try_into().unwrap()).unwrap_err() == ClaimError::Closed); } - // A claim that sees the gate never counts itself: the - // compare-and-swap gives up instead of incrementing, so late - // writers leave no trace on the counter. - assert!(shm.peek_u64(0) == before); + // Each refusal still counted its claim, and the gate stayed set. + assert!(shm.peek_u64(0) == before + 100); } #[test] diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs index dee53ffd2..8553d8adc 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs @@ -77,8 +77,8 @@ impl ShmWriter { self.mapped.claims().load(Ordering::Relaxed) & CLOSED != 0 } - /// Claims a frame of exactly `frame_size` bytes: two compare-and-swap - /// loops, one per counter (rule 1). + /// Claims a frame of exactly `frame_size` bytes. Wait-free: two + /// `fetch_add`s, no retry loop (rule 1). /// /// The receiver cannot see the frame until [`FrameMut::finish`] /// commits it; dropping it instead gives the claim up, and the @@ -105,39 +105,20 @@ impl ShmWriter { // Widening a 32-bit size never loses anything. let reservation = u64::from(frame_size.get()); // Payload bytes first, so a payload-capacity failure does not burn a - // slot. The add refuses to wrap rather than wrapping: an overflowed - // counter would hand out an offset inside the region that another - // frame already owns. - let Ok(payload_start) = mapped.payload_reserved().try_update( - Ordering::Relaxed, - Ordering::Relaxed, - |reserved| reserved.checked_add(reservation), - ) else { - return Err(report_loss()); - }; + // slot. + let payload_start = mapped.payload_reserved().fetch_add(reservation, Ordering::Relaxed); let Some(payload_offset) = fitted_offset(payload_start, frame_size.get(), mapped.payload_len) else { return Err(report_loss()); }; - // The claim counter shares its word with the gate, so the count - // stops one short of it rather than carrying into it. That one - // condition covers both refusals: a gate already set is above the - // ceiling too. The add has to stay inside the closure — a set gate - // over a maxed count is `u64::MAX`, which `then_some` would add to - // eagerly and overflow. - let claims = - match mapped.claims().try_update(Ordering::Relaxed, Ordering::Relaxed, |claims| { - (claims < CLOSED - 1).then(|| claims + 1) - }) { - Ok(claims) => claims, - // Not a loss: the receiver had already stopped collecting when - // this record was refused. - Err(claims) if claims & CLOSED != 0 => return Err(ClaimError::Closed), - // The count reached its ceiling, which takes 2^63 claims. - Err(_) => return Err(report_loss()), - }; + let claims = mapped.claims().fetch_add(1, Ordering::Relaxed); + if claims & CLOSED != 0 { + // Not a loss: the receiver had already stopped collecting when + // this record was refused. + return Err(ClaimError::Closed); + } let slot_index = to_usize(claims); if slot_index >= SLOTS { return Err(report_loss()); From 43e894fb56b314cee10afb9346b7762f83afd490 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Mon, 17 Aug 2026 10:44:19 +0800 Subject: [PATCH 63/92] fix(fspy-shm): report the losses only the writer can see MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A code review turned up three ways this PR's own guarantee could fail quietly, plus the docs it had left behind. `Sender::send` could give up on a record — a serialization mismatch, or a frame it had already claimed — and return with nothing said. A failed claim reports itself, but a writer that abandons a frame is indistinguishable from one that died, and a writer that died never performed its operation. So there was no way to report the one case where a live writer skips a record and goes on to act. `ShmWriter` gains `report_lost_record` for exactly that, and `send` uses it. `Receiver::close` removed the backing file before sealing. A process starting up in that window failed to attach, which is the one loss the gate cannot see, and could then act before the seal's snapshot. Sealing first means it attaches, finds the channel closed, and gives up cleanly — and its records are legitimately past the boundary. The rest is documentation that stopped being true when the seal stopped freezing slots: `Receiver::close` and `ChannelAccesses` both still promised unfinished frames were "atomically aborted", the seal is one load and a bit rather than two loads, and the claim counter counts claims that reached the payload reservation rather than every attempt. Tests: `Sender::send` had no coverage at all — every test reached past it into `claim_frame`, so a disagreement between `serialized_size` and `serialize_into` would have dropped every record silently. It now round trips real records. Also pinned: that a payload-capacity failure costs no slot, and that a reported loss fails the seal. Co-Authored-By: Claude Fable 5 --- crates/fspy/src/ipc.rs | 11 +-- crates/fspy_shared/src/ipc/channel/mod.rs | 82 ++++++++++++++----- .../src/ipc/channel/shm_io/README.md | 15 ++-- .../src/ipc/channel/shm_io/layout.rs | 23 ++++-- .../fspy_shared/src/ipc/channel/shm_io/mod.rs | 21 +++++ .../src/ipc/channel/shm_io/reader.rs | 2 +- .../src/ipc/channel/shm_io/writer.rs | 14 ++++ 7 files changed, 128 insertions(+), 40 deletions(-) diff --git a/crates/fspy/src/ipc.rs b/crates/fspy/src/ipc.rs index 7c4137fb9..79f1be856 100644 --- a/crates/fspy/src/ipc.rs +++ b/crates/fspy/src/ipc.rs @@ -21,14 +21,15 @@ impl TryFrom for ChannelAccesses { /// Closes the channel and rejects traces that cannot back the run's /// file accesses. /// - /// Never waits for tracked processes — closing rejects new records and - /// atomically ignores unfinished ones (see - /// [`fspy_shared::ipc::channel::Receiver::close`]) — and its work is - /// bounded by the number of reported records, so it runs inline. + /// Never waits for tracked processes: closing reads one counter and + /// shuts the channel's gate (see + /// [`fspy_shared::ipc::channel::Receiver::close`]), so it runs inline + /// however many records were reported. /// /// Fails when a record was lost before close, which is what keeps the /// tracking result trustworthy for caching: closing hands back frames - /// only when they are all of them. + /// only when they are all of them. A run that fills the region + /// therefore fails here rather than reporting a short trace. fn try_from(receiver: Receiver) -> io::Result { Ok(Self { frames: receiver.close()? }) } diff --git a/crates/fspy_shared/src/ipc/channel/mod.rs b/crates/fspy_shared/src/ipc/channel/mod.rs index ef654eaa0..0c4248794 100644 --- a/crates/fspy_shared/src/ipc/channel/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/mod.rs @@ -182,8 +182,11 @@ impl ChannelConf { /// Creates a sender. /// /// Never blocks. Fails when the receiver has already closed the channel - /// or dropped: the backing file is then removed (and, for the removal - /// failure edge, the region itself is marked closed). + /// or dropped, because the backing file is removed either way. A close + /// also shuts the region's gate before removing it, so a sender that + /// attaches in that window still finds a closed channel; a receiver + /// that is merely dropped only removes the file, and a removal that + /// fails there leaves the region attachable. #[expect( clippy::missing_errors_doc, reason = "error conditions are self-evident from return type" @@ -229,15 +232,19 @@ impl Sender { /// Serializes one record into a committed frame. /// /// A record that cannot be sent is skipped, because that is all a - /// sender inside an intercepted call can do: the channel may have - /// closed (the record belongs past its boundary), or the region may be - /// full (a loss the failed claim reports by setting the CLOSED gate, - /// so the receiver reports the frames incomplete). + /// sender inside an intercepted call can do — but never silently. A + /// failed claim already reports itself: for space by setting the CLOSED + /// gate, or as closed, which means the record belongs past the + /// receiver's boundary. The remaining ways to give up are this + /// sender's own, so it reports them, and the seal then refuses to hand + /// back a set of frames that is missing one. pub fn send>(&self, value: &T) { let Ok(serialized_size) = T::serialized_size(value) else { + self.writer.report_lost_record(); return; }; let Ok(Some(frame_size)) = usize::try_from(serialized_size).map(NonZeroUsize::new) else { + self.writer.report_lost_record(); return; }; let Ok(mut frame) = self.writer.claim_frame(frame_size) else { @@ -245,7 +252,9 @@ impl Sender { }; let mut buf: &mut [u8] = &mut frame; if T::serialize_into(&mut buf, value).is_err() || !buf.is_empty() { - // An abandoned frame; the receiver ignores its slot. + // The frame is abandoned — the receiver ignores its slot — so + // the loss needs reporting on its own. + self.writer.report_lost_record(); return; } frame.finish(); @@ -285,29 +294,36 @@ impl Receiver { /// Closes the channel and returns every committed frame, borrowed from /// the shared mapping that moves into the returned [`FrameReader`]. /// - /// Never blocks on senders: new claims are rejected from this point on, - /// unfinished frames are atomically aborted, and committed frames become - /// readable in place. A sender process that is still alive keeps - /// running; anything it reports after this point is outside the - /// channel's boundary by design. The mapping is released when the + /// Never blocks on senders: it reads the claim counter once, which + /// fixes how far reading goes, and shuts the gate so no later claim + /// succeeds. Committed frames become readable in place. A sender that + /// was still filling a frame keeps running, and its frame may or may + /// not appear depending on whether it commits before the read reaches + /// that slot — either way the operation it describes happens after the + /// receiver stopped collecting. The mapping is released when the /// returned [`FrameReader`] drops. /// /// # Errors /// /// Fails when a record was lost before the close — a sender ran out of - /// room, or tried to send something too large — and when the region - /// cannot hold the protocol at all. Either way there is no complete - /// set of records, so none are handed back. + /// room, tried to send something too large, or gave up on one it had + /// already claimed — and when the region cannot hold the protocol at + /// all. Either way there is no complete set of records, so none are + /// handed back, and a caller that needs the trace has to treat the run + /// as untrackable rather than as having reported nothing. pub fn close(self) -> io::Result { let Self { _keeper: keeper, mapping } = self; - // Remove the backing file first so no new process attaches while the - // channel closes. - drop(keeper); // SAFETY: `mapping` was created zero-initialized by `channel`, its // address is stable and independently owned, and all attached // processes access it only through the `shm_io` protocol. - unsafe { ShmReader::seal::(mapping) } - .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err)) + let reader = unsafe { ShmReader::seal::(mapping) } + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; + // Remove the backing file only after the gate is shut. A process + // that attaches in between finds a closed channel and gives up + // cleanly; one that found the file already gone could not attach at + // all, and so could not report whatever it then failed to record. + drop(keeper); + Ok(reader) } } @@ -320,6 +336,7 @@ mod tests { use subprocess_test::command_for_fn; use super::*; + use crate::ipc::{AccessMode, IpcPath, PathAccess}; /// Any test region must hold the compile-time table; sparse, so cheap. const GIB: usize = 1 << 30; @@ -352,6 +369,31 @@ mod tests { assert!(frames.iter().next().unwrap() == &[4, 2]); } + /// `Sender::send` is the only writer production uses, and the rest of + /// these tests reach past it into `claim_frame`. This one drives it + /// end to end, so that a disagreement between `serialized_size` and + /// `serialize_into` — which would silently drop every record — fails + /// here rather than in a build. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn sender_round_trips_records() { + let (conf, receiver) = channel(GIB).unwrap(); + let sender = conf.sender().unwrap(); + let paths = ["/tmp/one", "/tmp/two/three"]; + for path in paths { + sender.send(&PathAccess::read(path)); + } + drop(sender); + + let frames = receiver.close().unwrap(); + let mut iter = frames.iter(); + for path in paths { + let access: PathAccess<'_> = wincode::deserialize_exact(iter.next().unwrap()).unwrap(); + assert!(access.path == <&IpcPath>::from(path)); + assert!(access.mode == AccessMode::READ); + } + assert!(iter.next().is_none()); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn smoke() { let (conf, receiver) = channel(GIB).unwrap(); diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/README.md b/crates/fspy_shared/src/ipc/channel/shm_io/README.md index 0eb1a57a6..1b366dd86 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/README.md +++ b/crates/fspy_shared/src/ipc/channel/shm_io/README.md @@ -76,16 +76,19 @@ runs is the heart of the design: zero. The receiver ignores it. Nothing else is affected, and no cleanup code ever runs or is needed. - **The process is alive but abandoned the frame** (dropped it without - finishing). Same thing: the slot stays zero and the receiver ignores it, - exactly as if the writer had died there. + finishing). The slot stays zero and the receiver ignores it, exactly as if + the writer had died there — the receiver cannot tell the two apart. So a + writer that gives up on a frame and then performs the action anyway has to + say so, with `ShmWriter::report_lost_record`. These rules assume one thing about how the channel is used: **a writer publishes a record before performing the action the record describes.** Then a dead writer's missing record describes an action that never happened, and a record refused after the seal describes an action performed after the channel closed — both safe to ignore. A writer that records -_after_ acting, or that abandons a frame and performs the action anyway, -steps outside this rule and loses records silently. +_after_ acting steps outside this rule and loses records silently. One that +gives up on a frame and acts anyway must report the loss, which closes the +channel and fails its seal. ## When the region fills up @@ -126,7 +129,7 @@ The receiver seals the channel once: fails right here — a partial set of frames is never handed out. 2. **Gate** further claims by setting the CLOSED bit, so stragglers stop. -That is the whole of it: two loads and one bit. No slot is touched, so +That is the whole of it: one load and one bit. No slot is touched, so sealing costs the same whether the channel holds one frame or ten million. The result, `ShmReader`, owns the mapping and lends out one `&[u8]` per @@ -169,7 +172,7 @@ CLAIMED (slot 0) -------------------------------> COMMITTED (readable) - Claiming is two atomic adds; committing is one store. Nothing retries, and no operation on the write path is a read-modify-write of a slot. -- Sealing is two loads and one bit, whatever the channel holds. Nothing is +- Sealing is one load and one bit, whatever the channel holds. Nothing is copied and nothing is allocated — the whole module is allocation-free; the reader reads the table to iterate. - On Linux, the first touch of the sparse backing file can cost diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs index ac7a38750..c8f23b57c 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs @@ -35,7 +35,8 @@ pub const CLOSED: u64 = 1 << 63; /// must start zeroed. The payload area is the rest of the mapping. #[repr(C)] pub struct Meta { - /// Bit 63 is the CLOSED gate; the low bits count claims ever attempted. + /// Bit 63 is the CLOSED gate; the low bits count the claims that got + /// as far as reserving payload space, which is where a slot is taken. pub claims: AtomicU64, /// Payload bytes ever reserved, including by failed claims. pub payload_reserved: AtomicU64, @@ -47,6 +48,13 @@ pub struct Meta { // so no field in `Meta` may need more alignment than that. const _: () = assert!(align_of::>() == align_of::()); +// The reader keeps a raw pointer into the table and reads through it long +// after the reference it came from is gone, which is sound only because +// every byte of `Meta` sits inside an atomic. This catches a field being +// added or padding appearing; it cannot catch a field changing type, so +// keep that in mind when editing the struct. +const _: () = assert!(size_of::>() == 5 * size_of::()); + // --- The descriptor slot codec --------------------------------------------- // // One slot is a 64-bit value that publishes a frame: @@ -97,13 +105,12 @@ const _: () = assert!(align_of::>() == align_of::()); // // Neither add is guarded against wrapping, because neither wrap can be // reached. The payload counter can only pass the region once a claim has -// failed, which sets the gate — and from then on every claim is refused -// at the claim counter, which is read after the reservation and before -// any span is built, so a wrapped payload counter never gets to name an -// offset. The claim count reaching the gate bit needs 2^63 claims, which -// is thousands of years at the rate they are measured to run; a channel -// lives for one command, and a gate that read as set by accident would -// only make the seal fail. +// failed, and from then on every claim is refused — by the bounds check +// on the reservation, or by the gate — before any span is built, so even +// a wrapped payload counter never gets to name an offset. The claim count +// reaching the gate bit needs 2^63 claims, which is thousands of years at +// the rate they are measured to run; a channel lives for one command, and +// a gate that read as set by accident would only make the seal fail. // // # Memory-ordering contract // diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs index f4140bd6d..bec5976c4 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs @@ -286,6 +286,9 @@ mod tests { // The refused reservation stays counted: four bytes of "test" // plus the 2048 that did not fit. assert!(shm.peek_u64(8) == 4 + 2048); + // Payload space is reserved before a slot is, so the refusal cost + // no slot: one claim counted, and the gate set. + assert!(shm.peek_u64(0) == (1 << 63) | 1); // "test" did land, but a lost record means the frames are not all // of them, so the seal hands back none of them. @@ -448,6 +451,24 @@ mod tests { assert!(sealed.unwrap_err() == SealError::Closed); } + /// A writer that gives up on a frame it already claimed, or before it + /// could claim one, has no failed claim to report the loss for it. + #[test] + fn a_reported_loss_fails_the_seal() { + let shm = MockedShm::alloc(1024); + // SAFETY: see `single_thread_basic`. + let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }.unwrap(); + assert!(writer.try_write_frame(b"kept")); + + assert!(!writer.is_closed()); + writer.report_lost_record(); + assert!(writer.is_closed()); + + // SAFETY: see `collect_frames`. + let sealed = unsafe { ShmReader::seal::(shm) }; + assert!(sealed.unwrap_err() == SealError::Closed); + } + #[test] fn claims_after_seal_are_gated_without_poisoning() { let shm = MockedShm::alloc(1024); diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs index 1008b0ecb..d93e90d0d 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs @@ -1,6 +1,6 @@ //! The reader side: seal the channel, then iterate the committed frames. //! -//! Sealing is two loads and one bit: it never walks the table and never +//! Sealing is one load and one bit: it never walks the table and never //! waits for writers. Reading is just as cheap — no payload byte is read //! or copied. The reader keeps the mapping alive and hands out each //! committed span on demand. Those borrows are safe because nothing diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs index 8553d8adc..7917f0f77 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs @@ -71,6 +71,20 @@ impl ShmWriter { Some(Self { mapped, _mem: mem }) } + /// Reports a record this writer could not write, which closes the + /// channel and fails its seal: whatever the receiver collects is no + /// longer all of them. + /// + /// A claim that fails for space reports itself. This is for the writer + /// that gives up for its own reasons — before it could claim, or after + /// claiming — and then goes on to perform the operation the record + /// described. Dropping the frame alone does not report anything: the + /// receiver cannot tell an abandoned slot from one whose writer died, + /// and a writer that died never performed its operation. + pub fn report_lost_record(&self) { + self.mapped.claims().fetch_or(CLOSED, Ordering::Relaxed); + } + /// Whether the CLOSED gate is set: the receiver sealed the channel, /// or an earlier claim failed and shut it down. pub fn is_closed(&self) -> bool { From 2d57f122d6842113e1dae1a26b4acc5032167351 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Mon, 17 Aug 2026 10:51:09 +0800 Subject: [PATCH 64/92] fix(fspy-shm): build the round-trip test's paths per platform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new sender test spelled its record paths as `&str`, which only becomes an `IpcPath` on unix — Windows carries UTF-16 and needs `from_wide`. My Windows lint had run with `--lib`, so it never compiled the tests and CI caught it instead. Co-Authored-By: Claude Fable 5 --- crates/fspy_shared/src/ipc/channel/mod.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/crates/fspy_shared/src/ipc/channel/mod.rs b/crates/fspy_shared/src/ipc/channel/mod.rs index 0c4248794..cd47cdb28 100644 --- a/crates/fspy_shared/src/ipc/channel/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/mod.rs @@ -378,7 +378,18 @@ mod tests { async fn sender_round_trips_records() { let (conf, receiver) = channel(GIB).unwrap(); let sender = conf.sender().unwrap(); - let paths = ["/tmp/one", "/tmp/two/three"]; + // A record path carries the platform's own string form: bytes on + // unix, UTF-16 on Windows. + #[cfg(unix)] + let owned = ["/tmp/one", "/tmp/two/three"]; + #[cfg(windows)] + let owned = [r"C:\tmp\one", r"C:\tmp\two\three"] + .map(|path| path.encode_utf16().collect::>()); + #[cfg(unix)] + let paths = owned.map(<&IpcPath>::from); + #[cfg(windows)] + let paths = [IpcPath::from_wide(&owned[0]), IpcPath::from_wide(&owned[1])]; + for path in paths { sender.send(&PathAccess::read(path)); } @@ -388,7 +399,7 @@ mod tests { let mut iter = frames.iter(); for path in paths { let access: PathAccess<'_> = wincode::deserialize_exact(iter.next().unwrap()).unwrap(); - assert!(access.path == <&IpcPath>::from(path)); + assert!(access.path == path); assert!(access.mode == AccessMode::READ); } assert!(iter.next().is_none()); From 954c9911d97da625a194d196d35179954a1442d6 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Mon, 17 Aug 2026 10:58:39 +0800 Subject: [PATCH 65/92] perf(fspy-shm): drop the pre-fault thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It warmed the region's first page on a background thread so the first claim would not pay for the backing file's first block allocation — a couple of milliseconds on Linux where the temporary directory is a journalling filesystem, and near nothing where it is tmpfs. That is once per channel, so once per task execution, against tasks that run for hundreds of milliseconds to minutes. It does not pay for a thread per channel and a Linux-only branch through channel creation. The benchmark's launch row will get much worse and should be read with that in mind: it times a target that opens nothing, so a two-millisecond constant is most of what it measures. The number to watch is whether the access rows move, and they should not. If the fault cost is ever worth removing rather than hiding, the way to do it is to stop putting an IPC region on a journalling filesystem. Co-Authored-By: Claude Fable 5 --- crates/fspy_shared/src/ipc/channel/mod.rs | 16 ------ .../src/ipc/channel/shm_io/README.md | 11 ++-- .../fspy_shared/src/ipc/channel/shm_io/mod.rs | 56 ------------------- 3 files changed, 5 insertions(+), 78 deletions(-) diff --git a/crates/fspy_shared/src/ipc/channel/mod.rs b/crates/fspy_shared/src/ipc/channel/mod.rs index cd47cdb28..84e55de05 100644 --- a/crates/fspy_shared/src/ipc/channel/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/mod.rs @@ -60,22 +60,6 @@ pub fn channel(capacity: usize) -> io::Result<(ChannelConf, Receiver)> { let keeper = ShmKeeper { path: shm_c_path }; let mapping = handle.map().map_err(shm_error_to_io)?; - // On Linux, the first touch of the sparse backing file — read or write — - // can cost milliseconds of journalled first-block allocation, and it - // would otherwise be paid by a sender's first record or by close's - // snapshot. Touch the header page through a second view concurrently - // with process startup instead. Only there: on Windows and macOS the - // first touch is cheap and the extra thread costs more than it saves. - // Best-effort — a channel without the pre-fault is merely slower. - #[cfg(target_os = "linux")] - if let Ok(prefault_mapping) = handle.map() { - let _ = std::thread::Builder::new().name("fspy-shm-prefault".into()).spawn(move || { - // SAFETY: the mapping views the region created zero-initialized - // above, which is only accessed through the `shm_io` protocol. - unsafe { shm_io::pre_fault::(&prefault_mapping) }; - }); - } - // Prove the region can host the protocol — the same fallible attach // senders perform — so a bad capacity fails the task now, not at its // first record. diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/README.md b/crates/fspy_shared/src/ipc/channel/shm_io/README.md index 1b366dd86..87f55e6c6 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/README.md +++ b/crates/fspy_shared/src/ipc/channel/shm_io/README.md @@ -175,12 +175,11 @@ CLAIMED (slot 0) -------------------------------> COMMITTED (readable) - Sealing is one load and one bit, whatever the channel holds. Nothing is copied and nothing is allocated — the whole module is allocation-free; the reader reads the table to iterate. -- On Linux, the first touch of the sparse backing file can cost - milliseconds on journalling filesystems (it is the fault path, not block - allocation — `fallocate` does not help). Creators should run `pre_fault` - off any latency-sensitive path — for example on a background thread, - concurrently with spawning the first writer. Windows and macOS fault - cheaply and skip this. +- On Linux, the first touch of the sparse backing file can cost a + millisecond or two on journalling filesystems — the fault path, not block + allocation, so `fallocate` does not help. It is paid once per channel, by + whichever side touches the region first. Putting the file on a filesystem + that does not journal avoids it at the source. ## Files diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs index bec5976c4..bdebb9a3e 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs @@ -75,12 +75,8 @@ mod reader; mod writer; use std::ptr::slice_from_raw_parts_mut; -#[cfg(target_os = "linux")] -use std::sync::atomic::Ordering; use fspy_shm::Mapping; -#[cfg(target_os = "linux")] -use layout::MappedLayout; // Only tests name the error types; production reports them through // `Display` and matches on `Ok`/`Err` alone. #[cfg(test)] @@ -107,35 +103,6 @@ impl AsRawSlice for &M { } } -/// Materializes the region's first page without changing protocol -/// state, so that neither a writer's first claim nor -/// [`ShmReader::seal`]'s snapshot pays for the backing file's first -/// block allocation — a millisecond-scale cost on some journalling -/// filesystems, for reads of holes as well as writes. Run it off any -/// latency-sensitive path. Only Linux channels use this: elsewhere the -/// first touch is cheap. -/// -/// # Safety -/// -/// Same contract as [`ShmWriter::new`]. -#[cfg(target_os = "linux")] -pub unsafe fn pre_fault(mem: &impl AsRawSlice) { - // Best effort: a region that cannot hold the protocol needs no - // warm-up — attaching to it will fail anyway. - // SAFETY: forwarded from this function's contract. - let Some(mapped) = (unsafe { MappedLayout::::new(mem.as_raw_slice()) }) else { - return; - }; - // A compare-exchange of zero with zero on the claim counter: on an - // untouched region it is a real write — which makes the file system - // allocate the first block of the sparse file — while leaving the - // counter as it was. If a claim got there first, the block already - // exists and the failed exchange changes nothing. (An `or` of zero - // would not do: the compiler may turn it into a plain load, which - // maps an empty page without allocating a block for it.) - let _ = mapped.claims().compare_exchange(0, 0, Ordering::Relaxed, Ordering::Relaxed); -} - #[cfg(test)] mod tests { use std::{ @@ -407,29 +374,6 @@ mod tests { assert!(iter.next() == None); } - #[cfg(target_os = "linux")] - #[test] - fn pre_fault_does_not_disturb_protocol_state() { - let shm = MockedShm::alloc(1024); - // SAFETY: see `single_thread_basic`. - let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }.unwrap(); - - // On the untouched region, before any claim. - // SAFETY: see `collect_frames`. - unsafe { pre_fault::(&shm) }; - assert!(writer.try_write_frame(b"foo")); - // Racing an already claimed region must change nothing either. - // SAFETY: see `collect_frames`. - unsafe { pre_fault::(&shm) }; - assert!(writer.try_write_frame(b"bar")); - - let frames = collect_frames(&shm); - let mut iter = frames.iter(); - assert!(iter.next().unwrap() == b"foo"); - assert!(iter.next().unwrap() == b"bar"); - assert!(iter.next() == None); - } - #[test] fn slot_capacity_failure_fails_the_seal() { // A 1024-byte region has a 15-slot table; the 16th claim must fail From 1ffd6e287f000d6e23c8ce1e24e0a66cae34c630 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Mon, 17 Aug 2026 11:00:55 +0800 Subject: [PATCH 66/92] refactor(fspy-shm): seal with one swap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seal read the claim counter and then set the gate in a second operation. A `swap` does both: it returns the count and shuts the gate in one step, so the boundary and the gate are one point in that counter's modification order rather than two with a window between them. Rule 1 loses the case it had to explain — the claim that lands after the snapshot but before the gate — because there is no longer anywhere for it to land. Replacing the count rather than or-ing into it is fine. Nothing reads it after a seal: a later claim fails on the gate before its slot index is used, and a later seal only tests the bit. Co-Authored-By: Claude Fable 5 --- .../src/ipc/channel/shm_io/layout.rs | 12 +++++------- .../src/ipc/channel/shm_io/reader.rs | 18 +++++++++--------- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs index c8f23b57c..eca416645 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs @@ -114,13 +114,11 @@ const _: () = assert!(size_of::>() == 5 * size_of::()); // // # Memory-ordering contract // -// 1. **Claim versus seal** — the seal boundary is a plain snapshot load -// of the claim counter: claims at or before it in the counter's -// modification order are in; later ones get slot indices the receiver -// never visits. Claims publish no payload data, so `Relaxed` -// suffices. The gate is not the boundary — it only stops late -// writers; a claim that gets in between the snapshot and the gate -// lands past the snapshot, where the receiver never looks. +// 1. **Claim versus seal** — the seal swaps the gate into the claim +// counter and reads the old value in one step, so the boundary and the +// gate are one point in that counter's modification order: claims at +// or before it are in, and every later one fails on the gate. Claims +// publish no payload data, so `Relaxed` suffices. // Completeness rides the same modification order: a failed claim sets // the gate before performing the operation whose record was lost, so // either the snapshot sees the bit or the loss happened after the diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs index d93e90d0d..d23dcdd70 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs @@ -101,10 +101,15 @@ impl ShmReader { return Err(SealError::UnsupportedRegion); }; - // The seal boundary (rule 1): claims at or before this snapshot - // are in, later ones land in slots iteration never reaches. - let claims = mapped.claims().load(Ordering::Relaxed); - // The same load says whether anything was lost. The gate is + // Draw the boundary and shut the gate in one step (rule 1): what + // this returns is the claim count at the instant no later claim + // can succeed. Claims already in flight land in slots iteration + // never reaches. Replacing the count rather than keeping it is + // fine — from here on nothing reads it, since a later claim fails + // on the gate before its slot index is used, and a later seal + // only tests the bit. + let claims = mapped.claims().swap(CLOSED, Ordering::Relaxed); + // The same value says whether anything was lost. The gate was // already set: either a claim failed — and rule 1 puts that loss // before this boundary — or someone sealed earlier and this seal // cannot say what was refused since. No complete set to read. @@ -115,11 +120,6 @@ impl ShmReader { // walking the whole table, not an error. let slot_count = to_usize(claims).min(SLOTS); - // Shut the gate, so late writers stop claiming slots and touching - // new pages. A claim that slips in between is dropped safely - // (rule 1). - mapped.claims().fetch_or(CLOSED, Ordering::Relaxed); - // The admitted slots, kept as a raw pointer so the reader needs // no lifetime and no `SLOTS`. // SAFETY of every later read through it: it points into the From 3b7a5435dae5039ca944abbfe97c45ac7ff3e867 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Mon, 17 Aug 2026 11:12:51 +0800 Subject: [PATCH 67/92] docs(fspy-shm): cut the protocol docs down The README and the module docs told the same story twice, at length. The README keeps it; `mod.rs` now says what the module is, states the one rule its users must follow, and points at the README for the rest. Cut from the README: the argument against designs nobody wrote, the lifecycle diagram that repeated the paragraph above it, and a performance section restating mechanics already covered. `layout.rs` loses its account of the counters, which the README gives, and keeps the ordering contract the code cites by rule number. Rewrote the prose throughout: active voice, no em dashes, fewer adverbs, and the specific thing named instead of gestured at. Co-Authored-By: Claude Fable 5 --- .../src/ipc/channel/shm_io/README.md | 286 ++++++++---------- .../src/ipc/channel/shm_io/layout.rs | 157 ++++------ .../fspy_shared/src/ipc/channel/shm_io/mod.rs | 102 ++----- .../src/ipc/channel/shm_io/reader.rs | 78 +++-- .../src/ipc/channel/shm_io/writer.rs | 76 +++-- 5 files changed, 273 insertions(+), 426 deletions(-) diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/README.md b/crates/fspy_shared/src/ipc/channel/shm_io/README.md index 87f55e6c6..d62f1438e 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/README.md +++ b/crates/fspy_shared/src/ipc/channel/shm_io/README.md @@ -3,194 +3,147 @@ One shared-memory region. Many writer processes append variable-length records; one receiver collects them once, when the channel's lifetime ends. -Three requirements shaped everything here: - -1. **A writer may die at any instruction** — killed, crashed, anywhere. - This must never corrupt the channel or lose another writer's records. -2. **A writer may outlive the channel.** The receiver must never wait for - writers; sealing is immediate. -3. **The receiver must know whether it got everything.** Either the frames - hold every record writers published, or sealing fails and hands back - nothing. Never a silently short result. - -Simpler designs fail these. A lock that writers hold while active proves -"no writers left" only as long as every writer manages the lock correctly — -one process dropping it early lets the reader race live writes and parse -half-written bytes. Waiting for writers to finish hangs forever on a -writer that never exits. Counting active writers breaks because a killed -process never decrements the count. +Three requirements shape the design: + +1. **A writer may die at any instruction.** That must not corrupt the + channel or cost another writer its records. +2. **A writer may outlive the channel.** The receiver never waits for one. +3. **The receiver must know whether it got everything.** Either it gets + every record writers published, or sealing fails and it gets none. ## The region -The region is any zero-initialized shared memory — in practice a sparse -file mapped into every participating process. A sparse mapping is address -space, not memory: only pages that are actually written get backed. +Any zero-initialized shared memory works. In practice it is a sparse file +mapped into every participating process, so only the pages someone writes +cost real memory. ```text | counters | descriptor table (SLOTS slots) | payloads (the rest, grow up) | ``` -The region starts with one `repr(C)` struct: two `AtomicU64` counters -followed by one 8-byte descriptor slot per frame. The table length is a compile-time constant the channel picks -once for both ends: - -- the **claim counter** — how many frames were ever claimed. Bit 63 is - the CLOSED gate, set by the receiver when it closes the channel — and - by any writer whose claim failed, which is how the receiver learns - that a record was lost. -- the **payload counter** — how many payload bytes were ever reserved. - -The payload area is simply the rest of the mapping, so the whole -geometry reduces to one number — the struct's size — and attaching -checks a single bound: the struct must fit inside the mapping, leaving a -payload area the descriptors' 32-bit offsets can address. The production -channel uses ~67 million slots in a 4 GiB region; the ~3.5 GiB payload -area holds ~15–20 million records of a few hundred bytes, so payload -space runs out first. - -The line between table space and payload space never moves. That is what -keeps claiming free of retry loops: each counter is checked against a -limit that never changes, using the value `fetch_add` returned, and -overshooting a limit is harmless because nothing ever locates data through -a counter — a committed descriptor carries its own offset and length. +One `repr(C)` struct holds two `AtomicU64` counters followed by one 8-byte +descriptor slot per frame. The channel fixes the table length at compile +time for both ends. + +- The **claim counter** counts frames ever claimed. Bit 63 is the CLOSED + gate: the receiver sets it when it seals, and so does any writer whose + claim failed, which is how the receiver hears about a lost record. +- The **payload counter** counts payload bytes ever reserved. + +Payloads take the rest of the mapping, so that struct's size is the whole +geometry. Attaching checks one bound: the struct fits, and what remains is +small enough for the descriptors' 32-bit offsets. The production channel +takes ~67 million slots out of 4 GiB and leaves ~3.5 GiB of payload room, +enough for 15 to 20 million records of a few hundred bytes. Payload space +runs out first. + +Where the table ends and payloads begin never moves. Claiming needs no +retry loop, because a writer checks each counter against a fixed limit +using the value `fetch_add` returned. Overshooting a limit costs nothing: +no counter says where data is, since every committed descriptor carries its +own offset and length. ## Writing a frame -Three steps: - -1. **Claim.** Two `fetch_add`s — one reserves payload bytes, one reserves a - slot. No retry loop, no lock. A claim that does not fit fails after the - fact and sets the CLOSED gate before the writer moves on. -2. **Fill.** The writer serializes into its payload span. The span is - exclusively its own; nobody else knows it exists yet. -3. **Commit.** One store puts a descriptor holding the payload's offset - and length into the frame's slot. Only the writer that claimed the slot - ever writes it, so no compare-and-swap is needed. Before the store the - receiver cannot see the frame at all; after it, the frame is visible and - its payload never changes again. - -Committing is explicit (`FrameMut::finish`). What happens when it never -runs is the heart of the design: - -- **The process died** — mid-claim, mid-fill, anywhere. The slot stays - zero. The receiver ignores it. Nothing else is affected, and no cleanup - code ever runs or is needed. -- **The process is alive but abandoned the frame** (dropped it without - finishing). The slot stays zero and the receiver ignores it, exactly as if - the writer had died there — the receiver cannot tell the two apart. So a - writer that gives up on a frame and then performs the action anyway has to - say so, with `ShmWriter::report_lost_record`. - -These rules assume one thing about how the channel is used: **a writer -publishes a record before performing the action the record describes.** -Then a dead writer's missing record describes an action that never -happened, and a record refused after the seal describes an action performed -after the channel closed — both safe to ignore. A writer that records -_after_ acting steps outside this rule and loses records silently. One that -gives up on a frame and acts anyway must report the loss, which closes the -channel and fails its seal. +1. **Claim.** Two `fetch_add`s reserve payload bytes and a slot. No retry + loop, no lock. A claim that does not fit fails after the fact, and sets + the CLOSED gate before the writer moves on. +2. **Fill.** The writer serializes into its payload span, which nobody else + knows exists. +3. **Commit.** One store puts the payload's offset and length into the + slot. Only the writer that claimed a slot ever writes it, so it needs no + compare-and-swap. The receiver cannot see the frame before that store, + and nobody touches the payload after it. + +`FrameMut::finish` commits. What happens when it never runs is the heart of +the design: + +- **The process died,** mid-claim or mid-fill. The slot stays zero and the + receiver ignores it. No cleanup code runs, because none exists. +- **The process abandoned the frame** and kept going. The slot stays zero + and the receiver ignores that too, since it cannot tell the two apart. + +So the channel asks one thing of its users: **publish a record before +performing the action it describes.** A dead writer's missing record then +describes an action that never happened, and a record refused after the +seal describes one performed after the channel closed. The receiver drops +both. A writer that records after acting loses records with nothing said. A +writer that abandons a frame and acts anyway calls +`ShmWriter::report_lost_record`. ## When the region fills up -The region is large — the payload area of a 4 GiB region holds tens of -millions of records — but it is not endless. When a claim asks for more -room than is left, in the payload area or in the table, the claim fails. -The writer skips that one record and carries on: recording must never -stop or crash the program doing the work. - -The loss is not silent. Before moving on, the failed claim sets the -CLOSED gate — the same bit the receiver sets when it seals. When the -receiver seals the channel it reads the bit once; if it was already set, -sealing fails and no frames are handed out at all. - -Setting the bit before moving on matters for the same reason committing -a record before acting does. If the receiver's read misses the bit, the -bit was set after the seal — so the skipped record describes an action -performed after the channel closed, which the receiver never promised to -include. And a writer that dies before setting the bit never performed -its action, so nothing was actually lost. - -Because the bit is also the gate, the first lost record closes the -channel: every later claim is refused. That refusal costs nothing — the -result is already doomed, so any further records would only be added to -something nobody can use. - -One more limit: a single frame holds at most `u32::MAX` bytes, because a -descriptor cannot describe more. Such a claim is refused — and reported — -the same way. - -## Sealing and reading +A 4 GiB region holds tens of millions of records, but not endless ones. +When a claim asks for more room than the payload area or the table has +left, it fails. The writer skips that record and carries on, because +recording must never stop the program doing the work. -The receiver seals the channel once: +The loss is not silent. The failed claim sets the CLOSED gate before it +returns, and a receiver that finds that bit already set fails its seal and +hands back nothing. -1. **Snapshot** the claim counter with a plain load. This is the boundary: - claims at or before it are in, later ones are not. If the CLOSED bit is - already set, a record was lost or someone sealed earlier, and the seal - fails right here — a partial set of frames is never handed out. -2. **Gate** further claims by setting the CLOSED bit, so stragglers stop. +Setting the bit first matters for the same reason publishing before acting +does. If the receiver's read misses the bit, the writer set it after the +seal, so the skipped record describes an action performed after the channel +closed. And a writer that died before setting it never performed its +action. -That is the whole of it: one load and one bit. No slot is touched, so -sealing costs the same whether the channel holds one frame or ten million. +Because the bit is also the gate, the first lost record closes the channel +and every later claim is refused. Those records would only pile up in a +result nobody can use. -The result, `ShmReader`, owns the mapping and lends out one `&[u8]` per -committed span, straight from shared memory — no copy. It reads the table -when asked, so a writer that was still filling a frame when the line was -drawn may show up in a later read and not an earlier one. The borrows are -sound because a committed span is never written again and is disjoint from -everything a live straggler may still touch. The mapping is released when -the reader is dropped. +A single frame holds at most `u32::MAX` bytes, since a descriptor cannot +describe more. Such a claim is refused and reported the same way. -```text - writer finishes the frame -CLAIMED (slot 0) -------------------------------> COMMITTED (readable) - | - +-- writer dies or gives the claim up ---> stays zero (ignored) -``` +## Sealing and reading -## Why this is sound, in one list - -- Finding frames never involves reading payload bytes; every slot has a - fixed place. A half-written payload can never be mistaken for metadata. -- A payload is reachable only through its committed descriptor. The commit - is a `Release` write and the receiver's load is an `Acquire` read, so a - descriptor the receiver sees brings its payload bytes with it. -- One writer owns each slot and writes it once, so a slot goes from zero to - committed and never changes again. -- No counter has to be exact: a refused claim sets the CLOSED gate — - failing the seal and refusing every later claim — - and leaves its increments where they are. Both counters only ever climb, - which costs nothing: neither one says where data is. The receiver still - clamps its snapshot to the fixed capacities, so even a scribbled counter - just means walking extra empty slots. -- A committed descriptor names the span its writer reserved and checked, - so the receiver builds its borrows from it without re-checking. That - trusts the other processes to follow the protocol — one that scribbles - random memory is outside the model — and it is why the receiver can read - frames straight out of shared memory, with no copies and no checksums. - -## Performance notes - -- Claiming is two atomic adds; committing is one store. Nothing retries, - and no operation on the write path is a read-modify-write of a slot. -- Sealing is one load and one bit, whatever the channel holds. Nothing is - copied and nothing is allocated — the whole module is allocation-free; - the reader reads the table to iterate. -- On Linux, the first touch of the sparse backing file can cost a - millisecond or two on journalling filesystems — the fault path, not block - allocation, so `fallocate` does not help. It is paid once per channel, by - whichever side touches the region first. Putting the file on a filesystem - that does not journal avoids it at the source. +Sealing swaps the CLOSED gate into the claim counter and reads the old +value. That one operation draws the boundary and shuts the gate: claims at +or before it are in, every later one fails. If the bit was already set, a +record was lost or someone sealed earlier, and sealing fails here. + +No slot is touched, so sealing a channel holding ten million frames costs +what sealing an empty one costs. + +`ShmReader` owns the mapping and lends out one `&[u8]` per committed span, +straight from shared memory. It reads the table when asked, so a writer +still filling a frame when the boundary was drawn may appear in a later +read and not an earlier one. Dropping the reader releases the mapping. + +## Why this is sound + +- Finding frames never reads payload bytes, and every slot sits at a fixed + place, so a half-written payload cannot be mistaken for metadata. +- The receiver reaches a payload only through its committed descriptor. The + writer commits with `Release` and the receiver loads with `Acquire`, so a + descriptor the receiver sees brings its payload bytes along. +- One writer owns each slot and writes it once. A slot goes from zero to + committed and stops. +- No counter has to be exact. Both only climb, refused claims leave their + increments behind, and the receiver clamps its snapshot to the table + length, so a scribbled counter costs it a walk over empty slots. +- The receiver builds its borrows from a committed descriptor without + re-checking it. That trusts the other processes to follow the protocol; + one that scribbles random memory is outside the model. It is also why the + receiver reads frames straight out of shared memory, with no copies and + no checksums. + +## Deployment note + +On Linux, the first touch of the sparse backing file costs a millisecond or +two on journalling filesystems. It is the fault path rather than block +allocation, so `fallocate` does not help. Whichever side touches the region +first pays it, once per channel. A filesystem that does not journal avoids +it. ## Files -| File | Role | -| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `mod.rs` | Public surface (`ShmWriter`, `ShmReader`), the protocol overview docs, and the integration tests — they run against a mocked region and are miri-clean (`cargo miri test -p fspy_shared shm_io`). | -| `writer.rs` | The writer side: claim a frame, fill it, finish it. | -| `reader.rs` | The reader side: seal the channel, then iterate the committed frames — with the argument for why its borrows are sound. | -| `layout.rs` | Only what both sides share: the region's `repr(C)` shape — counters, then slots — the descriptor format, and `MappedLayout`, that shape bound to one concrete mapping. Encoding lives with the writer, decoding with the reader. | - -Arrows point at what a file depends on: +| File | Role | +| ----------- | ------------------------------------------------------------------------------------------------------------- | +| `mod.rs` | Public surface, and integration tests over a mocked region (`cargo miri test -p fspy_shared shm_io`). | +| `writer.rs` | Claim a frame, fill it, finish it. | +| `reader.rs` | Seal the channel, then iterate committed frames, with the argument for why its borrows hold. | +| `layout.rs` | What both sides share: the `repr(C)` shape, the descriptor format, the ordering contract, and `MappedLayout`. | ```mermaid graph TD @@ -200,5 +153,4 @@ graph TD reader --> layout ``` -Read from the bottom up — `layout.rs`, then `writer.rs` and `reader.rs`, -then `mod.rs` — and each file only needs the ones below it. +Each file needs only the ones below it, so read from `layout.rs` upward. diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs index eca416645..d0178a81d 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs @@ -1,44 +1,34 @@ -//! Everything the writer and the reader sides share. +//! What the writer and reader sides share: the region's `repr(C)` shape, +//! the descriptor format, and [`MappedLayout`], which locates the parts of +//! one mapping. The sides live in [`super::writer`] and [`super::reader`]; +//! `README.md` describes the region itself. //! -//! The region is divided into three fixed areas: -//! -//! ```text -//! | counters | descriptor table | payloads (grow up) | -//! ``` -//! -//! Counters and table are one `repr(C)` [`Meta`] struct whose table -//! length is a const parameter the channel specifies; the payload area is -//! the rest of the mapping, so the whole geometry is that struct's size. -//! This module holds the struct, the slot wire format, and -//! [`MappedLayout`]: where the parts of one mapping are, worked out once -//! at attach. The sides live in [`super::writer`] and [`super::reader`]. -//! -//! Overflow safety: the payload area is never longer than `u32::MAX` -//! bytes (checked at attach), so an offset and a length always fit a -//! descriptor's 32-bit fields, and bounds checks add them in 32 bits. +//! The payload area never exceeds `u32::MAX` bytes, checked at attach, so +//! an offset and a length always fit a descriptor's 32-bit fields and +//! bounds checks add them in 32 bits. use std::{num::NonZeroU32, ptr::NonNull, sync::atomic::AtomicU64}; -/// The CLOSED gate bit of the claim counter: set when the receiver seals -/// the channel, and by any failed claim as its loss report (rule 1). +/// The CLOSED gate bit of the claim counter. The receiver sets it when it +/// seals, and so does any failed claim, which is how it reports the loss +/// (rule 1). /// -/// A bit, not a value to compare against, so it survives the increment of -/// a writer that arrives late. Counting cannot realistically reach it: at -/// the rate claims are measured to run, 2^63 of them take thousands of -/// years, and a channel lives for one command. If it ever did happen the -/// gate would read as set — no more records, and the seal fails, which is -/// the cautious answer rather than a wrong one. +/// A bit rather than a value to compare against, so it survives the +/// increment of a writer that arrives late. Counting cannot reach it: that +/// takes 2^63 claims, and a channel lives for one command. A gate that +/// somehow read as set would fail the seal, which is the cautious +/// answer. pub const CLOSED: u64 = 1 << 63; -/// The part of the region that is always in the same place — the -/// counters and the descriptor table — as one `repr(C)` struct, which -/// must start zeroed. The payload area is the rest of the mapping. +/// The part of the region that never moves: the counters and the +/// descriptor table, as one `repr(C)` struct that starts zeroed. Payloads +/// take the rest of the mapping. #[repr(C)] pub struct Meta { - /// Bit 63 is the CLOSED gate; the low bits count the claims that got - /// as far as reserving payload space, which is where a slot is taken. + /// Bit 63 is the CLOSED gate. The low bits count claims that got as + /// far as reserving payload space, which is where a slot is taken. pub claims: AtomicU64, - /// Payload bytes ever reserved, including by failed claims. + /// Payload bytes ever reserved, failed claims included. pub payload_reserved: AtomicU64, /// One descriptor slot per frame. pub table: [AtomicU64; SLOTS], @@ -77,68 +67,41 @@ const _: () = assert!(size_of::>() == 5 * size_of::()); // Offsets are counted from the start of the payload area, so a descriptor // can never point into the counters or the table. -// --- The mapped layout and the ordering contract --------------------------- -// `MappedLayout::new` works out both pointers once, at attach: one to the -// `Meta` struct, one to the payload area. The endpoint keeps them next to -// the mapping they point into. The payload pointer stays raw because -// writers hand out `&mut` slices into it, which must not overlap a shared -// reference. -// -// # Shared atomics -// -// Two independent monotonic `AtomicU64` counters: -// -// - the **claim counter**: bit 63 is the CLOSED gate, the low bits count -// claims. One increment per claim, and the old value it returns says -// everything the writer needs: which slot it got, whether the channel -// is closed, and whether the table is full. The seal sets the gate, and -// so does every failed claim: the one bit both reports the loss and -// stops later writers doing work for a result the receiver must -// already reject. -// - the **payload counter**: payload bytes reserved, one increment. -// -// Both counters only ever climb, and a refused claim leaves its increment -// behind. That costs nothing: neither counter says where data is — each -// descriptor carries its own offset and length — so an inflated one -// points nothing at the wrong bytes. The receiver clamps its snapshot to -// the table length rather than trusting it. +// --- MappedLayout and the ordering contract -------------------------------- +// `MappedLayout::new` works out both pointers once, at attach, and the +// endpoint keeps them beside the mapping. The payload pointer stays raw +// because writers hand out `&mut` slices into it, which must not overlap a +// shared reference. // -// Neither add is guarded against wrapping, because neither wrap can be -// reached. The payload counter can only pass the region once a claim has -// failed, and from then on every claim is refused — by the bounds check -// on the reservation, or by the gate — before any span is built, so even -// a wrapped payload counter never gets to name an offset. The claim count -// reaching the gate bit needs 2^63 claims, which is thousands of years at -// the rate they are measured to run; a channel lives for one command, and -// a gate that read as set by accident would only make the seal fail. +// Neither counter guards its add against wrapping, because neither wrap is +// reachable. The payload counter passes the region only after a claim has +// failed, and every claim after that is refused before it builds a span. +// The claim count needs 2^63 increments to reach the gate bit. // // # Memory-ordering contract // -// 1. **Claim versus seal** — the seal swaps the gate into the claim -// counter and reads the old value in one step, so the boundary and the -// gate are one point in that counter's modification order: claims at -// or before it are in, and every later one fails on the gate. Claims -// publish no payload data, so `Relaxed` suffices. -// Completeness rides the same modification order: a failed claim sets -// the gate before performing the operation whose record was lost, so -// either the snapshot sees the bit or the loss happened after the -// boundary; a writer that skipped on seeing the bit is covered the -// same way; one that died before setting it never performed its -// operation, so nothing was lost. -// 2. **Writer commit** — `FrameMut::finish` stores the descriptor with -// `Release`: every payload write happens-before it can be seen. The -// writer that claimed the slot is the only one that ever writes it, so -// a plain store is enough. -// 3. **Receiver read** — `Iter` loads each descriptor with `Acquire`, so -// a descriptor it sees brings the payload bytes with it. +// 1. **Claim versus seal.** The seal swaps the gate into the claim counter +// and reads the old value in one step, so the boundary and the gate are +// one point in that counter's modification order: claims at or before +// it are in, every later one fails on the gate. Claims publish no +// payload data, so `Relaxed` suffices. Completeness rides the same +// order: a failed claim sets the gate before performing the operation +// whose record it lost, so either the seal sees the bit or the loss +// happened past the boundary. A writer that died before setting it +// never performed its operation. +// 2. **Writer commit.** `FrameMut::finish` stores the descriptor with +// `Release`, so every payload write lands first. The writer that +// claimed the slot is the only one that writes it, so a store is +// enough. +// 3. **Receiver read.** `Iter` loads each descriptor with `Acquire`, so a +// descriptor it sees brings the payload bytes along. /// Converts an integer into a `usize`. /// -/// Never loses bits: the argument has to fit a `u64`, which is what the -/// bound says, and the assert lets only targets whose `usize` is 64 bits -/// build this module. That assert is the only reason the cast is safe, -/// so it sits here rather than at module scope — and this is the only -/// `as` in the protocol. +/// Never loses bits: the bound takes only what fits a `u64`, and the +/// assert lets only 64-bit targets build this module. That assert is why +/// the cast is safe, so it sits here rather than at module scope. It is +/// the only `as` in the protocol. #[expect(clippy::cast_possible_truncation, reason = "the assert allows only equal widths")] pub fn to_usize(value: impl Into) -> usize { const { assert!(size_of::() == size_of::(), "requires a 64-bit target") }; @@ -154,20 +117,18 @@ fn try_cast_aligned(ptr: *mut T) -> Option<*mut U> { if ptr.addr().is_multiple_of(align_of::()) { Some(ptr.cast()) } else { None } } -/// Where the two parts of the region are: the fixed [`Meta`] struct and -/// the payload area. Worked out once at attach and kept in the endpoint. -/// The pointers stay valid as long as the mapping does, because they -/// point into the mapped memory, not into the endpoint holding them. +/// Where the [`Meta`] struct and the payload area sit in one mapping, +/// worked out once at attach. The pointers outlive this value, since they +/// point into the mapping rather than into the endpoint holding them. #[derive(Clone, Copy)] pub struct MappedLayout { meta: NonNull>, - /// Start of the payload area. A raw pointer, not a reference: - /// writers hand out `&mut` slices into it, which must not overlap a - /// shared reference. + /// Start of the payload area, raw rather than a reference: writers + /// hand out `&mut` slices into it, which must not overlap a shared + /// reference. pub payload_start: NonNull, - /// Length of the payload area. A `u32`, the width of a descriptor's - /// offset and length, so the writer's bounds check needs no - /// conversion. + /// Length of the payload area, in the width of a descriptor's offset, + /// so the writer's bounds check needs no conversion. pub payload_len: u32, } @@ -184,8 +145,8 @@ pub enum SlotState { /// Byte offset of the payload from the start of the payload region. offset: u32, /// Byte length of the payload. Nonzero, so a committed value is - /// never the zero a fresh slot starts at — the offset alone could - /// be zero. + /// never the zero a fresh slot starts at, which the offset alone + /// could be. len: NonZeroU32, }, } diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs index bdebb9a3e..64079eb80 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs @@ -1,74 +1,22 @@ //! A crash-tolerant, nonblocking frame channel in a shared memory region. //! -//! Many writer processes append variable-length frames at once; one -//! receiver closes the channel and collects every committed frame without -//! waiting for any writer. A process may die at any instruction — while -//! claiming, while writing, just before committing — and the only thing -//! lost is its own unfinished frame. +//! Many writer processes append variable-length frames at once. One +//! receiver seals the channel and reads every committed frame without +//! waiting for any of them. A writer may die at any instruction and lose +//! only its own unfinished frame. No writer runs cleanup code, because +//! none exists: no exit hooks, PID checks, heartbeats, or timeouts. //! -//! `README.md` in this directory tells the whole story in plain words and -//! indexes the modules. +//! The channel asks one thing of its writers: **publish a record before +//! performing the action it describes.** A record that never arrives then +//! describes an action that never happened, and one refused after the seal +//! describes an action performed after the receiver stopped collecting. +//! [`ShmWriter::report_lost_record`] covers the case left over, where a +//! writer gives up on a record and acts anyway. //! -//! # Region layout -//! -//! ```text -//! low addresses high addresses -//! +----------+--------+--------+---------+-----------+-----------+------+ -//! | counters | slot 0 | slot 1 | ... | payload 0 | payload 1 | ... | -//! +----------+--------+--------+---------+-----------+-----------+------+ -//! fixed descriptor table payloads grow up -> -//! ``` -//! -//! Counters and table are one `repr(C)` struct whose table length is a -//! compile-time constant both sides share (the channel picks it); the -//! payload area is the rest of the mapping ([`layout`]). Attaching works -//! out where the struct and the payload area start; the payload area -//! stays plain bytes. -//! -//! A claim is two wait-free `fetch_add`s — one reserves payload bytes, -//! one a descriptor slot — and the old values they return are what the -//! writer checks against the region's fixed size. A failed claim sets the -//! CLOSED gate to report the loss and leaves its increments where they -//! are: both counters only ever climb. Each descriptor carries its own -//! offset and length, so the counters never say where data is, and every -//! slot sits at a fixed place, so an unfinished frame can never hide a -//! later one. -//! -//! # Frame lifecycle -//! -//! ```text -//! writer finishes the frame -//! CLAIMED (slot 0) ---------------------------------> COMMITTED (readable) -//! | -//! +---- writer dies or gives the claim up ---> stays zero (ignored) -//! ``` -//! -//! The only way to reach a payload is through its committed descriptor, -//! and a descriptor is committed only after the payload is fully written -//! ([`layout`]'s ordering contract). The receiver never works out where a -//! frame is by reading payload bytes, and the borrows [`ShmReader`] hands -//! out cover exactly the spans their writers reserved — nothing writes to -//! them any more, and they never overlap what a live writer may touch. -//! -//! # Seal boundary -//! -//! [`ShmReader::seal`] draws its line by taking a snapshot of the claim -//! counter and then shutting the gate. It walks no slots: a frame is read -//! if its descriptor is there when the reader looks at it, so a writer -//! still filling a frame when the line was drawn may land on either side. -//! A claim taken after the snapshot lands in a slot the reader never -//! reaches. Both are safe because a writer writes its record *before* -//! doing what the record describes: one that died mid-frame never did it, -//! and one that claimed or committed after the snapshot does it after the -//! receiver stopped collecting. -//! -//! A record refused *before* the seal — no room, oversized frame — sets -//! the gate first, so every later claim is refused and the seal itself -//! fails: one lost record already ruins the result, and a partial set of -//! frames is never handed out. -//! -//! None of this needs writers to clean up after themselves: no exit -//! hooks, PID checks, heartbeats, or timeouts. +//! `README.md` in this directory describes the region, the claim sequence, +//! and why the receiver can borrow frames out of shared memory. [`layout`] +//! carries the memory-ordering contract that the code cites by rule +//! number. mod layout; mod reader; @@ -272,7 +220,7 @@ mod tests { assert!(writer.try_write_frame(b"kept")); // No descriptor can describe a frame this long: the claim is - // refused and sets the gate, which shuts out later claims — their + // refused and sets the gate, which shuts out later claims: their // records would ride on a result the receiver must already // reject. let oversized = (layout::to_usize(u32::MAX) + 1).try_into().unwrap(); @@ -427,7 +375,7 @@ mod tests { assert!(iter.next() == None); // The seal set the gate: a late writer's claim fails cleanly and - // does not mark the channel incomplete — that record belongs + // does not mark the channel incomplete, since that record belongs // after the receiver stopped collecting. assert!(writer.is_closed()); let before = shm.peek_u64(0); @@ -448,7 +396,7 @@ mod tests { frame.copy_from_slice(b"late!"); // The receiver seals while the frame is still unfinished: nothing - // to show yet, and nothing lost either — the writer has not + // to show yet, and nothing lost either, since the writer has not // performed the operation this record describes. let frames = collect_frames(&shm); assert!(frames.iter().count() == 0); @@ -517,7 +465,7 @@ mod tests { }); // The table holds 15 slots and 120 writes were attempted, so some - // had to fail on capacity — and one failure is enough to fail the + // had to fail on capacity, and one failure is enough to fail the // seal. The writers all survived it. assert!(writer.is_closed()); // SAFETY: see `collect_frames`. @@ -571,7 +519,7 @@ mod tests { assert!(frame == b"hello"); } // Claims taken after the boundary are never read, so the count can - // fall short of what the writers wrote — but nothing can appear + // fall short of what the writers wrote, but nothing can appear // that was never finished. let written: usize = results.into_iter().sum(); assert!(count <= written); @@ -586,10 +534,10 @@ mod tests { let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }.unwrap(); assert!(writer.try_write_frame(b"hello")); - // A wildly inflated claim counter — mass claim failures or a foreign - // scribble — degrades to a full-table sweep, never out-of-bounds - // slot access: the committed frame survives, the untouched slots - // freeze as unpublishable. + // A wildly inflated claim counter, from mass claim failures or a + // foreign scribble, degrades to a full-table sweep rather than an + // out-of-bounds slot access: the committed frame survives and the + // untouched slots read as unpublished. shm.poke_u64(0, (1 << 40) | 1); let frames = collect_frames(&shm); @@ -692,7 +640,7 @@ mod tests { } /// A writer killed mid-frame (SIGKILL on Unix, `TerminateProcess` on - /// Windows — both via `Child::kill`) must not lose other writers' frames + /// Windows, both via `Child::kill`) must not lose other writers' frames /// or completeness: no cleanup code runs in the killed process. #[test] #[cfg(not(miri))] diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs index d23dcdd70..f34e0f892 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs @@ -1,17 +1,16 @@ //! The reader side: seal the channel, then iterate the committed frames. //! -//! Sealing is one load and one bit: it never walks the table and never -//! waits for writers. Reading is just as cheap — no payload byte is read -//! or copied. The reader keeps the mapping alive and hands out each -//! committed span on demand. Those borrows are safe because nothing -//! writes to a committed span again (committing uses up the writer's -//! frame) and it never overlaps what a live writer may still touch. +//! Neither step walks the table or waits for a writer, and no payload byte +//! is copied. The reader keeps the mapping alive and hands out each +//! committed span on demand. Those borrows hold because nothing writes to +//! a committed span again (committing uses up the writer's frame) and it +//! never overlaps what a live writer may touch. //! -//! A span's offset and length come from the writer that reserved them, -//! and nothing here re-checks them. That rests on the promise made at -//! attach — that the region is touched only through this protocol. A -//! process that scribbles on it some other way breaks that promise, and -//! this code does not defend against it. +//! A span's offset and length come from the writer that reserved them, and +//! nothing here re-checks them. That rests on the promise made at attach: +//! only this protocol touches the region. A process that scribbles on it +//! some other way breaks the promise, and this code does not defend +//! against that. use std::{ fmt, @@ -33,19 +32,17 @@ pub enum SealError { /// [`MappedLayout::new`]). #[error("the shared-memory region cannot host the channel")] UnsupportedRegion, - /// The channel was already closed when the seal ran: a claim had - /// failed — no room left, or an oversized frame — or someone sealed it - /// earlier and this seal cannot say what was refused since. Either - /// way the frames are not all of them, so there are none to hand out. + /// The channel was already closed when the seal ran. A claim had + /// failed, or someone sealed earlier and this seal cannot say what was + /// refused since. Either way the frames are not all of them, so the + /// reader hands back none. #[error("the shared-memory channel was closed before it was sealed")] Closed, } /// A reader over the committed frames of a sealed channel, serving them -/// straight out of the mapping, which stays alive inside this value and -/// is released when the reader drops. It holds no buffer of its own: -/// iterating reads the descriptor table, so sealing a channel allocates -/// nothing and touches nothing. +/// straight out of the mapping. It holds no buffer of its own, so sealing +/// allocates nothing; dropping it releases the mapping. pub struct ShmReader { /// Where the payload area is, for turning descriptors into spans. payload_start: NonNull, @@ -65,19 +62,18 @@ unsafe impl Send for ShmReader {} unsafe impl Sync for ShmReader {} impl ShmReader { - /// Seals the channel — no further records — and returns the reader of + /// Seals the channel against further records and returns the reader of /// its committed frames. /// - /// A reader exists only for a channel that kept everything: if a claim - /// had already failed, or the channel was already sealed, there is no - /// complete set of records to read and this fails instead. + /// A reader exists only for a channel that kept everything. If a claim + /// had already failed, or someone sealed earlier, there is no complete + /// set of records and this fails instead. /// - /// Never waits for writers, and never walks the table: it takes the - /// snapshot that fixes how far iteration goes, then shuts the gate so - /// no further claim is taken. A claim from before the snapshot that - /// commits later shows up if it lands before the read that looks for - /// it; one taken after the snapshot lands in a slot iteration never - /// reaches. + /// One atomic operation fixes how far iteration goes and shuts the + /// gate, so it neither waits for a writer nor walks the table. A claim + /// taken before that point but committed after it shows up if the + /// store lands before the read reaches its slot; one taken after it + /// lands in a slot iteration never reaches. /// /// # Safety /// @@ -94,25 +90,23 @@ impl ShmReader { /// was already closed before this call. pub unsafe fn seal(mem: M) -> Result { // SAFETY: forwarded from this function's contract, which keeps the - // region valid for as long as the reader lives — and so for every + // region valid for as long as the reader lives, and so for every // use of the pointers, which are stored in the reader and dropped // with it. let Some(mapped) = (unsafe { MappedLayout::::new(mem.as_raw_slice()) }) else { return Err(SealError::UnsupportedRegion); }; - // Draw the boundary and shut the gate in one step (rule 1): what - // this returns is the claim count at the instant no later claim - // can succeed. Claims already in flight land in slots iteration - // never reaches. Replacing the count rather than keeping it is - // fine — from here on nothing reads it, since a later claim fails - // on the gate before its slot index is used, and a later seal - // only tests the bit. + // Draw the boundary and shut the gate in one step (rule 1): this + // returns the claim count at the instant no later claim can + // succeed. Replacing the count rather than keeping it is fine, + // since nothing reads it from here on: a later claim fails on the + // gate before it uses its slot index, and a later seal only tests + // the bit. let claims = mapped.claims().swap(CLOSED, Ordering::Relaxed); - // The same value says whether anything was lost. The gate was - // already set: either a claim failed — and rule 1 puts that loss - // before this boundary — or someone sealed earlier and this seal - // cannot say what was refused since. No complete set to read. + // The same value says whether anything was lost. A gate already + // set means a failed claim, which rule 1 puts before this + // boundary, or an earlier seal this one cannot account for. if claims & CLOSED != 0 { return Err(SealError::Closed); } @@ -179,7 +173,7 @@ impl<'a> Iterator for Iter<'a> { }; // SAFETY: a committed descriptor names the span its writer // reserved inside the payload area, and the attach contract - // says nothing but this protocol writes the region — so these + // says nothing but this protocol writes the region, so these // are the bits a writer put here. Nothing writes to a // committed span any more, and the reader borrowed for `'a` // keeps the mapping alive. diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs index 7917f0f77..51df1cd3a 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs @@ -12,12 +12,10 @@ use super::{ layout::{CLOSED, MappedLayout, SlotState, to_usize}, }; -/// A concurrent shared-memory frame writer. -/// -/// Safe to use from many threads and processes at once: each frame is -/// reserved atomically, filled in a span no one else can touch, and -/// published with one atomic write (the ordering contract in -/// [`super::layout`]). +/// A shared-memory frame writer, usable from many threads and processes at +/// once. Each frame is reserved atomically, filled in a span no one else +/// can touch, and published with one atomic write (the ordering contract +/// in [`super::layout`]). pub struct ShmWriter { mapped: MappedLayout, /// Owns the region the pointers point into. Declared after them: @@ -37,24 +35,22 @@ unsafe impl Sync for ShmWriter {} /// Why a frame could not be claimed. #[derive(thiserror::Error, Clone, Copy, PartialEq, Eq, Debug)] pub enum ClaimError { - /// The CLOSED gate was set: the receiver sealed the channel, or an - /// earlier claim failed and shut it down. Dropping the record is - /// right either way — the receiver had already stopped collecting, or - /// that same bit already makes the seal fail. + /// The CLOSED gate is set, by a seal or by an earlier failed claim. + /// Dropping the record is right either way: the receiver had stopped + /// collecting, or that same bit already fails its seal. #[error("the channel has been closed")] Closed, - /// There was no room: the region is full, or the frame is longer than - /// the `u32::MAX`-byte limit. The loss is already recorded — this - /// claim set the CLOSED gate — so sealing the channel will fail and - /// every later claim is refused. + /// No room left, or a frame longer than the `u32::MAX` a descriptor + /// can describe. This claim set the CLOSED gate on its way out, so the + /// seal will fail and every later claim is refused. #[error("no space left in the shared-memory region")] Capacity, } impl ShmWriter { /// Creates a writer on a shared-memory region, or `None` when the - /// region cannot hold the protocol (see [`MappedLayout::new`]) — a - /// truncated or unrelated file, for a sender that did not create it. + /// region cannot hold the protocol (see [`MappedLayout::new`]), as a + /// truncated or unrelated file cannot. /// /// # Safety /// @@ -65,7 +61,7 @@ impl ShmWriter { pub unsafe fn new(mem: M) -> Option { // SAFETY: forwarded from this function's contract, which keeps the // region valid, and used only by this protocol, for as long as the - // writer lives — and so for every use of the pointers, which are + // writer lives, and so for every use of the pointers, which are // stored in the writer and dropped with it. let mapped = unsafe { MappedLayout::new(mem.as_raw_slice()) }?; Some(Self { mapped, _mem: mem }) @@ -75,18 +71,17 @@ impl ShmWriter { /// channel and fails its seal: whatever the receiver collects is no /// longer all of them. /// - /// A claim that fails for space reports itself. This is for the writer - /// that gives up for its own reasons — before it could claim, or after - /// claiming — and then goes on to perform the operation the record - /// described. Dropping the frame alone does not report anything: the - /// receiver cannot tell an abandoned slot from one whose writer died, - /// and a writer that died never performed its operation. + /// A claim that fails for space reports itself. This covers a writer + /// that gives up for its own reasons, before or after claiming, and + /// then performs the operation the record described. Dropping the + /// frame reports nothing on its own: the receiver cannot tell an + /// abandoned slot from one whose writer died, and a writer that died + /// never performed its operation. pub fn report_lost_record(&self) { self.mapped.claims().fetch_or(CLOSED, Ordering::Relaxed); } - /// Whether the CLOSED gate is set: the receiver sealed the channel, - /// or an earlier claim failed and shut it down. + /// Whether the CLOSED gate is set, by a seal or by a failed claim. pub fn is_closed(&self) -> bool { self.mapped.claims().load(Ordering::Relaxed) & CLOSED != 0 } @@ -95,11 +90,10 @@ impl ShmWriter { /// `fetch_add`s, no retry loop (rule 1). /// /// The receiver cannot see the frame until [`FrameMut::finish`] - /// commits it; dropping it instead gives the claim up, and the - /// receiver ignores the slot exactly as if the writer had died. A - /// claim that does not fit — the region is full, or `frame_size` is - /// over `u32::MAX` — fails as [`ClaimError::Capacity`] after setting - /// the CLOSED gate. + /// commits it. Dropping it gives the claim up, and the receiver + /// ignores the slot as if the writer had died. A claim that does not + /// fit returns [`ClaimError::Capacity`], after setting the CLOSED + /// gate. pub fn claim_frame(&self, frame_size: NonZeroUsize) -> Result, ClaimError> { let mapped = self.mapped; @@ -182,14 +176,13 @@ fn fitted_offset(start: u64, len: u32, payload_len: u32) -> Option { (end <= payload_len).then_some(offset) } -/// An exclusively owned, claimed-but-unpublished frame. +/// An exclusively owned frame, claimed but not yet published. /// -/// [`FrameMut::finish`] commits the frame; it is the only way to show the -/// payload to the receiver. Dropping the frame gives the claim up: the -/// slot stays unfinished and the receiver ignores it, exactly as if the -/// writer had died there. A writer that drops a frame and still performs -/// the operation it described breaks the rule this channel is built on — -/// write the record first, then do the thing it records. +/// [`FrameMut::finish`] is the only way to show the payload to the +/// receiver. Dropping the frame gives the claim up: the slot stays +/// unfinished and the receiver ignores it, as if the writer had died +/// there. A writer that drops a frame and performs the operation anyway +/// owes the channel a [`ShmWriter::report_lost_record`]. #[derive(Debug)] pub struct FrameMut<'a> { slot: &'a AtomicU64, @@ -214,11 +207,10 @@ impl DerefMut for FrameMut<'_> { impl FrameMut<'_> { /// Commits the frame, making it visible to the receiver. /// - /// A receiver that already sealed the channel may or may not show this - /// frame: it reads the table when asked, so what it reports depends on - /// whether the descriptor is there yet. Either answer is truthful — - /// the operation this record describes had not happened when the - /// receiver drew its line. + /// A receiver that already sealed the channel may or may not show it, + /// depending on whether this store lands before the read reaches that + /// slot. Either answer is truthful, because the operation this record + /// describes had not happened when the receiver drew its line. pub fn finish(self) { // Rule 2: `Release` orders every payload write before the // descriptor. This writer owns the slot, so a store is enough. From 6b61b84a40f326458d29fb22b8c5ae4f2852aa8c Mon Sep 17 00:00:00 2001 From: wan9chi Date: Mon, 17 Aug 2026 11:48:04 +0800 Subject: [PATCH 68/92] docs(fspy-shm): stop hard-wrapping the README Each paragraph is one line now, so editors and viewers wrap it to whatever width the reader has, and a reworded sentence no longer reflows the lines under it. Co-Authored-By: Claude Fable 5 --- .../src/ipc/channel/shm_io/README.md | 130 +++++------------- 1 file changed, 32 insertions(+), 98 deletions(-) diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/README.md b/crates/fspy_shared/src/ipc/channel/shm_io/README.md index d62f1438e..0ec55b8ba 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/README.md +++ b/crates/fspy_shared/src/ipc/channel/shm_io/README.md @@ -1,140 +1,74 @@ # shm_io: a crash-tolerant frame channel over shared memory -One shared-memory region. Many writer processes append variable-length -records; one receiver collects them once, when the channel's lifetime ends. +One shared-memory region. Many writer processes append variable-length records; one receiver collects them once, when the channel's lifetime ends. Three requirements shape the design: -1. **A writer may die at any instruction.** That must not corrupt the - channel or cost another writer its records. +1. **A writer may die at any instruction.** That must not corrupt the channel or cost another writer its records. 2. **A writer may outlive the channel.** The receiver never waits for one. -3. **The receiver must know whether it got everything.** Either it gets - every record writers published, or sealing fails and it gets none. +3. **The receiver must know whether it got everything.** Either it gets every record writers published, or sealing fails and it gets none. ## The region -Any zero-initialized shared memory works. In practice it is a sparse file -mapped into every participating process, so only the pages someone writes -cost real memory. +Any zero-initialized shared memory works. In practice it is a sparse file mapped into every participating process, so only the pages someone writes cost real memory. ```text | counters | descriptor table (SLOTS slots) | payloads (the rest, grow up) | ``` -One `repr(C)` struct holds two `AtomicU64` counters followed by one 8-byte -descriptor slot per frame. The channel fixes the table length at compile -time for both ends. +One `repr(C)` struct holds two `AtomicU64` counters followed by one 8-byte descriptor slot per frame. The channel fixes the table length at compile time for both ends. -- The **claim counter** counts frames ever claimed. Bit 63 is the CLOSED - gate: the receiver sets it when it seals, and so does any writer whose - claim failed, which is how the receiver hears about a lost record. +- The **claim counter** counts frames ever claimed. Bit 63 is the CLOSED gate: the receiver sets it when it seals, and so does any writer whose claim failed, which is how the receiver hears about a lost record. - The **payload counter** counts payload bytes ever reserved. -Payloads take the rest of the mapping, so that struct's size is the whole -geometry. Attaching checks one bound: the struct fits, and what remains is -small enough for the descriptors' 32-bit offsets. The production channel -takes ~67 million slots out of 4 GiB and leaves ~3.5 GiB of payload room, -enough for 15 to 20 million records of a few hundred bytes. Payload space -runs out first. +Payloads take the rest of the mapping, so that struct's size is the whole geometry. Attaching checks one bound: the struct fits, and what remains is small enough for the descriptors' 32-bit offsets. The production channel takes ~67 million slots out of 4 GiB and leaves ~3.5 GiB of payload room, enough for 15 to 20 million records of a few hundred bytes. Payload space runs out first. -Where the table ends and payloads begin never moves. Claiming needs no -retry loop, because a writer checks each counter against a fixed limit -using the value `fetch_add` returned. Overshooting a limit costs nothing: -no counter says where data is, since every committed descriptor carries its -own offset and length. +Where the table ends and payloads begin never moves. Claiming needs no retry loop, because a writer checks each counter against a fixed limit using the value `fetch_add` returned. Overshooting a limit costs nothing: no counter says where data is, since every committed descriptor carries its own offset and length. ## Writing a frame -1. **Claim.** Two `fetch_add`s reserve payload bytes and a slot. No retry - loop, no lock. A claim that does not fit fails after the fact, and sets - the CLOSED gate before the writer moves on. -2. **Fill.** The writer serializes into its payload span, which nobody else - knows exists. -3. **Commit.** One store puts the payload's offset and length into the - slot. Only the writer that claimed a slot ever writes it, so it needs no - compare-and-swap. The receiver cannot see the frame before that store, - and nobody touches the payload after it. - -`FrameMut::finish` commits. What happens when it never runs is the heart of -the design: - -- **The process died,** mid-claim or mid-fill. The slot stays zero and the - receiver ignores it. No cleanup code runs, because none exists. -- **The process abandoned the frame** and kept going. The slot stays zero - and the receiver ignores that too, since it cannot tell the two apart. - -So the channel asks one thing of its users: **publish a record before -performing the action it describes.** A dead writer's missing record then -describes an action that never happened, and a record refused after the -seal describes one performed after the channel closed. The receiver drops -both. A writer that records after acting loses records with nothing said. A -writer that abandons a frame and acts anyway calls -`ShmWriter::report_lost_record`. +1. **Claim.** Two `fetch_add`s reserve payload bytes and a slot. No retry loop, no lock. A claim that does not fit fails after the fact, and sets the CLOSED gate before the writer moves on. +2. **Fill.** The writer serializes into its payload span, which nobody else knows exists. +3. **Commit.** One store puts the payload's offset and length into the slot. Only the writer that claimed a slot ever writes it, so it needs no compare-and-swap. The receiver cannot see the frame before that store, and nobody touches the payload after it. + +`FrameMut::finish` commits. What happens when it never runs is the heart of the design: + +- **The process died,** mid-claim or mid-fill. The slot stays zero and the receiver ignores it. No cleanup code runs, because none exists. +- **The process abandoned the frame** and kept going. The slot stays zero and the receiver ignores that too, since it cannot tell the two apart. + +So the channel asks one thing of its users: **publish a record before performing the action it describes.** A dead writer's missing record then describes an action that never happened, and a record refused after the seal describes one performed after the channel closed. The receiver drops both. A writer that records after acting loses records with nothing said. A writer that abandons a frame and acts anyway calls `ShmWriter::report_lost_record`. ## When the region fills up -A 4 GiB region holds tens of millions of records, but not endless ones. -When a claim asks for more room than the payload area or the table has -left, it fails. The writer skips that record and carries on, because -recording must never stop the program doing the work. +A 4 GiB region holds tens of millions of records, but not endless ones. When a claim asks for more room than the payload area or the table has left, it fails. The writer skips that record and carries on, because recording must never stop the program doing the work. -The loss is not silent. The failed claim sets the CLOSED gate before it -returns, and a receiver that finds that bit already set fails its seal and -hands back nothing. +The loss is not silent. The failed claim sets the CLOSED gate before it returns, and a receiver that finds that bit already set fails its seal and hands back nothing. -Setting the bit first matters for the same reason publishing before acting -does. If the receiver's read misses the bit, the writer set it after the -seal, so the skipped record describes an action performed after the channel -closed. And a writer that died before setting it never performed its -action. +Setting the bit first matters for the same reason publishing before acting does. If the receiver's read misses the bit, the writer set it after the seal, so the skipped record describes an action performed after the channel closed. And a writer that died before setting it never performed its action. -Because the bit is also the gate, the first lost record closes the channel -and every later claim is refused. Those records would only pile up in a -result nobody can use. +Because the bit is also the gate, the first lost record closes the channel and every later claim is refused. Those records would only pile up in a result nobody can use. -A single frame holds at most `u32::MAX` bytes, since a descriptor cannot -describe more. Such a claim is refused and reported the same way. +A single frame holds at most `u32::MAX` bytes, since a descriptor cannot describe more. Such a claim is refused and reported the same way. ## Sealing and reading -Sealing swaps the CLOSED gate into the claim counter and reads the old -value. That one operation draws the boundary and shuts the gate: claims at -or before it are in, every later one fails. If the bit was already set, a -record was lost or someone sealed earlier, and sealing fails here. +Sealing swaps the CLOSED gate into the claim counter and reads the old value. That one operation draws the boundary and shuts the gate: claims at or before it are in, every later one fails. If the bit was already set, a record was lost or someone sealed earlier, and sealing fails here. -No slot is touched, so sealing a channel holding ten million frames costs -what sealing an empty one costs. +No slot is touched, so sealing a channel holding ten million frames costs what sealing an empty one costs. -`ShmReader` owns the mapping and lends out one `&[u8]` per committed span, -straight from shared memory. It reads the table when asked, so a writer -still filling a frame when the boundary was drawn may appear in a later -read and not an earlier one. Dropping the reader releases the mapping. +`ShmReader` owns the mapping and lends out one `&[u8]` per committed span, straight from shared memory. It reads the table when asked, so a writer still filling a frame when the boundary was drawn may appear in a later read and not an earlier one. Dropping the reader releases the mapping. ## Why this is sound -- Finding frames never reads payload bytes, and every slot sits at a fixed - place, so a half-written payload cannot be mistaken for metadata. -- The receiver reaches a payload only through its committed descriptor. The - writer commits with `Release` and the receiver loads with `Acquire`, so a - descriptor the receiver sees brings its payload bytes along. -- One writer owns each slot and writes it once. A slot goes from zero to - committed and stops. -- No counter has to be exact. Both only climb, refused claims leave their - increments behind, and the receiver clamps its snapshot to the table - length, so a scribbled counter costs it a walk over empty slots. -- The receiver builds its borrows from a committed descriptor without - re-checking it. That trusts the other processes to follow the protocol; - one that scribbles random memory is outside the model. It is also why the - receiver reads frames straight out of shared memory, with no copies and - no checksums. +- Finding frames never reads payload bytes, and every slot sits at a fixed place, so a half-written payload cannot be mistaken for metadata. +- The receiver reaches a payload only through its committed descriptor. The writer commits with `Release` and the receiver loads with `Acquire`, so a descriptor the receiver sees brings its payload bytes along. +- One writer owns each slot and writes it once. A slot goes from zero to committed and stops. +- No counter has to be exact. Both only climb, refused claims leave their increments behind, and the receiver clamps its snapshot to the table length, so a scribbled counter costs it a walk over empty slots. +- The receiver builds its borrows from a committed descriptor without re-checking it. That trusts the other processes to follow the protocol; one that scribbles random memory is outside the model. It is also why the receiver reads frames straight out of shared memory, with no copies and no checksums. ## Deployment note -On Linux, the first touch of the sparse backing file costs a millisecond or -two on journalling filesystems. It is the fault path rather than block -allocation, so `fallocate` does not help. Whichever side touches the region -first pays it, once per channel. A filesystem that does not journal avoids -it. +On Linux, the first touch of the sparse backing file costs a millisecond or two on journalling filesystems. It is the fault path rather than block allocation, so `fallocate` does not help. Whichever side touches the region first pays it, once per channel. A filesystem that does not journal avoids it. ## Files From e0894be6ded5b593dcdaa651e1fd63aa2ad24e48 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Mon, 17 Aug 2026 11:54:08 +0800 Subject: [PATCH 69/92] docs(fspy-shm): say why the seal clamps its slot count The comment claimed the counter might read higher than the table without saying how, which made the clamp look like padding. The cause is ordinary: a writer bumps the counter, finds its index out of range, and only then sets the gate, so a seal landing in that window reads the higher count with the gate still clear. Slicing the table on the raw count would panic in the receiver. Co-Authored-By: Claude Fable 5 --- crates/fspy_shared/src/ipc/channel/shm_io/reader.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs index f34e0f892..b89712ef8 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs @@ -110,8 +110,12 @@ impl ShmReader { if claims & CLOSED != 0 { return Err(SealError::Closed); } - // Clamped, so a counter reading higher than the table just means - // walking the whole table, not an error. + // The count runs past the table whenever writers attempt more + // claims than there are slots: one bumps the counter, finds its + // index out of range, and only then sets the gate, so a seal + // landing in that window reads the higher count with the gate + // still clear. Clamping turns that into a walk over the whole + // table; slicing on the raw count would panic. let slot_count = to_usize(claims).min(SLOTS); // The admitted slots, kept as a raw pointer so the reader needs From f3db8e80ae1045058e4bda556feda4d9e41ce317 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Mon, 17 Aug 2026 11:56:31 +0800 Subject: [PATCH 70/92] docs(fspy-shm): note why an overshot count is not an error A count past the table means a claim was refused, which invites the question of whether the seal should fail on it. It should not: that writer performs the operation it could not record only after the boundary, and if it died first it performed none. Co-Authored-By: Claude Fable 5 --- crates/fspy_shared/src/ipc/channel/shm_io/reader.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs index b89712ef8..354fde4e7 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs @@ -116,6 +116,10 @@ impl ShmReader { // landing in that window reads the higher count with the gate // still clear. Clamping turns that into a walk over the whole // table; slicing on the raw count would panic. + // + // Not an error, either: that writer performs the operation it + // failed to record only after this boundary, and if it died first + // it never performed one at all. let slot_count = to_usize(claims).min(SLOTS); // The admitted slots, kept as a raw pointer so the reader needs From 96abd8471310f64db1b37bc496fc5e2eaef50119 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Mon, 17 Aug 2026 13:10:36 +0800 Subject: [PATCH 71/92] refactor(fspy-shm): split what the channel reports from what it panics on The CLOSED gate now means one thing: the region had no room. That is the only failure `shm_io` owns, since the region's size is the only thing it controls. `report_lost_record` goes with that, and the gate loses its second meaning. Everything else a sender can hit is a defect in this crate, so the channel layer panics instead of reporting. `Sender::send` panics when a record's serialized size disagrees with the bytes it writes, and `sender()` panics when the region is there but cannot be opened, mapped, or attached to. A missing backing file stays an error, because it is not a failure at all: the receiver removed it, so it has already stopped collecting and this process is working past the boundary. That distinction is what lets the rest abort. A process that cannot attach has no way to tell the receiver it recorded nothing, and a trace that silently omits every access a process made is worse than a build that stops. Co-Authored-By: Claude Fable 5 --- crates/fspy_client_unix/src/lib.rs | 9 +- .../src/windows/client.rs | 10 ++- crates/fspy_shared/src/ipc/channel/mod.rs | 90 +++++++++++-------- .../src/ipc/channel/shm_io/README.md | 2 +- .../src/ipc/channel/shm_io/layout.rs | 5 +- .../fspy_shared/src/ipc/channel/shm_io/mod.rs | 24 +---- .../src/ipc/channel/shm_io/writer.rs | 19 +--- 7 files changed, 74 insertions(+), 85 deletions(-) diff --git a/crates/fspy_client_unix/src/lib.rs b/crates/fspy_client_unix/src/lib.rs index 119aa5670..77167f444 100644 --- a/crates/fspy_client_unix/src/lib.rs +++ b/crates/fspy_client_unix/src/lib.rs @@ -54,10 +54,13 @@ impl Client { let ipc_sender = match encoded_payload.payload.ipc_channel_conf.sender() { Ok(sender) => Some(sender), + // The only failure `sender` returns is a channel that has + // already closed, which happens when this process starts after + // the root target exited. Everything it does from here is past + // the receiver's boundary, so recording nothing loses nothing. + // Anything worse stops the process inside `sender` instead. Err(err) => { - // This can happen if the process starts after the root target - // has exited and the receiver has closed the channel. - eprintln!("fspy: failed to create ipc sender: {err}"); + eprintln!("fspy: the trace channel has closed: {err}"); None } }; diff --git a/crates/fspy_preload_windows/src/windows/client.rs b/crates/fspy_preload_windows/src/windows/client.rs index 76d2f79fd..ee435ae8f 100644 --- a/crates/fspy_preload_windows/src/windows/client.rs +++ b/crates/fspy_preload_windows/src/windows/client.rs @@ -18,16 +18,18 @@ impl<'a> Client<'a> { let ipc_sender = match payload.channel_conf.sender() { Ok(sender) => Some(sender), + // The only failure `sender` returns is a channel that has + // already closed, which happens when this process starts after + // the root target exited. Everything it does from here is past + // the receiver's boundary, so recording nothing loses nothing. + // Anything worse stops the process inside `sender` instead. Err(err) => { - // this can happen if the process is started after the root target process has exited. - // By that time the channel would have been closed in the receiver side. - // In this case we just leave a message and skip sending any path accesses. #[expect( clippy::print_stderr, reason = "preload library uses stderr for debug diagnostics" )] { - eprintln!("fspy: failed to create ipc sender: {err}"); + eprintln!("fspy: the trace channel has closed: {err}"); } None } diff --git a/crates/fspy_shared/src/ipc/channel/mod.rs b/crates/fspy_shared/src/ipc/channel/mod.rs index 84e55de05..e2cff3579 100644 --- a/crates/fspy_shared/src/ipc/channel/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/mod.rs @@ -165,12 +165,19 @@ impl Drop for ShmKeeper { impl ChannelConf { /// Creates a sender. /// - /// Never blocks. Fails when the receiver has already closed the channel - /// or dropped, because the backing file is removed either way. A close - /// also shuts the region's gate before removing it, so a sender that - /// attaches in that window still finds a closed channel; a receiver - /// that is merely dropped only removes the file, and a removal that - /// fails there leaves the region attachable. + /// Never blocks. Fails only when the channel is already over: the + /// receiver removed the backing file, or it sealed the region before + /// removing it and a sender caught the gate in between. Either way + /// whatever this process does next happens past the receiver's + /// boundary, so skipping its records loses nothing. + /// + /// # Panics + /// + /// When the channel is there but cannot be attached to: the file + /// refuses to open or map, or it cannot hold the protocol. A process + /// with no writer has no way to tell the receiver it recorded nothing, + /// and a trace that silently omits every access it made is worse than + /// no trace, so it stops here instead. #[expect( clippy::missing_errors_doc, reason = "error conditions are self-evident from return type" @@ -180,24 +187,30 @@ impl ChannelConf { // the preload contexts that create senders (pre-`main` constructors, // the Windows loader lock). let arena = fspy_nostd_alloc::arena(); - let shm_path = self.shm_id.to_os_c_string_in(&arena).ok_or_else(|| { - io::Error::new(io::ErrorKind::InvalidData, "invalid shared-memory path") - })?; - let mapping = fspy_shm::open(shm_path.as_c_str().as_thin()) - .map_err(shm_error_to_io)? - .map() - .map_err(shm_error_to_io)?; + let shm_path = self + .shm_id + .to_os_c_string_in(&arena) + .expect("the channel's own shared-memory path is not a valid C string"); + let handle = match fspy_shm::open(shm_path.as_c_str().as_thin()) { + Ok(handle) => handle, + Err(error) => { + let error = shm_error_to_io(error); + // The receiver removed the backing file, so it has already + // stopped collecting. + if error.kind() == io::ErrorKind::NotFound { + return Err(error); + } + panic!("cannot open the shared-memory channel: {error}"); + } + }; + let mapping = handle.map().unwrap_or_else(|error| { + panic!("cannot map the shared-memory channel: {}", shm_error_to_io(error)) + }); // SAFETY: `mapping` is a freshly mapped shared memory region created // zero-initialized by `channel` and accessed only through the // `shm_io` protocol by every attached process. - let Some(writer) = (unsafe { ShmWriter::new(mapping) }) else { - // A truncated or foreign file fails here — it never panics the - // host process. - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "shared-memory region cannot host the channel", - )); - }; + let writer = unsafe { ShmWriter::new(mapping) } + .expect("the shared-memory region cannot hold the channel"); if writer.is_closed() { return Err(io::Error::new( io::ErrorKind::BrokenPipe, @@ -215,32 +228,33 @@ pub struct Sender { impl Sender { /// Serializes one record into a committed frame. /// - /// A record that cannot be sent is skipped, because that is all a - /// sender inside an intercepted call can do — but never silently. A - /// failed claim already reports itself: for space by setting the CLOSED - /// gate, or as closed, which means the record belongs past the - /// receiver's boundary. The remaining ways to give up are this - /// sender's own, so it reports them, and the seal then refuses to hand - /// back a set of frames that is missing one. + /// A claim the channel refuses is skipped, because that is all a sender + /// inside an intercepted call can do: the channel has closed, so the + /// record belongs past the receiver's boundary, or the region is full + /// and the failed claim already set the CLOSED gate to say so. + /// + /// # Panics + /// + /// When the record's serialized size disagrees with the bytes it then + /// writes. Nothing the caller passes can cause that, so it is a defect + /// in this crate or its codec, and a trace built on it would be wrong + /// in ways the receiver cannot see. pub fn send>(&self, value: &T) { let Ok(serialized_size) = T::serialized_size(value) else { - self.writer.report_lost_record(); - return; + panic!("a record cannot report its serialized size"); }; let Ok(Some(frame_size)) = usize::try_from(serialized_size).map(NonZeroUsize::new) else { - self.writer.report_lost_record(); - return; + panic!("a record reports a serialized size of {serialized_size} bytes"); }; let Ok(mut frame) = self.writer.claim_frame(frame_size) else { return; }; let mut buf: &mut [u8] = &mut frame; - if T::serialize_into(&mut buf, value).is_err() || !buf.is_empty() { - // The frame is abandoned — the receiver ignores its slot — so - // the loss needs reporting on its own. - self.writer.report_lost_record(); - return; - } + let written = T::serialize_into(&mut buf, value); + assert!( + written.is_ok() && buf.is_empty(), + "a record wrote fewer bytes than the {serialized_size} it reported" + ); frame.finish(); } } diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/README.md b/crates/fspy_shared/src/ipc/channel/shm_io/README.md index 0ec55b8ba..4d70977a2 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/README.md +++ b/crates/fspy_shared/src/ipc/channel/shm_io/README.md @@ -36,7 +36,7 @@ Where the table ends and payloads begin never moves. Claiming needs no retry loo - **The process died,** mid-claim or mid-fill. The slot stays zero and the receiver ignores it. No cleanup code runs, because none exists. - **The process abandoned the frame** and kept going. The slot stays zero and the receiver ignores that too, since it cannot tell the two apart. -So the channel asks one thing of its users: **publish a record before performing the action it describes.** A dead writer's missing record then describes an action that never happened, and a record refused after the seal describes one performed after the channel closed. The receiver drops both. A writer that records after acting loses records with nothing said. A writer that abandons a frame and acts anyway calls `ShmWriter::report_lost_record`. +So the channel asks one thing of its users: **publish a record before performing the action it describes.** A dead writer's missing record then describes an action that never happened, and a record refused after the seal describes one performed after the channel closed. The receiver drops both. A writer that records after acting, or that abandons a frame and acts anyway, breaks the rule and loses records with nothing said. The channel cannot see either one. ## When the region fills up diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs index d0178a81d..9d24b07fd 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs @@ -10,8 +10,9 @@ use std::{num::NonZeroU32, ptr::NonNull, sync::atomic::AtomicU64}; /// The CLOSED gate bit of the claim counter. The receiver sets it when it -/// seals, and so does any failed claim, which is how it reports the loss -/// (rule 1). +/// seals, and so does a claim the region had no room for, which is how it +/// reports the loss (rule 1). Nothing else sets it: what a writer cannot +/// record for its own reasons is not this protocol's to report. /// /// A bit rather than a value to compare against, so it survives the /// increment of a writer that arrives late. Counting cannot reach it: that diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs index 64079eb80..3c77af5ef 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs @@ -9,9 +9,9 @@ //! The channel asks one thing of its writers: **publish a record before //! performing the action it describes.** A record that never arrives then //! describes an action that never happened, and one refused after the seal -//! describes an action performed after the receiver stopped collecting. -//! [`ShmWriter::report_lost_record`] covers the case left over, where a -//! writer gives up on a record and acts anyway. +//! describes an action performed after the receiver stopped collecting. A +//! writer that gives up on a record and performs the action anyway breaks +//! the rule, and this protocol cannot tell that it did. //! //! `README.md` in this directory describes the region, the claim sequence, //! and why the receiver can borrow frames out of shared memory. [`layout`] @@ -343,24 +343,6 @@ mod tests { assert!(sealed.unwrap_err() == SealError::Closed); } - /// A writer that gives up on a frame it already claimed, or before it - /// could claim one, has no failed claim to report the loss for it. - #[test] - fn a_reported_loss_fails_the_seal() { - let shm = MockedShm::alloc(1024); - // SAFETY: see `single_thread_basic`. - let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }.unwrap(); - assert!(writer.try_write_frame(b"kept")); - - assert!(!writer.is_closed()); - writer.report_lost_record(); - assert!(writer.is_closed()); - - // SAFETY: see `collect_frames`. - let sealed = unsafe { ShmReader::seal::(shm) }; - assert!(sealed.unwrap_err() == SealError::Closed); - } - #[test] fn claims_after_seal_are_gated_without_poisoning() { let shm = MockedShm::alloc(1024); diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs index 51df1cd3a..cd5e53652 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs @@ -67,20 +67,6 @@ impl ShmWriter { Some(Self { mapped, _mem: mem }) } - /// Reports a record this writer could not write, which closes the - /// channel and fails its seal: whatever the receiver collects is no - /// longer all of them. - /// - /// A claim that fails for space reports itself. This covers a writer - /// that gives up for its own reasons, before or after claiming, and - /// then performs the operation the record described. Dropping the - /// frame reports nothing on its own: the receiver cannot tell an - /// abandoned slot from one whose writer died, and a writer that died - /// never performed its operation. - pub fn report_lost_record(&self) { - self.mapped.claims().fetch_or(CLOSED, Ordering::Relaxed); - } - /// Whether the CLOSED gate is set, by a seal or by a failed claim. pub fn is_closed(&self) -> bool { self.mapped.claims().load(Ordering::Relaxed) & CLOSED != 0 @@ -181,8 +167,9 @@ fn fitted_offset(start: u64, len: u32, payload_len: u32) -> Option { /// [`FrameMut::finish`] is the only way to show the payload to the /// receiver. Dropping the frame gives the claim up: the slot stays /// unfinished and the receiver ignores it, as if the writer had died -/// there. A writer that drops a frame and performs the operation anyway -/// owes the channel a [`ShmWriter::report_lost_record`]. +/// there. The two look identical from the outside, which is why a writer +/// that drops a frame and performs the operation anyway breaks the rule +/// this channel rests on. #[derive(Debug)] pub struct FrameMut<'a> { slot: &'a AtomicU64, From 30588aaf471bc4915a63a745b1c96de1afa76a6f Mon Sep 17 00:00:00 2001 From: wan9chi Date: Mon, 17 Aug 2026 13:20:09 +0800 Subject: [PATCH 72/92] refactor(fspy-shm): state the failures as expects Four of the five panics were a `let ... else` or a closure wrapped around the value they were unwrapping. `expect` says the same thing in one line and prints the underlying error with it, which the hand-written messages had to interpolate by hand. Splitting the serialize check in two also names the failures separately: one for a codec that refuses its own frame, one for a codec that fills less of it than it asked for. The open path keeps its `match`, since a missing file returns rather than panics and there is no unwrap to hang an `expect` on. Co-Authored-By: Claude Fable 5 --- crates/fspy_shared/src/ipc/channel/mod.rs | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/crates/fspy_shared/src/ipc/channel/mod.rs b/crates/fspy_shared/src/ipc/channel/mod.rs index e2cff3579..0c9f2b9c1 100644 --- a/crates/fspy_shared/src/ipc/channel/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/mod.rs @@ -203,9 +203,7 @@ impl ChannelConf { panic!("cannot open the shared-memory channel: {error}"); } }; - let mapping = handle.map().unwrap_or_else(|error| { - panic!("cannot map the shared-memory channel: {}", shm_error_to_io(error)) - }); + let mapping = handle.map().expect("cannot map the shared-memory channel"); // SAFETY: `mapping` is a freshly mapped shared memory region created // zero-initialized by `channel` and accessed only through the // `shm_io` protocol by every attached process. @@ -240,21 +238,18 @@ impl Sender { /// in this crate or its codec, and a trace built on it would be wrong /// in ways the receiver cannot see. pub fn send>(&self, value: &T) { - let Ok(serialized_size) = T::serialized_size(value) else { - panic!("a record cannot report its serialized size"); - }; - let Ok(Some(frame_size)) = usize::try_from(serialized_size).map(NonZeroUsize::new) else { - panic!("a record reports a serialized size of {serialized_size} bytes"); - }; + let serialized_size = + T::serialized_size(value).expect("a record cannot report its serialized size"); + let frame_size = usize::try_from(serialized_size) + .ok() + .and_then(NonZeroUsize::new) + .expect("a record reports a serialized size of zero, or one no frame could hold"); let Ok(mut frame) = self.writer.claim_frame(frame_size) else { return; }; let mut buf: &mut [u8] = &mut frame; - let written = T::serialize_into(&mut buf, value); - assert!( - written.is_ok() && buf.is_empty(), - "a record wrote fewer bytes than the {serialized_size} it reported" - ); + T::serialize_into(&mut buf, value).expect("a record will not serialize into its own frame"); + assert!(buf.is_empty(), "a record wrote fewer bytes than the size it reported"); frame.finish(); } } From af3d07e3343a521e45aa9857f9bfd63022e837f0 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Mon, 17 Aug 2026 13:26:25 +0800 Subject: [PATCH 73/92] perf(fspy-shm): report a loss with a store The gate was set with `fetch_or`, which is a locked read-modify-write on a line every writer is contending for. A plain store does the job: it drops the claim count along with setting the bit, and nothing reads that count once the gate is set. The seal fails on the bit before it looks at the count, and a claim that reads the cleared count reads the gate with it, so it gives up before using a slot index. The two tests that asserted a count next to the gate now assert the gate alone. Co-Authored-By: Claude Fable 5 --- crates/fspy_shared/src/ipc/channel/shm_io/mod.rs | 10 ++++------ crates/fspy_shared/src/ipc/channel/shm_io/writer.rs | 8 +++++++- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs index 3c77af5ef..1ac2bcbdb 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs @@ -201,9 +201,8 @@ mod tests { // The refused reservation stays counted: four bytes of "test" // plus the 2048 that did not fit. assert!(shm.peek_u64(8) == 4 + 2048); - // Payload space is reserved before a slot is, so the refusal cost - // no slot: one claim counted, and the gate set. - assert!(shm.peek_u64(0) == (1 << 63) | 1); + // The loss report replaces the count with the gate. + assert!(shm.peek_u64(0) == 1 << 63); // "test" did land, but a lost record means the frames are not all // of them, so the seal hands back none of them. @@ -333,9 +332,8 @@ mod tests { assert!(writer.try_write_frame(b"x")); } assert!(writer.claim_frame(1.try_into().unwrap()).unwrap_err() == ClaimError::Capacity); - // The refused claim leaves its increment behind, and sets the - // gate: sixteen claims counted, fifteen of them in slots. - assert!(shm.peek_u64(0) == (1 << 63) | 16); + // The loss report replaces the count with the gate. + assert!(shm.peek_u64(0) == 1 << 63); // Fifteen frames landed, but the sixteenth was lost. // SAFETY: see `collect_frames`. diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs index cd5e53652..96b006881 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs @@ -85,8 +85,14 @@ impl ShmWriter { // The loss report (rule 1): the gate marks the frames incomplete // and shuts the channel down for later claims. + // + // Storing the gate rather than or-ing it in drops the claim count, + // which nothing reads once the gate is set. The seal fails on the + // bit before it looks at the count, and a claim that reads the + // cleared count reads the gate along with it, so it gives up + // before using a slot index. let report_loss = || { - mapped.claims().fetch_or(CLOSED, Ordering::Relaxed); + mapped.claims().store(CLOSED, Ordering::Relaxed); ClaimError::Capacity }; From 08150ee1a630e3b398cbfbfea602dedaa608fcdd Mon Sep 17 00:00:00 2001 From: wan9chi Date: Mon, 17 Aug 2026 13:29:08 +0800 Subject: [PATCH 74/92] refactor(fspy-shm): let the table's own bounds decide Both sides checked an index against `SLOTS` and then indexed the table on the strength of that check. `get` does both at once, so the bound comes from the slice being indexed rather than from a constant that has to agree with it. The writer takes the slot it claimed or reports the loss, and holds the reference instead of the index. The seal takes the admitted prefix or falls back to the whole table, which says what the old `min` meant. Co-Authored-By: Claude Fable 5 --- crates/fspy_shared/src/ipc/channel/shm_io/reader.rs | 9 +++++---- crates/fspy_shared/src/ipc/channel/shm_io/writer.rs | 7 +++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs index 354fde4e7..e01d4d630 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs @@ -114,13 +114,14 @@ impl ShmReader { // claims than there are slots: one bumps the counter, finds its // index out of range, and only then sets the gate, so a seal // landing in that window reads the higher count with the gate - // still clear. Clamping turns that into a walk over the whole - // table; slicing on the raw count would panic. + // still clear. Such a count names slots that do not exist, so the + // fall-back reads the whole table. // // Not an error, either: that writer performs the operation it // failed to record only after this boundary, and if it died first // it never performed one at all. - let slot_count = to_usize(claims).min(SLOTS); + let slots = mapped.table(); + let admitted = slots.get(..to_usize(claims)).unwrap_or(slots); // The admitted slots, kept as a raw pointer so the reader needs // no lifetime and no `SLOTS`. @@ -129,7 +130,7 @@ impl ShmReader { // `AtomicU64`, so a writer storing a descriptor never invalidates // it. That last part stops holding if `Meta` ever gains a field // that is not an atomic. - let table = NonNull::from_ref(&mapped.table()[..slot_count]); + let table = NonNull::from_ref(admitted); Ok(Self { payload_start: mapped.payload_start, table, _mem: mem }) } diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs index 96b006881..bc9a17aee 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs @@ -119,10 +119,9 @@ impl ShmWriter { // this record was refused. return Err(ClaimError::Closed); } - let slot_index = to_usize(claims); - if slot_index >= SLOTS { + let Some(slot) = self.mapped.table().get(to_usize(claims)) else { return Err(report_loss()); - } + }; // SAFETY: the claim reserved this span for itself, and the check // above put it inside the region. Other writers reserve spans @@ -136,7 +135,7 @@ impl ShmWriter { ) }; Ok(FrameMut { - slot: &self.mapped.table()[slot_index], + slot, slot_to_commit: SlotState::Committed { offset: payload_offset, len: frame_size } .encode(), content, From 5ca10c977b1b01d2d40bd28314e8395d1f1a79ce Mon Sep 17 00:00:00 2001 From: wan9chi Date: Mon, 17 Aug 2026 14:10:51 +0800 Subject: [PATCH 75/92] feat(fspy): let the caller size the tracking channel The shared memory a tracked run reports its file accesses through was a constant in `fspy`, four gibibytes wide. How many accesses a program makes is the runner's business rather than the tracer's, and nothing could ask for a different size, so no test could put a task in front of a channel too small for it. The size becomes an argument to `fspy::Command::new`, and the runner reads `VP_RUN_INTERNAL_FSPY_SHM_CAPACITY` for it, keeping the same four gibibytes when it is unset. The variable is internal: it exists so a test can shrink the channel until a task overruns it, and nothing outside this repository should set it. The e2e case that comes with it stats one 2 MiB path, the largest single record tracking can be asked to hold, under a 64 MiB channel. That leaves room to spare, so the run caches like any other, which is what tells us the size arrived. The interesting case, a channel with no room for the record, has to wait: today it aborts the task process, and the panic it prints carries a thread id, a toolchain path, a backtrace and a platform's own abort code, none of which snapshot the same way twice. Co-Authored-By: Claude Opus 5 --- crates/fspy/examples/cli.rs | 4 +- crates/fspy/src/command.rs | 10 +++- crates/fspy/src/ipc.rs | 5 -- crates/fspy/src/unix/mod.rs | 4 +- crates/fspy/src/windows/mod.rs | 9 ++-- crates/fspy/tests/node_fs.rs | 2 +- crates/fspy/tests/oxlint.rs | 2 +- crates/fspy/tests/static_executable.rs | 2 +- crates/fspy/tests/test_utils/mod.rs | 9 ++++ crates/fspy_benchmark_launcher/src/main.rs | 9 +++- crates/fspy_e2e/src/main.rs | 6 ++- crates/subprocess_test/src/lib.rs | 7 ++- crates/vt/src/session/execute/spawn.rs | 25 +++++++++- .../fixtures/fspy_shm_capacity/package.json | 4 ++ .../fixtures/fspy_shm_capacity/snapshots.toml | 26 ++++++++++ ...capacity_env_sizes_the_tracking_channel.md | 49 +++++++++++++++++++ .../fixtures/fspy_shm_capacity/vite-task.json | 8 +++ 17 files changed, 158 insertions(+), 23 deletions(-) create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/package.json create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/snapshots.toml create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/snapshots/shm_capacity_env_sizes_the_tracking_channel.md create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/vite-task.json diff --git a/crates/fspy/examples/cli.rs b/crates/fspy/examples/cli.rs index 4ae34790f..a0135d0a8 100644 --- a/crates/fspy/examples/cli.rs +++ b/crates/fspy/examples/cli.rs @@ -15,7 +15,9 @@ async fn main() -> anyhow::Result<()> { let program = PathBuf::from(args.next().unwrap()); - let mut command = fspy::Command::new(program); + // Sparse address space, so a generous region costs nothing until the + // tracked program's records use it. + let mut command = fspy::Command::new(program, 4 << 30); command.envs(std::env::vars_os()).args(args); let child = command.spawn(tokio_util::sync::CancellationToken::new()).await?; diff --git a/crates/fspy/src/command.rs b/crates/fspy/src/command.rs index fb150b26c..b81bc4371 100644 --- a/crates/fspy/src/command.rs +++ b/crates/fspy/src/command.rs @@ -15,6 +15,10 @@ use crate::{SPY_IMPL, TrackedChild, error::SpawnError}; #[derive(derive_more::Debug)] pub struct Command { program: OsString, + /// Bytes of shared memory for this run's file-access records. + /// Caller-chosen: how many a program makes is the caller's business, + /// not this crate's. + pub(crate) shm_capacity: usize, args: Vec, envs: FxHashMap, cwd: Option, @@ -31,12 +35,14 @@ pub struct Command { } impl Command { - /// Create a new command to spy on the given program. + /// Create a new command to spy on the given program, giving its + /// records `shm_capacity` bytes of shared memory. /// Initially, environment variables are not inherited from the parent. /// To inherit, explicitly use `.envs(std::env::vars_os())`. - pub fn new>(program: P) -> Self { + pub fn new>(program: P, shm_capacity: usize) -> Self { Self { program: program.as_ref().to_os_string(), + shm_capacity, args: Vec::new(), envs: FxHashMap::default(), cwd: None, diff --git a/crates/fspy/src/ipc.rs b/crates/fspy/src/ipc.rs index 51d498600..af10a29b3 100644 --- a/crates/fspy/src/ipc.rs +++ b/crates/fspy/src/ipc.rs @@ -6,11 +6,6 @@ use fspy_shared::ipc::{ }; use tokio::task::spawn_blocking; -// Shared memory size for storing path accesses. -// 4 GiB is large enough to store path accesses in almost any realistic scenario. -// This doesn't allocate physical memory until it's actually used. -pub const SHM_CAPACITY: usize = 4 * 1024 * 1024 * 1024; - #[ouroboros::self_referencing] pub struct OwnedReceiverLockGuard { /// Owns the shared memory diff --git a/crates/fspy/src/unix/mod.rs b/crates/fspy/src/unix/mod.rs index f1d657436..2c97aad42 100644 --- a/crates/fspy/src/unix/mod.rs +++ b/crates/fspy/src/unix/mod.rs @@ -25,7 +25,7 @@ use tokio::task::spawn_blocking; use tokio_util::sync::CancellationToken; #[cfg(not(target_env = "musl"))] -use crate::ipc::{OwnedReceiverLockGuard, SHM_CAPACITY}; +use crate::ipc::OwnedReceiverLockGuard; use crate::{ChildTermination, Command, TrackedChild, arena::PathAccessArena, error::SpawnError}; #[derive(Debug)] @@ -80,7 +80,7 @@ impl SpyImpl { #[cfg(not(target_env = "musl"))] let (ipc_channel_conf, ipc_receiver) = - channel(SHM_CAPACITY).map_err(SpawnError::ChannelCreation)?; + channel(command.shm_capacity).map_err(SpawnError::ChannelCreation)?; let payload = Payload { #[cfg(not(target_env = "musl"))] diff --git a/crates/fspy/src/windows/mod.rs b/crates/fspy/src/windows/mod.rs index c468888a6..66966e67f 100644 --- a/crates/fspy/src/windows/mod.rs +++ b/crates/fspy/src/windows/mod.rs @@ -21,10 +21,8 @@ use winapi::{ use winsafe::co::{CP, WC}; use crate::{ - ChildTermination, TrackedChild, - command::Command, - error::SpawnError, - ipc::{OwnedReceiverLockGuard, SHM_CAPACITY}, + ChildTermination, TrackedChild, command::Command, error::SpawnError, + ipc::OwnedReceiverLockGuard, }; const INTERPOSE_CDYLIB: Artifact = @@ -82,12 +80,13 @@ impl SpyImpl { ) -> Result { let ansi_dll_path_with_nul = Arc::clone(&self.ansi_dll_path_with_nul); command.env("FSPY", "1"); + let shm_capacity = command.shm_capacity; let mut command = command.into_tokio_command(); command.creation_flags(CREATE_SUSPENDED); let (channel_conf, receiver) = - channel(SHM_CAPACITY).map_err(SpawnError::ChannelCreation)?; + channel(shm_capacity).map_err(SpawnError::ChannelCreation)?; let mut spawn_success = false; let spawn_success = &mut spawn_success; diff --git a/crates/fspy/tests/node_fs.rs b/crates/fspy/tests/node_fs.rs index 96e951487..e6ef489d0 100644 --- a/crates/fspy/tests/node_fs.rs +++ b/crates/fspy/tests/node_fs.rs @@ -28,7 +28,7 @@ fn track_script( ) -> anyhow::Result { let (program, path) = resolve_runtime(runtime)?; - let mut command = fspy::Command::new(program); + let mut command = fspy::Command::new(program, test_utils::TEST_SHM_CAPACITY); command .envs(vars_os().filter(|(name, _)| !name.eq_ignore_ascii_case("PATH"))) .env("PATH", path); // https://github.com/jdx/mise/discussions/5968 diff --git a/crates/fspy/tests/oxlint.rs b/crates/fspy/tests/oxlint.rs index fe4a96291..1f70b7a9f 100644 --- a/crates/fspy/tests/oxlint.rs +++ b/crates/fspy/tests/oxlint.rs @@ -30,7 +30,7 @@ fn find_oxlint() -> std::path::PathBuf { async fn track_oxlint(dir: &std::path::Path, args: &[&str]) -> anyhow::Result { let oxlint_path = find_oxlint(); - let mut command = fspy::Command::new(&oxlint_path); + let mut command = fspy::Command::new(&oxlint_path, test_utils::TEST_SHM_CAPACITY); // Build PATH with packages/tools/.bin prepended so oxlint can find tsgolint let tools_dir = tools_bin_dir(); diff --git a/crates/fspy/tests/static_executable.rs b/crates/fspy/tests/static_executable.rs index ae3c27169..f16c44612 100644 --- a/crates/fspy/tests/static_executable.rs +++ b/crates/fspy/tests/static_executable.rs @@ -40,7 +40,7 @@ fn test_bin_path() -> &'static Path { } async fn track_test_bin(args: &[&str], cwd: Option<&str>) -> PathAccessIterable { - let mut cmd = fspy::Command::new(test_bin_path()); + let mut cmd = fspy::Command::new(test_bin_path(), test_utils::TEST_SHM_CAPACITY); if let Some(cwd) = cwd { cmd.current_dir(cwd); } diff --git a/crates/fspy/tests/test_utils/mod.rs b/crates/fspy/tests/test_utils/mod.rs index cfa46c4a9..c8790b6bd 100644 --- a/crates/fspy/tests/test_utils/mod.rs +++ b/crates/fspy/tests/test_utils/mod.rs @@ -1,6 +1,15 @@ use std::path::{Path, PathBuf, StripPrefixError}; use fspy::{AccessMode, PathAccessIterable}; + +/// Shared memory for a tracked test program's file-access records. Sparse +/// address space, so this costs nothing until the records use it. +#[expect( + clippy::allow_attributes, + reason = "this module is compiled into every test binary, including the ones that never spawn" +)] +#[allow(dead_code, reason = "not every test file spawns a tracked program")] +pub const TEST_SHM_CAPACITY: usize = 1 << 30; // Used by the track_child! macro; not all test files use this macro #[doc(hidden)] #[expect( diff --git a/crates/fspy_benchmark_launcher/src/main.rs b/crates/fspy_benchmark_launcher/src/main.rs index 80a54bb90..f406eb856 100644 --- a/crates/fspy_benchmark_launcher/src/main.rs +++ b/crates/fspy_benchmark_launcher/src/main.rs @@ -14,6 +14,11 @@ use fspy::Command; use tokio::{io::AsyncReadExt as _, process::ChildStdout, runtime::Builder}; use tokio_util::sync::CancellationToken; +/// Shared memory for the tracked run's file-access records. It must match +/// what the runner gives a real task, since claiming a frame is part of +/// what this benchmark measures. +const SHM_CAPACITY: usize = 4 << 30; + /// The base path the target opens, appended to its arguments so that only this /// crate names it: the target derives its per-thread paths from it, and /// validation asserts the bare path was captured. @@ -77,7 +82,7 @@ struct Launch { /// that a tracked launch covers session setup, injection, and teardown, and /// nothing of this launcher's own startup. async fn run_tracked(target: &OsString, target_args: &[OsString], relative: bool) -> Launch { - let mut command = Command::new(target); + let mut command = Command::new(target, SHM_CAPACITY); command .args(target_args) .arg(if relative { MISSING_RELATIVE_PATH } else { MISSING_PATH }) @@ -141,7 +146,7 @@ async fn report(mut launch: Launch) { /// resolves the root working directory and joins the bare name back into /// [`MISSING_PATH`] — so the assertion below covers both modes. async fn validate(target: &OsString, target_args: &[OsString], relative: bool) { - let mut command = Command::new(target); + let mut command = Command::new(target, SHM_CAPACITY); command .args(target_args) .arg(if relative { MISSING_RELATIVE_PATH } else { MISSING_PATH }) diff --git a/crates/fspy_e2e/src/main.rs b/crates/fspy_e2e/src/main.rs index 9d6a30525..89b77564c 100644 --- a/crates/fspy_e2e/src/main.rs +++ b/crates/fspy_e2e/src/main.rs @@ -8,6 +8,10 @@ use std::{ }; use fspy::{AccessMode, PathAccess}; + +/// Shared memory for a tracked case's file-access records. Sparse address +/// space, so a generous region costs nothing until the records use it. +const SHM_CAPACITY: usize = 4 << 30; use rustc_hash::FxHashMap; use serde::{Deserialize, Serialize}; use tokio::io::AsyncReadExt; @@ -75,7 +79,7 @@ async fn main() { continue; } println!("Running case `{}` in dir `{}`", name, case.dir); - let mut cmd = fspy::Command::new(case.cmd[0].clone()); + let mut cmd = fspy::Command::new(case.cmd[0].clone(), SHM_CAPACITY); let dir = manifest_dir.join(&case.dir); cmd.args(&case.cmd[1..]) .envs(env::vars_os()) diff --git a/crates/subprocess_test/src/lib.rs b/crates/subprocess_test/src/lib.rs index f9f81b970..81611a07a 100644 --- a/crates/subprocess_test/src/lib.rs +++ b/crates/subprocess_test/src/lib.rs @@ -24,10 +24,15 @@ impl From for StdCommand { } } +/// Shared memory for a test subprocess's file-access records: sparse +/// address space, so a generous size costs nothing until it is used. +#[cfg(feature = "fspy")] +const TEST_SHM_CAPACITY: usize = 1 << 30; + #[cfg(feature = "fspy")] impl From for fspy::Command { fn from(cmd: Command) -> Self { - let mut fspy_cmd = Self::new(cmd.program); + let mut fspy_cmd = Self::new(cmd.program, TEST_SHM_CAPACITY); fspy_cmd.args(cmd.args).envs(cmd.envs); fspy_cmd.current_dir(cmd.cwd); fspy_cmd diff --git a/crates/vt/src/session/execute/spawn.rs b/crates/vt/src/session/execute/spawn.rs index adff8aac9..62f6a6f33 100644 --- a/crates/vt/src/session/execute/spawn.rs +++ b/crates/vt/src/session/execute/spawn.rs @@ -14,6 +14,29 @@ use tokio::process::{ChildStderr, ChildStdout}; use tokio_util::sync::CancellationToken; use vt_plan::SpawnCommand; +/// Shared memory for one tracked task's file-access records. 4 GiB of +/// sparse address space: none of it becomes real memory until records +/// land in it, and it leaves room for tens of millions of accesses. +#[cfg(fspy)] +const FSPY_SHM_CAPACITY: usize = 4 << 30; + +/// Overrides [`FSPY_SHM_CAPACITY`] with a byte count. Internal: it exists +/// so tests can shrink the region until a task overruns it, and nothing +/// outside this repository should set it. +#[cfg(fspy)] +const FSPY_SHM_CAPACITY_ENV: &str = "VP_RUN_INTERNAL_FSPY_SHM_CAPACITY"; + +/// The shared memory each tracked task gets. Read once, since a run's +/// tasks all get the same size. +#[cfg(fspy)] +static FSPY_SHM_CAPACITY_IN_USE: std::sync::LazyLock = std::sync::LazyLock::new(|| { + std::env::var_os(FSPY_SHM_CAPACITY_ENV).map_or(FSPY_SHM_CAPACITY, |value| { + value.to_str().and_then(|value| value.parse().ok()).unwrap_or_else(|| { + panic!("{FSPY_SHM_CAPACITY_ENV} is not a byte count: {}", value.display()) + }) + }) +}); + /// How the child's stdin/stdout/stderr are configured. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SpawnStdio { @@ -99,7 +122,7 @@ where K: AsRef, V: AsRef, { - let mut fspy_cmd = fspy::Command::new(cmd.program_path.as_path()); + let mut fspy_cmd = fspy::Command::new(cmd.program_path.as_path(), *FSPY_SHM_CAPACITY_IN_USE); fspy_cmd.args(cmd.args.iter().map(vt_str::Str::as_str)); fspy_cmd.envs(cmd.spawn_envs.iter()); fspy_cmd.envs(extra_envs); diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/package.json b/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/package.json new file mode 100644 index 000000000..5fe27cb1c --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/package.json @@ -0,0 +1,4 @@ +{ + "name": "fspy-shm-capacity", + "private": true +} diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/snapshots.toml b/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/snapshots.toml new file mode 100644 index 000000000..7402b876e --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/snapshots.toml @@ -0,0 +1,26 @@ +[[e2e]] +name = "shm_capacity_env_sizes_the_tracking_channel" +comment = """ +`VP_RUN_INTERNAL_FSPY_SHM_CAPACITY` sizes the shared memory a tracked task reports its file accesses through. The task stats one 2 MiB path, which is the largest single record tracking can be asked to hold, and 64 MiB holds it comfortably, so the run caches like any other. + +Setting the capacity below that record is what the knob exists for. That case has to wait: a channel with no room for a record currently aborts the task process, and the panic it prints carries a thread id, a toolchain path, a backtrace and a platform's own abort code, none of which snapshot the same way twice. +""" +steps = [ + { argv = [ + "vt", + "run", + "-v", + "stat", + ], envs = [ + [ + "VP_RUN_INTERNAL_FSPY_SHM_CAPACITY", + "67108864", + ], + ], comment = "64 MiB, room to spare for a 2 MiB record" }, + { argv = [ + "vt", + "run", + "-v", + "stat", + ], comment = "replayed from the entry the first run stored" }, +] diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/snapshots/shm_capacity_env_sizes_the_tracking_channel.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/snapshots/shm_capacity_env_sizes_the_tracking_channel.md new file mode 100644 index 000000000..c44129291 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/snapshots/shm_capacity_env_sizes_the_tracking_channel.md @@ -0,0 +1,49 @@ +# shm_capacity_env_sizes_the_tracking_channel + +`VP_RUN_INTERNAL_FSPY_SHM_CAPACITY` sizes the shared memory a tracked task reports its file accesses through. The task stats one 2 MiB path, which is the largest single record tracking can be asked to hold, and 64 MiB holds it comfortably, so the run caches like any other. + +Setting the capacity below that record is what the knob exists for. That case has to wait: a channel with no room for a record currently aborts the task process, and the panic it prints carries a thread id, a toolchain path, a backtrace and a platform's own abort code, none of which snapshot the same way twice. + +## `VP_RUN_INTERNAL_FSPY_SHM_CAPACITY=67108864 vt run -v stat` + +64 MiB, room to spare for a 2 MiB record + +``` +$ vtt stat_long_filename 2097152 + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Vite+ Task Runner • Execution Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Statistics: 1 tasks • 0 cache hits • 1 cache misses +Performance: 0% cache hit rate + +Task Details: +──────────────────────────────────────────────── + [1] fspy-shm-capacity#stat: $ vtt stat_long_filename 2097152 ✓ + → Cache miss: no previous cache entry found +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` + +## `vt run -v stat` + +replayed from the entry the first run stored + +``` +$ vtt stat_long_filename 2097152 ◉ cache hit, replaying + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Vite+ Task Runner • Execution Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Statistics: 1 tasks • 1 cache hits • 0 cache misses +Performance: 100% cache hit rate + +Task Details: +──────────────────────────────────────────────── + [1] fspy-shm-capacity#stat: $ vtt stat_long_filename 2097152 ✓ + → Cache hit - output replayed - +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/vite-task.json b/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/vite-task.json new file mode 100644 index 000000000..c9075fd16 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/vite-task.json @@ -0,0 +1,8 @@ +{ + "tasks": { + "stat": { + "command": "vtt stat_long_filename 2097152", + "cache": true + } + } +} From a5e27a9b01b7a0f6c13c3bc4ffd8565f968eea8c Mon Sep 17 00:00:00 2001 From: wan9chi Date: Mon, 17 Aug 2026 14:05:22 +0800 Subject: [PATCH 76/92] refactor(fspy-shm): choose the slot count at run time The descriptor table's length was a constant in the channel, one slot per 64 bytes of a 4 GiB region. Both ends read it from that constant, so it had to be a compile-time answer, and it rode on a `SLOTS` const generic through `Meta`, `MappedLayout`, `ShmWriter` and `ShmReader::seal`. Nothing about the protocol needs it decided that early. Both ends need only to agree, so the receiver passes the count when it creates the region and every sender reads it back out of the channel's own configuration. `Meta` becomes the two counters alone, and the table becomes a slice pointer beside them, which is what the reader already kept. That lets a caller size a channel for what it expects to record, rather than taking a number this crate picked. The runner names both halves now, and its own `VP_RUN_INTERNAL_FSPY_SHM_CAPACITY` can shrink a channel far enough for a test to overrun it, which needs the table to shrink with it. Two smaller things follow from the same change. `Receiver::close` now reports only the one failure a caller can act on, a record a sender could not write, and panics on a region that cannot hold the protocol, which `channel` proved it could before any sender saw it. And a run whose tracking came up short no longer fails the task: the runner reports it as a not-cached reason, since the task itself did its work and only the record of it is missing. Co-Authored-By: Claude Opus 5 --- crates/fspy/examples/cli.rs | 5 +- crates/fspy/src/command.rs | 11 +- crates/fspy/src/ipc.rs | 47 +++---- crates/fspy/src/lib.rs | 2 +- crates/fspy/src/unix/mod.rs | 23 +++- crates/fspy/src/windows/mod.rs | 19 +-- crates/fspy/tests/node_fs.rs | 2 +- crates/fspy/tests/oxlint.rs | 2 +- crates/fspy/tests/static_executable.rs | 2 +- crates/fspy/tests/test_utils/mod.rs | 9 ++ crates/fspy_benchmark_launcher/src/main.rs | 11 +- crates/fspy_e2e/src/main.rs | 6 +- crates/fspy_shared/src/ipc/channel/mod.rs | 105 +++++++++------- .../src/ipc/channel/shm_io/README.md | 6 +- .../src/ipc/channel/shm_io/layout.rs | 117 +++++++++++------- .../fspy_shared/src/ipc/channel/shm_io/mod.rs | 69 +++++------ .../src/ipc/channel/shm_io/reader.rs | 21 ++-- .../src/ipc/channel/shm_io/writer.rs | 22 ++-- crates/fspy_shared/src/ipc/mod.rs | 16 +++ crates/subprocess_test/src/lib.rs | 7 +- crates/vt/src/session/event.rs | 6 + crates/vt/src/session/execute/cache_update.rs | 33 +++++ crates/vt/src/session/execute/spawn.rs | 33 ++++- crates/vt/src/session/reporter/summary.rs | 26 ++++ crates/vt_bin/src/vtt/main.rs | 4 +- crates/vt_bin/src/vtt/stat_many.rs | 19 +++ .../fixtures/fspy_shm_capacity/package.json | 4 + .../fixtures/fspy_shm_capacity/snapshots.toml | 24 ++++ ...pacity_exhaustion_leaves_the_task_alone.md | 49 ++++++++ .../fixtures/fspy_shm_capacity/vite-task.json | 8 ++ 30 files changed, 511 insertions(+), 197 deletions(-) create mode 100644 crates/vt_bin/src/vtt/stat_many.rs create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/package.json create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/snapshots.toml create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/snapshots/capacity_exhaustion_leaves_the_task_alone.md create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/vite-task.json diff --git a/crates/fspy/examples/cli.rs b/crates/fspy/examples/cli.rs index 4ae34790f..79e76a344 100644 --- a/crates/fspy/examples/cli.rs +++ b/crates/fspy/examples/cli.rs @@ -15,7 +15,10 @@ async fn main() -> anyhow::Result<()> { let program = PathBuf::from(args.next().unwrap()); - let mut command = fspy::Command::new(program); + // Sparse address space, so a generous region costs nothing until the + // tracked program's records use it. + let shm = fspy::ChannelSize { capacity: 4 << 30, slots: 1 << 26 }; + let mut command = fspy::Command::new(program, shm); command.envs(std::env::vars_os()).args(args); let child = command.spawn(tokio_util::sync::CancellationToken::new()).await?; diff --git a/crates/fspy/src/command.rs b/crates/fspy/src/command.rs index fb150b26c..fba2251eb 100644 --- a/crates/fspy/src/command.rs +++ b/crates/fspy/src/command.rs @@ -4,6 +4,7 @@ use std::{ process::Stdio, }; +use fspy_shared::ipc::ChannelSize; #[cfg(unix)] use fspy_shared_unix::exec::Exec; use rustc_hash::FxHashMap; @@ -15,6 +16,10 @@ use crate::{SPY_IMPL, TrackedChild, error::SpawnError}; #[derive(derive_more::Debug)] pub struct Command { program: OsString, + /// Shared memory for this run's file-access records. Caller-chosen: + /// how many a program makes is the caller's business, not this + /// crate's. + pub(crate) shm: ChannelSize, args: Vec, envs: FxHashMap, cwd: Option, @@ -31,12 +36,14 @@ pub struct Command { } impl Command { - /// Create a new command to spy on the given program. + /// Create a new command to spy on the given program, giving its + /// records `shm` worth of shared memory. /// Initially, environment variables are not inherited from the parent. /// To inherit, explicitly use `.envs(std::env::vars_os())`. - pub fn new>(program: P) -> Self { + pub fn new>(program: P, shm: ChannelSize) -> Self { Self { program: program.as_ref().to_os_string(), + shm, args: Vec::new(), envs: FxHashMap::default(), cwd: None, diff --git a/crates/fspy/src/ipc.rs b/crates/fspy/src/ipc.rs index 79f1be856..01ba6619f 100644 --- a/crates/fspy/src/ipc.rs +++ b/crates/fspy/src/ipc.rs @@ -1,45 +1,46 @@ -use std::io; - use fspy_shared::ipc::{ PathAccess, channel::{FrameReader, Receiver}, }; -// Shared memory region size: the channel's fixed descriptor table plus -// ~3.5 GiB of payload room — enough path accesses for almost any realistic -// scenario. None of it occupies physical memory until actually used. -pub const SHM_CAPACITY: usize = 4 * 1024 * 1024 * 1024; - /// The path accesses a run reported through the IPC channel. pub struct ChannelAccesses { - frames: FrameReader, + /// `None` when a record was lost, which leaves what did arrive too + /// incomplete to build anything on. + frames: Option, } -impl TryFrom for ChannelAccesses { - type Error = io::Error; - - /// Closes the channel and rejects traces that cannot back the run's - /// file accesses. +impl From for ChannelAccesses { + /// Closes the channel and keeps its frames, unless a sender ran out of + /// room. /// /// Never waits for tracked processes: closing reads one counter and /// shuts the channel's gate (see /// [`fspy_shared::ipc::channel::Receiver::close`]), so it runs inline /// however many records were reported. - /// - /// Fails when a record was lost before close, which is what keeps the - /// tracking result trustworthy for caching: closing hands back frames - /// only when they are all of them. A run that fills the region - /// therefore fails here rather than reporting a short trace. - fn try_from(receiver: Receiver) -> io::Result { - Ok(Self { frames: receiver.close()? }) + fn from(receiver: Receiver) -> Self { + Self { frames: receiver.close().ok() } } } impl ChannelAccesses { + /// Whether every record senders published is here. + /// + /// `false` means a sender could not record something it then went on + /// to do, so what follows is a subset of what the run really touched. + /// Anything that needs all of them — caching, above all — has to treat + /// the run as untracked rather than as having touched only these + /// paths. + pub fn is_complete(&self) -> bool { + self.frames.is_some() + } + pub fn iter_path_accesses(&self) -> impl Iterator> { - self.frames.iter().map(|frame| { - wincode::deserialize_exact(frame) - .expect("committed frames are complete under the channel protocol") + self.frames.iter().flat_map(|frames| { + frames.iter().map(|frame| { + wincode::deserialize_exact(frame) + .expect("committed frames are complete under the channel protocol") + }) }) } } diff --git a/crates/fspy/src/lib.rs b/crates/fspy/src/lib.rs index 6c89414ba..341120d4b 100644 --- a/crates/fspy/src/lib.rs +++ b/crates/fspy/src/lib.rs @@ -20,7 +20,7 @@ mod command; use std::{env::temp_dir, fs::create_dir, io, process::ExitStatus, sync::LazyLock}; pub use command::Command; -pub use fspy_shared::ipc::{AccessMode, PathAccess}; +pub use fspy_shared::ipc::{AccessMode, ChannelSize, PathAccess}; use futures_util::future::BoxFuture; pub use os_impl::PathAccessIterable; use os_impl::SpyImpl; diff --git a/crates/fspy/src/unix/mod.rs b/crates/fspy/src/unix/mod.rs index fe517b99c..0ca4ee124 100644 --- a/crates/fspy/src/unix/mod.rs +++ b/crates/fspy/src/unix/mod.rs @@ -25,7 +25,7 @@ use tokio::task::spawn_blocking; use tokio_util::sync::CancellationToken; #[cfg(not(target_env = "musl"))] -use crate::ipc::{ChannelAccesses, SHM_CAPACITY}; +use crate::ipc::ChannelAccesses; use crate::{ChildTermination, Command, TrackedChild, arena::PathAccessArena, error::SpawnError}; #[derive(Debug)] @@ -80,7 +80,7 @@ impl SpyImpl { #[cfg(not(target_env = "musl"))] let (ipc_channel_conf, ipc_receiver) = - channel(SHM_CAPACITY).map_err(SpawnError::ChannelCreation)?; + channel(command.shm).map_err(SpawnError::ChannelCreation)?; let payload = Payload { #[cfg(not(target_env = "musl"))] @@ -162,7 +162,7 @@ impl SpyImpl { // Close the ipc channel after the child has exited. // We are not interested in path accesses from descendants after the main child has exited. #[cfg(not(target_env = "musl"))] - let ipc_accesses = ChannelAccesses::try_from(ipc_receiver)?; + let ipc_accesses = ChannelAccesses::from(ipc_receiver); let path_accesses = PathAccessIterable { arenas, #[cfg(not(target_env = "musl"))] @@ -184,6 +184,23 @@ pub struct PathAccessIterable { } impl PathAccessIterable { + /// Whether every access the run made is here. + /// + /// `false` when a tracked process could not record one it went on to + /// perform, which leaves [`Self::iter`] short of what really happened. + /// The seccomp supervisor collects on this side of the boundary, so + /// only the shared-memory channel can come up short. + pub fn is_complete(&self) -> bool { + #[cfg(not(target_env = "musl"))] + { + self.ipc_accesses.is_complete() + } + #[cfg(target_env = "musl")] + { + true + } + } + pub fn iter(&self) -> impl Iterator> { let accesses_in_arena = self.arenas.iter().flat_map(|arena| arena.borrow_accesses().iter()).copied(); diff --git a/crates/fspy/src/windows/mod.rs b/crates/fspy/src/windows/mod.rs index a79606e73..cb885eca7 100644 --- a/crates/fspy/src/windows/mod.rs +++ b/crates/fspy/src/windows/mod.rs @@ -21,10 +21,7 @@ use winapi::{ use winsafe::co::{CP, WC}; use crate::{ - ChildTermination, TrackedChild, - command::Command, - error::SpawnError, - ipc::{ChannelAccesses, SHM_CAPACITY}, + ChildTermination, TrackedChild, command::Command, error::SpawnError, ipc::ChannelAccesses, }; const INTERPOSE_CDYLIB: Artifact = @@ -35,6 +32,14 @@ pub struct PathAccessIterable { } impl PathAccessIterable { + /// Whether every access the run made is here. + /// + /// `false` when a tracked process could not record one it went on to + /// perform, which leaves [`Self::iter`] short of what really happened. + pub fn is_complete(&self) -> bool { + self.ipc_accesses.is_complete() + } + pub fn iter(&self) -> impl Iterator> { self.ipc_accesses.iter_path_accesses() } @@ -82,12 +87,12 @@ impl SpyImpl { ) -> Result { let ansi_dll_path_with_nul = Arc::clone(&self.ansi_dll_path_with_nul); command.env("FSPY", "1"); + let shm = command.shm; let mut command = command.into_tokio_command(); command.creation_flags(CREATE_SUSPENDED); - let (channel_conf, receiver) = - channel(SHM_CAPACITY).map_err(SpawnError::ChannelCreation)?; + let (channel_conf, receiver) = channel(shm).map_err(SpawnError::ChannelCreation)?; let mut spawn_success = false; let spawn_success = &mut spawn_success; @@ -169,7 +174,7 @@ impl SpyImpl { }; // Close the ipc channel after the child has exited. // We are not interested in path accesses from descendants after the main child has exited. - let ipc_accesses = ChannelAccesses::try_from(receiver)?; + let ipc_accesses = ChannelAccesses::from(receiver); let path_accesses = PathAccessIterable { ipc_accesses }; io::Result::Ok(ChildTermination { status, path_accesses }) diff --git a/crates/fspy/tests/node_fs.rs b/crates/fspy/tests/node_fs.rs index 96e951487..f8662e47e 100644 --- a/crates/fspy/tests/node_fs.rs +++ b/crates/fspy/tests/node_fs.rs @@ -28,7 +28,7 @@ fn track_script( ) -> anyhow::Result { let (program, path) = resolve_runtime(runtime)?; - let mut command = fspy::Command::new(program); + let mut command = fspy::Command::new(program, test_utils::TEST_SHM); command .envs(vars_os().filter(|(name, _)| !name.eq_ignore_ascii_case("PATH"))) .env("PATH", path); // https://github.com/jdx/mise/discussions/5968 diff --git a/crates/fspy/tests/oxlint.rs b/crates/fspy/tests/oxlint.rs index fe4a96291..dbac0c769 100644 --- a/crates/fspy/tests/oxlint.rs +++ b/crates/fspy/tests/oxlint.rs @@ -30,7 +30,7 @@ fn find_oxlint() -> std::path::PathBuf { async fn track_oxlint(dir: &std::path::Path, args: &[&str]) -> anyhow::Result { let oxlint_path = find_oxlint(); - let mut command = fspy::Command::new(&oxlint_path); + let mut command = fspy::Command::new(&oxlint_path, test_utils::TEST_SHM); // Build PATH with packages/tools/.bin prepended so oxlint can find tsgolint let tools_dir = tools_bin_dir(); diff --git a/crates/fspy/tests/static_executable.rs b/crates/fspy/tests/static_executable.rs index ae3c27169..c6392588b 100644 --- a/crates/fspy/tests/static_executable.rs +++ b/crates/fspy/tests/static_executable.rs @@ -40,7 +40,7 @@ fn test_bin_path() -> &'static Path { } async fn track_test_bin(args: &[&str], cwd: Option<&str>) -> PathAccessIterable { - let mut cmd = fspy::Command::new(test_bin_path()); + let mut cmd = fspy::Command::new(test_bin_path(), test_utils::TEST_SHM); if let Some(cwd) = cwd { cmd.current_dir(cwd); } diff --git a/crates/fspy/tests/test_utils/mod.rs b/crates/fspy/tests/test_utils/mod.rs index cfa46c4a9..623eebc7f 100644 --- a/crates/fspy/tests/test_utils/mod.rs +++ b/crates/fspy/tests/test_utils/mod.rs @@ -1,6 +1,15 @@ use std::path::{Path, PathBuf, StripPrefixError}; use fspy::{AccessMode, PathAccessIterable}; + +/// Shared memory for a tracked test program's file-access records. Sparse +/// address space, so this costs nothing until the records use it. +#[expect( + clippy::allow_attributes, + reason = "this module is compiled into every test binary, including the ones that never spawn" +)] +#[allow(dead_code, reason = "not every test file spawns a tracked program")] +pub const TEST_SHM: fspy::ChannelSize = fspy::ChannelSize { capacity: 1 << 30, slots: 1 << 24 }; // Used by the track_child! macro; not all test files use this macro #[doc(hidden)] #[expect( diff --git a/crates/fspy_benchmark_launcher/src/main.rs b/crates/fspy_benchmark_launcher/src/main.rs index 80a54bb90..31c4de7fc 100644 --- a/crates/fspy_benchmark_launcher/src/main.rs +++ b/crates/fspy_benchmark_launcher/src/main.rs @@ -10,10 +10,15 @@ use std::{env, ffi::OsString, process::Stdio, time::Instant}; -use fspy::Command; +use fspy::{ChannelSize, Command}; use tokio::{io::AsyncReadExt as _, process::ChildStdout, runtime::Builder}; use tokio_util::sync::CancellationToken; +/// Shared memory for the tracked run's file-access records. It must match +/// what the runner gives a real task, since claiming a frame is part of +/// what this benchmark measures. +const SHM: ChannelSize = ChannelSize { capacity: 4 << 30, slots: 1 << 26 }; + /// The base path the target opens, appended to its arguments so that only this /// crate names it: the target derives its per-thread paths from it, and /// validation asserts the bare path was captured. @@ -77,7 +82,7 @@ struct Launch { /// that a tracked launch covers session setup, injection, and teardown, and /// nothing of this launcher's own startup. async fn run_tracked(target: &OsString, target_args: &[OsString], relative: bool) -> Launch { - let mut command = Command::new(target); + let mut command = Command::new(target, SHM); command .args(target_args) .arg(if relative { MISSING_RELATIVE_PATH } else { MISSING_PATH }) @@ -141,7 +146,7 @@ async fn report(mut launch: Launch) { /// resolves the root working directory and joins the bare name back into /// [`MISSING_PATH`] — so the assertion below covers both modes. async fn validate(target: &OsString, target_args: &[OsString], relative: bool) { - let mut command = Command::new(target); + let mut command = Command::new(target, SHM); command .args(target_args) .arg(if relative { MISSING_RELATIVE_PATH } else { MISSING_PATH }) diff --git a/crates/fspy_e2e/src/main.rs b/crates/fspy_e2e/src/main.rs index 9d6a30525..a1b679f02 100644 --- a/crates/fspy_e2e/src/main.rs +++ b/crates/fspy_e2e/src/main.rs @@ -8,6 +8,10 @@ use std::{ }; use fspy::{AccessMode, PathAccess}; + +/// Shared memory for a tracked case's file-access records. Sparse address +/// space, so a generous region costs nothing until the records use it. +const SHM: fspy::ChannelSize = fspy::ChannelSize { capacity: 4 << 30, slots: 1 << 26 }; use rustc_hash::FxHashMap; use serde::{Deserialize, Serialize}; use tokio::io::AsyncReadExt; @@ -75,7 +79,7 @@ async fn main() { continue; } println!("Running case `{}` in dir `{}`", name, case.dir); - let mut cmd = fspy::Command::new(case.cmd[0].clone()); + let mut cmd = fspy::Command::new(case.cmd[0].clone(), SHM); let dir = manifest_dir.join(&case.dir); cmd.args(&case.cmd[1..]) .envs(env::vars_os()) diff --git a/crates/fspy_shared/src/ipc/channel/mod.rs b/crates/fspy_shared/src/ipc/channel/mod.rs index 0c9f2b9c1..69e32394e 100644 --- a/crates/fspy_shared/src/ipc/channel/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/mod.rs @@ -13,15 +13,7 @@ use allocator_api2::alloc::Global; use fspy_nostd::Fat; use fspy_nostd_alloc::OsCString; use fspy_shm::Mapping; -use shm_io::{ShmReader, ShmWriter}; - -/// Descriptor slots per channel — the compile-time half of the region's -/// shape, shared by the receiver and every sender through this constant. -/// It matches what the old an-eighth-of-the-region rule gave the 4 GiB -/// production region: one 8-byte descriptor per ~56 payload bytes at full -/// capacity, generous slack for record-sized frames. The table is sparse -/// address space until slots are actually touched. -const SLOTS: usize = 1 << 26; +use shm_io::{SealError, ShmReader, ShmWriter, to_usize}; /// Reads the committed frames of a sealed channel; borrows the shared /// mapping, which stays alive (and mapped) until this value drops. @@ -29,7 +21,7 @@ pub type FrameReader = shm_io::ShmReader; use uuid::Uuid; use wincode::{SchemaRead, SchemaWrite, Serialize as _, config::DefaultConfig}; -use super::IpcStr; +use super::{ChannelSize, IpcStr}; /// Prefix of shared-memory backing file names inside the system temporary /// directory. @@ -43,16 +35,15 @@ const SHM_BACKING_PREFIX: &str = "vite-task-fspy-"; #[derive(SchemaWrite, SchemaRead, Clone, Debug)] pub struct ChannelConf { shm_id: Box, + /// The slot count the region was created with, since a sender cannot + /// work it out from the mapping's size alone. + slots: u64, } /// Creates a mpsc IPC channel with one receiver and a `ChannelConf` that can be passed around processes and used to create multiple senders. -/// -/// `capacity` is the region size in bytes; it must hold the compile-time -/// descriptor table, and the rest of it is payload room. Senders need no -/// configuration beyond the `ChannelConf` — the layout is the -/// compile-time table plus whatever the mapped file's size says. #[expect(clippy::missing_errors_doc, reason = "non-vt crate: cannot use vt_str/vt_path types")] -pub fn channel(capacity: usize) -> io::Result<(ChannelConf, Receiver)> { +pub fn channel(size: ChannelSize) -> io::Result<(ChannelConf, Receiver)> { + let ChannelSize { capacity, slots } = size; let shm_c_path = os_c_string(shm_backing_path()?.as_os_str())?; let handle = fspy_shm::create(shm_c_path.as_c_str().as_thin(), capacity).map_err(shm_error_to_io)?; @@ -61,20 +52,23 @@ pub fn channel(capacity: usize) -> io::Result<(ChannelConf, Receiver)> { let mapping = handle.map().map_err(shm_error_to_io)?; // Prove the region can host the protocol — the same fallible attach - // senders perform — so a bad capacity fails the task now, not at its - // first record. + // senders perform — so a size the two halves do not fit in fails the + // task now, not at its first record. // SAFETY: the region was just created zero-initialized and is only // accessed through the `shm_io` protocol. - if unsafe { ShmWriter::<_, SLOTS>::new(&mapping) }.is_none() { + if unsafe { ShmWriter::new(&mapping, slots) }.is_none() { return Err(io::Error::new( io::ErrorKind::InvalidInput, - "capacity cannot host the channel's descriptor table", + "the shared-memory capacity cannot hold that many slots", )); } - let conf = ChannelConf { shm_id: IpcStr::from_os_c_str(keeper.path.as_c_str()).to_boxed() }; + let conf = ChannelConf { + shm_id: IpcStr::from_os_c_str(keeper.path.as_c_str()).to_boxed(), + slots: slots.try_into().expect("a slot count is a 64-bit target's own usize"), + }; - Ok((conf, Receiver { _keeper: keeper, mapping })) + Ok((conf, Receiver { _keeper: keeper, mapping, slots })) } /// Encodes `path` as an owned NUL-terminated platform C string. @@ -207,7 +201,7 @@ impl ChannelConf { // SAFETY: `mapping` is a freshly mapped shared memory region created // zero-initialized by `channel` and accessed only through the // `shm_io` protocol by every attached process. - let writer = unsafe { ShmWriter::new(mapping) } + let writer = unsafe { ShmWriter::new(mapping, to_usize(self.slots)) } .expect("the shared-memory region cannot hold the channel"); if writer.is_closed() { return Err(io::Error::new( @@ -220,7 +214,7 @@ impl ChannelConf { } pub struct Sender { - writer: ShmWriter, + writer: ShmWriter, } impl Sender { @@ -272,6 +266,8 @@ pub struct Receiver { /// may attach. _keeper: ShmKeeper, mapping: Mapping, + /// The slot count the region was created with, needed again to seal it. + slots: usize, } // SAFETY: `Receiver` only holds the mapping; it accesses it exclusively @@ -298,28 +294,48 @@ impl Receiver { /// /// # Errors /// - /// Fails when a record was lost before the close — a sender ran out of - /// room, tried to send something too large, or gave up on one it had - /// already claimed — and when the region cannot hold the protocol at - /// all. Either way there is no complete set of records, so none are - /// handed back, and a caller that needs the trace has to treat the run - /// as untrackable rather than as having reported nothing. - pub fn close(self) -> io::Result { - let Self { _keeper: keeper, mapping } = self; + /// [`RecordsLost`] when a sender could not record something it went on + /// to do. There is no complete set of records then, so none are handed + /// back, and a caller that needs the trace has to treat the run as + /// untracked rather than as having reported nothing. + /// + /// # Panics + /// + /// When the region cannot hold the protocol, which [`channel`] proved + /// it could before any sender saw it. + pub fn close(self) -> Result { + let Self { _keeper: keeper, mapping, slots } = self; // SAFETY: `mapping` was created zero-initialized by `channel`, its // address is stable and independently owned, and all attached // processes access it only through the `shm_io` protocol. - let reader = unsafe { ShmReader::seal::(mapping) } - .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; + let sealed = unsafe { ShmReader::seal(mapping, slots) }; // Remove the backing file only after the gate is shut. A process // that attaches in between finds a closed channel and gives up // cleanly; one that found the file already gone could not attach at // all, and so could not report whatever it then failed to record. drop(keeper); - Ok(reader) + match sealed { + Ok(reader) => Ok(reader), + // This receiver is the only one that could have sealed, and it + // is gone by now, so the gate can only be a sender's report. + Err(SealError::Closed) => Err(RecordsLost), + Err(SealError::UnsupportedRegion) => { + panic!("the shared-memory region cannot hold the channel") + } + } } } +/// A sender could not record something, so the receiver has no complete +/// set of records to hand back. +/// +/// The region filled up, or a record came out longer than one frame can +/// hold. Both are the channel running out of room rather than anything the +/// senders did wrong, and both leave the run untracked. +#[derive(thiserror::Error, Clone, Copy, PartialEq, Eq, Debug)] +#[error("a sender ran out of room in the shared-memory channel")] +pub struct RecordsLost; + #[cfg(test)] mod tests { use std::{ffi::OsString, fs, num::NonZeroUsize, str::from_utf8}; @@ -331,15 +347,16 @@ mod tests { use super::*; use crate::ipc::{AccessMode, IpcPath, PathAccess}; - /// Any test region must hold the compile-time table; sparse, so cheap. - const GIB: usize = 1 << 30; + /// A gibibyte of sparse address space, so its table costs nothing + /// until slots are touched. + const SIZE: ChannelSize = ChannelSize { capacity: 1 << 30, slots: 1 << 24 }; /// The shared-memory path is generated absolute, so a sender in a process /// with a different working directory and a relative temporary directory /// must still attach. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn sender_ignores_changed_temp_and_working_directory() { - let (conf, receiver) = channel(GIB).unwrap(); + let (conf, receiver) = channel(SIZE).unwrap(); let changed_cwd = temp_dir().join(format!("fspy-ipc-changed-cwd-{}", Uuid::new_v4())); fs::create_dir(&changed_cwd).unwrap(); @@ -369,7 +386,7 @@ mod tests { /// here rather than in a build. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn sender_round_trips_records() { - let (conf, receiver) = channel(GIB).unwrap(); + let (conf, receiver) = channel(SIZE).unwrap(); let sender = conf.sender().unwrap(); // A record path carries the platform's own string form: bytes on // unix, UTF-16 on Windows. @@ -400,7 +417,7 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn smoke() { - let (conf, receiver) = channel(GIB).unwrap(); + let (conf, receiver) = channel(SIZE).unwrap(); let cmd = command_for_fn!(conf, |conf: ChannelConf| { let sender = conf.sender().unwrap(); let frame_size = NonZeroUsize::new(2).unwrap(); @@ -422,7 +439,7 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[expect(clippy::print_stdout, reason = "test diagnostics")] async fn forbid_new_senders_after_close() { - let (conf, receiver) = channel(GIB).unwrap(); + let (conf, receiver) = channel(SIZE).unwrap(); let _frames = receiver.close().unwrap(); let cmd = command_for_fn!(conf, |conf: ChannelConf| { @@ -435,7 +452,7 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[expect(clippy::print_stdout, reason = "test diagnostics")] async fn forbid_new_senders_after_receiver_dropped() { - let (conf, receiver) = channel(GIB).unwrap(); + let (conf, receiver) = channel(SIZE).unwrap(); drop(receiver); let cmd = command_for_fn!(conf, |conf: ChannelConf| { @@ -449,7 +466,7 @@ mod tests { /// claim any new frame afterwards. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn attached_sender_cannot_claim_after_close() { - let (conf, receiver) = channel(GIB).unwrap(); + let (conf, receiver) = channel(SIZE).unwrap(); let sender = conf.sender().unwrap(); let mut frame = sender.writer.claim_frame(NonZeroUsize::new(2).unwrap()).unwrap(); @@ -467,7 +484,7 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn concurrent_senders() { - let (conf, receiver) = channel(GIB).unwrap(); + let (conf, receiver) = channel(SIZE).unwrap(); for i in 0u16..200 { let cmd = command_for_fn!((conf.clone(), i), |(conf, i): (ChannelConf, u16)| { let sender = conf.sender().unwrap(); diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/README.md b/crates/fspy_shared/src/ipc/channel/shm_io/README.md index 4d70977a2..dae21e457 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/README.md +++ b/crates/fspy_shared/src/ipc/channel/shm_io/README.md @@ -13,15 +13,15 @@ Three requirements shape the design: Any zero-initialized shared memory works. In practice it is a sparse file mapped into every participating process, so only the pages someone writes cost real memory. ```text -| counters | descriptor table (SLOTS slots) | payloads (the rest, grow up) | +| counters | descriptor table (one slot per frame) | payloads (the rest, grow up) | ``` -One `repr(C)` struct holds two `AtomicU64` counters followed by one 8-byte descriptor slot per frame. The channel fixes the table length at compile time for both ends. +Two `AtomicU64` counters sit at the front, followed by one 8-byte descriptor slot per frame. - The **claim counter** counts frames ever claimed. Bit 63 is the CLOSED gate: the receiver sets it when it seals, and so does any writer whose claim failed, which is how the receiver hears about a lost record. - The **payload counter** counts payload bytes ever reserved. -Payloads take the rest of the mapping, so that struct's size is the whole geometry. Attaching checks one bound: the struct fits, and what remains is small enough for the descriptors' 32-bit offsets. The production channel takes ~67 million slots out of 4 GiB and leaves ~3.5 GiB of payload room, enough for 15 to 20 million records of a few hundred bytes. Payload space runs out first. +The mapping's size and the number of slots are the whole geometry, and every process attaching to a region passes the slot count it was created with. Payloads take what the table leaves. Attaching checks one bound: the counters and the table fit, and what remains is small enough for the descriptors' 32-bit offsets. Splitting 4 GiB at one slot per 64 bytes, say, gives ~67 million slots and ~3.5 GiB of payload room, which is tens of millions of records of a few hundred bytes; payload space runs out first. Where the table ends and payloads begin never moves. Claiming needs no retry loop, because a writer checks each counter against a fixed limit using the value `fetch_add` returned. Overshooting a limit costs nothing: no counter says where data is, since every committed descriptor carries its own offset and length. diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs index 9d24b07fd..a665c8e16 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs @@ -21,30 +21,29 @@ use std::{num::NonZeroU32, ptr::NonNull, sync::atomic::AtomicU64}; /// answer. pub const CLOSED: u64 = 1 << 63; -/// The part of the region that never moves: the counters and the -/// descriptor table, as one `repr(C)` struct that starts zeroed. Payloads +/// The two counters at the start of the region, as one `repr(C)` struct +/// that starts zeroed. The descriptor table follows them, and payloads /// take the rest of the mapping. #[repr(C)] -pub struct Meta { +pub struct Counters { /// Bit 63 is the CLOSED gate. The low bits count claims that got as /// far as reserving payload space, which is where a slot is taken. pub claims: AtomicU64, /// Payload bytes ever reserved, failed claims included. pub payload_reserved: AtomicU64, - /// One descriptor slot per frame. - pub table: [AtomicU64; SLOTS], } -// The mapping starts at a `u64`-aligned address and is cast to `&Meta`, -// so no field in `Meta` may need more alignment than that. -const _: () = assert!(align_of::>() == align_of::()); +// The mapping starts at a `u64`-aligned address and is cast to +// `&Counters`, so no field in it may need more alignment than that. The +// descriptor table then starts at a multiple of that alignment too. +const _: () = assert!(align_of::() == align_of::()); -// The reader keeps a raw pointer into the table and reads through it long -// after the reference it came from is gone, which is sound only because -// every byte of `Meta` sits inside an atomic. This catches a field being -// added or padding appearing; it cannot catch a field changing type, so -// keep that in mind when editing the struct. -const _: () = assert!(size_of::>() == 5 * size_of::()); +// Both endpoints keep raw pointers into the region and read through them +// long after the references they came from are gone, which is sound only +// because every byte they point at sits inside an atomic. This catches a +// field being added or padding appearing; it cannot catch a field changing +// type, so keep that in mind when editing the struct. +const _: () = assert!(size_of::() == 2 * size_of::()); // --- The descriptor slot codec --------------------------------------------- // @@ -69,10 +68,10 @@ const _: () = assert!(size_of::>() == 5 * size_of::()); // can never point into the counters or the table. // --- MappedLayout and the ordering contract -------------------------------- -// `MappedLayout::new` works out both pointers once, at attach, and the -// endpoint keeps them beside the mapping. The payload pointer stays raw -// because writers hand out `&mut` slices into it, which must not overlap a -// shared reference. +// `MappedLayout::new` works out where each part sits once, at attach, and +// the endpoint keeps the result beside the mapping. The payload pointer +// stays raw because writers hand out `&mut` slices into it, which must not +// overlap a shared reference. // // Neither counter guards its add against wrapping, because neither wrap is // reachable. The payload counter passes the region only after a claim has @@ -118,12 +117,15 @@ fn try_cast_aligned(ptr: *mut T) -> Option<*mut U> { if ptr.addr().is_multiple_of(align_of::()) { Some(ptr.cast()) } else { None } } -/// Where the [`Meta`] struct and the payload area sit in one mapping, -/// worked out once at attach. The pointers outlive this value, since they -/// point into the mapping rather than into the endpoint holding them. +/// Where the counters, the descriptor table and the payload area sit in +/// one mapping, worked out once at attach. The pointers outlive this +/// value, since they point into the mapping rather than into the endpoint +/// holding them. #[derive(Clone, Copy)] -pub struct MappedLayout { - meta: NonNull>, +pub struct MappedLayout { + counters: NonNull, + /// Every descriptor slot the region was created with. + table: NonNull<[AtomicU64]>, /// Start of the payload area, raw rather than a reference: writers /// hand out `&mut` slices into it, which must not overlap a shared /// reference. @@ -173,11 +175,16 @@ impl SlotState { } } -impl MappedLayout { - /// Locates the parts of a shared mapping, or returns `None` when the - /// mapping cannot hold the protocol: a null or misaligned start, too - /// little room for [`Meta`], or a payload area too long for a 32-bit - /// offset to reach. +impl MappedLayout { + /// Locates the parts of a shared mapping whose table holds `slots` + /// descriptors, or returns `None` when the mapping cannot hold the + /// protocol: a null or misaligned start, too little room for the + /// counters and that many slots, or a payload area too long for a + /// 32-bit offset to reach. + /// + /// Both endpoints must pass the `slots` the region was created with. + /// A smaller one reads part of the table as payload; a larger one + /// reads payload bytes as descriptors. /// /// # Safety /// @@ -186,46 +193,56 @@ impl MappedLayout { /// used. /// - The memory must have been zero-initialized when the region was /// created, and accessed only through this protocol since. - pub unsafe fn new(mem: *mut [u8]) -> Option { + pub unsafe fn new(mem: *mut [u8], slots: usize) -> Option { let mem_start = mem.cast::(); - // The mapping must hold the fixed struct. - let payload_len = mem.len().checked_sub(size_of::>())?; + // The mapping must hold the counters and the whole table. + let table_len = slots.checked_mul(size_of::())?; + let meta_len = size_of::().checked_add(table_len)?; + let payload_len = mem.len().checked_sub(meta_len)?; // A descriptor holds a 32-bit offset, so the payload area can be // no longer than a `u32`. Keeping the converted value is what lets // later bounds checks stay in 32 bits. let payload_len = u32::try_from(payload_len).ok()?; // These two conversions are the checks on the start address: - // aligned for `Meta`, and not null. - let meta = NonNull::new(try_cast_aligned::<_, Meta>(mem_start)?)?; + // aligned for `Counters`, and not null. + let counters = NonNull::new(try_cast_aligned::<_, Counters>(mem_start)?)?; - // The payload area is everything after the fixed struct. - // SAFETY: the mapping holds the struct (checked above). - let payload_start = NonNull::new(unsafe { mem_start.add(size_of::>()) })?; - Some(Self { meta, payload_start, payload_len }) + // The table sits right after the counters, and the payload area + // after the table. + // SAFETY: the mapping holds both (checked above), and the table's + // alignment follows from the start address being aligned for + // `Counters`, whose size is a multiple of that alignment. + let table_start = NonNull::new(unsafe { mem_start.add(size_of::()) })?; + let table = NonNull::slice_from_raw_parts(table_start.cast::(), slots); + // SAFETY: as above. + let payload_start = NonNull::new(unsafe { mem_start.add(meta_len) })?; + Some(Self { counters, table, payload_start, payload_len }) } - /// The fixed part of the region. - const fn meta(&self) -> &Meta { + /// The counters at the start of the region. + const fn counters(&self) -> &Counters { // SAFETY: `new`'s contract keeps the memory valid while any - // pointer is used, and `Meta` is all atomics, so the shared + // pointer is used, and both counters are atomics, so the shared // borrow is valid even while other threads and processes access // the same memory through them. - unsafe { self.meta.as_ref() } + unsafe { self.counters.as_ref() } } /// The claim counter. pub const fn claims(&self) -> &AtomicU64 { - &self.meta().claims + &self.counters().claims } /// The payload counter. pub const fn payload_reserved(&self) -> &AtomicU64 { - &self.meta().payload_reserved + &self.counters().payload_reserved } /// The descriptor table. pub const fn table(&self) -> &[AtomicU64] { - &self.meta().table + // SAFETY: see `counters`; the table is a run of atomics inside the + // same mapping. + unsafe { self.table.as_ref() } } } @@ -251,8 +268,14 @@ mod tests { } #[test] - fn meta_is_the_counters_then_the_table() { - assert!(size_of::>() == (2 + 15) * size_of::()); - assert!(align_of::>() == align_of::()); + fn a_region_too_small_for_the_table_is_rejected() { + let mut mem = [0u64; 4]; + let raw = std::ptr::slice_from_raw_parts_mut(mem.as_mut_ptr().cast::(), 32); + // Two counters and two slots exactly fill it; a third slot does not. + // SAFETY: the array is live, aligned and zeroed, and nothing else + // touches it. + assert!(unsafe { MappedLayout::new(raw, 2) }.is_some()); + // SAFETY: as above. + assert!(unsafe { MappedLayout::new(raw, 3) }.is_none()); } } diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs index 1ac2bcbdb..a3005caf7 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs @@ -25,11 +25,10 @@ mod writer; use std::ptr::slice_from_raw_parts_mut; use fspy_shm::Mapping; -// Only tests name the error types; production reports them through -// `Display` and matches on `Ok`/`Err` alone. -#[cfg(test)] -pub use reader::SealError; -pub use reader::ShmReader; +pub use layout::to_usize; +pub use reader::{SealError, ShmReader}; +// Only tests name a claim's failure; a sender skips the record either +// way, so production matches on `Ok`/`Err` alone. #[cfg(test)] pub use writer::ClaimError; pub use writer::ShmWriter; @@ -138,7 +137,7 @@ mod tests { fn collect_frames(shm: &MockedShm) -> ShmReader { // SAFETY: `MockedShm` provides a stable, zero-initialized allocation // accessed only through the protocol. - unsafe { ShmReader::seal::(shm.clone()) }.unwrap() + unsafe { ShmReader::seal(shm.clone(), S) }.unwrap() } #[test] @@ -146,7 +145,7 @@ mod tests { let shm = MockedShm::alloc(1024); // SAFETY: `MockedShm::alloc` provides a valid, properly-sized, // zero-initialized allocation. - let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }.unwrap(); + let writer = unsafe { ShmWriter::new(shm.clone(), S) }.unwrap(); assert!(writer.try_write_frame(b"hello")); assert!(writer.try_write_frame(b"world")); assert!(writer.try_write_frame(b"this is a test")); @@ -163,7 +162,7 @@ mod tests { fn zero_sized_frames_are_rejected() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }.unwrap(); + let writer = unsafe { ShmWriter::new(shm.clone(), S) }.unwrap(); assert!(writer.try_write_frame(b"hello")); assert!(!writer.try_write_frame(b"")); @@ -177,7 +176,7 @@ mod tests { fn frame_spanning_many_u64s_roundtrips_exactly() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }.unwrap(); + let writer = unsafe { ShmWriter::new(shm.clone(), S) }.unwrap(); let pattern: Vec = (0..=99).collect(); assert!(writer.try_write_frame(&pattern)); @@ -191,7 +190,7 @@ mod tests { fn full_region_fails_the_seal() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }.unwrap(); + let writer = unsafe { ShmWriter::new(shm.clone(), S) }.unwrap(); assert!(writer.try_write_frame(b"test")); @@ -207,7 +206,7 @@ mod tests { // "test" did land, but a lost record means the frames are not all // of them, so the seal hands back none of them. // SAFETY: see `collect_frames`. - let sealed = unsafe { ShmReader::seal::(shm) }; + let sealed = unsafe { ShmReader::seal(shm, S) }; assert!(sealed.unwrap_err() == SealError::Closed); } @@ -215,7 +214,7 @@ mod tests { fn oversized_frame_is_refused_and_fails_the_seal() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }.unwrap(); + let writer = unsafe { ShmWriter::new(shm.clone(), S) }.unwrap(); assert!(writer.try_write_frame(b"kept")); // No descriptor can describe a frame this long: the claim is @@ -228,7 +227,7 @@ mod tests { assert!(!writer.try_write_frame(b"refused")); // SAFETY: see `collect_frames`. - let sealed = unsafe { ShmReader::seal::(shm) }; + let sealed = unsafe { ShmReader::seal(shm, S) }; assert!(sealed.unwrap_err() == SealError::Closed); } @@ -236,7 +235,7 @@ mod tests { fn crash_after_claim_is_skipped() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }.unwrap(); + let writer = unsafe { ShmWriter::new(shm.clone(), S) }.unwrap(); assert!(writer.try_write_frame(b"foo")); // A crash right after claiming and an abandoned frame leave the @@ -257,7 +256,7 @@ mod tests { fn crash_during_partial_write_is_skipped() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }.unwrap(); + let writer = unsafe { ShmWriter::new(shm.clone(), S) }.unwrap(); assert!(writer.try_write_frame(b"foo")); // Simulate a crash during writing: the frame is abandoned @@ -282,7 +281,7 @@ mod tests { // receiver from finding the valid frames around them. let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }.unwrap(); + let writer = unsafe { ShmWriter::new(shm.clone(), S) }.unwrap(); assert!(writer.try_write_frame(b"foo")); @@ -308,7 +307,7 @@ mod tests { fn abandoned_frame_is_ignored() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }.unwrap(); + let writer = unsafe { ShmWriter::new(shm.clone(), S) }.unwrap(); assert!(writer.try_write_frame(b"foo")); // Dropping an unfinished frame abandons it: the receiver ignores @@ -327,7 +326,7 @@ mod tests { // on the slot side while payload space remains. let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }.unwrap(); + let writer = unsafe { ShmWriter::new(shm.clone(), S) }.unwrap(); for _ in 0..15 { assert!(writer.try_write_frame(b"x")); } @@ -337,7 +336,7 @@ mod tests { // Fifteen frames landed, but the sixteenth was lost. // SAFETY: see `collect_frames`. - let sealed = unsafe { ShmReader::seal::(shm) }; + let sealed = unsafe { ShmReader::seal(shm, S) }; assert!(sealed.unwrap_err() == SealError::Closed); } @@ -345,7 +344,7 @@ mod tests { fn claims_after_seal_are_gated_without_poisoning() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }.unwrap(); + let writer = unsafe { ShmWriter::new(shm.clone(), S) }.unwrap(); assert!(writer.try_write_frame(b"foo")); assert!(!writer.is_closed()); @@ -370,7 +369,7 @@ mod tests { fn commit_after_seal_shows_up_in_a_later_read() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }.unwrap(); + let writer = unsafe { ShmWriter::new(shm.clone(), S) }.unwrap(); let mut frame = writer.claim_frame(5.try_into().unwrap()).unwrap(); frame.copy_from_slice(b"late!"); @@ -389,7 +388,7 @@ mod tests { // A second seal fails: the first one set the gate, and a re-seal // cannot say what was refused since then. // SAFETY: see `collect_frames`. - let sealed = unsafe { ShmReader::seal::(shm) }; + let sealed = unsafe { ShmReader::seal(shm, S) }; assert!(sealed.unwrap_err() == SealError::Closed); } @@ -406,7 +405,7 @@ mod tests { // SAFETY: see `single_thread_basic`. The clone shares the // same backing memory, which is safe because the protocol // synchronizes concurrent access with atomics. - let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }.unwrap(); + let writer = unsafe { ShmWriter::new(shm.clone(), S) }.unwrap(); for _ in 0..10 { assert!(writer.try_write_frame(b"hello")); assert!(writer.try_write_frame(b"foo")); @@ -417,7 +416,7 @@ mod tests { }); // SAFETY: see `collect_frames`. - let frames = unsafe { ShmReader::seal::(shm) }.unwrap(); + let frames = unsafe { ShmReader::seal(shm, S) }.unwrap(); let mut count = 0; for frame in &frames { count += 1; @@ -431,7 +430,7 @@ mod tests { fn concurrent_exceeded_size() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }.unwrap(); + let writer = unsafe { ShmWriter::new(shm.clone(), S) }.unwrap(); thread::scope(|s| { for _ in 0..4 { s.spawn(|| { @@ -449,7 +448,7 @@ mod tests { // seal. The writers all survived it. assert!(writer.is_closed()); // SAFETY: see `collect_frames`. - let sealed = unsafe { ShmReader::seal::(shm) }; + let sealed = unsafe { ShmReader::seal(shm, S) }; assert!(sealed.unwrap_err() == SealError::Closed); } @@ -464,7 +463,7 @@ mod tests { let writers = [(); 2].map(|()| { s.spawn(|| { // SAFETY: see `concurrent`. - let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }.unwrap(); + let writer = unsafe { ShmWriter::new(shm.clone(), S) }.unwrap(); barrier.wait(); let mut written = 0usize; // Bounded so the test terminates even if the seal is slow; @@ -486,7 +485,7 @@ mod tests { barrier.wait(); // SAFETY: see `collect_frames`. - let frames = unsafe { ShmReader::seal::(shm.clone()) }.unwrap(); + let frames = unsafe { ShmReader::seal(shm.clone(), S) }.unwrap(); let results = writers.map(|writer| writer.join().unwrap()); (frames, results) }); @@ -511,7 +510,7 @@ mod tests { fn overshot_claim_counter_clamps_to_the_table() { let shm = MockedShm::alloc(1024); // SAFETY: see `single_thread_basic`. - let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(shm.clone()) }.unwrap(); + let writer = unsafe { ShmWriter::new(shm.clone(), S) }.unwrap(); assert!(writer.try_write_frame(b"hello")); // A wildly inflated claim counter, from mass claim failures or a @@ -549,7 +548,7 @@ mod tests { // SAFETY: the wrapped allocation is valid; only its alignment is // deliberately wrong. - assert!(unsafe { ShmWriter::<_, S>::new(misaligned_shm) }.is_none()); + assert!(unsafe { ShmWriter::new(misaligned_shm, S) }.is_none()); } #[test] @@ -590,7 +589,7 @@ mod tests { // SAFETY: `mapping` is a freshly mapped shared memory // region with a valid pointer and size; the protocol // synchronizes concurrent access. - let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(mapping) }.unwrap(); + let writer = unsafe { ShmWriter::new(mapping, S) }.unwrap(); for i in 0..FRAME_COUNT_EACH_CHILD { let frame_data = std::format!("{child_index} {i}"); assert!(writer.try_write_frame(frame_data.as_bytes())); @@ -608,7 +607,7 @@ mod tests { // SAFETY: the mapping is a valid shared-memory region created zeroed // and accessed only through the protocol. - let frames = unsafe { ShmReader::seal::(mapping) }.unwrap(); + let frames = unsafe { ShmReader::seal(mapping, S) }.unwrap(); let collected = frames.iter().map(BStr::new).collect::>(); assert!(collected.len() == CHILD_COUNT * FRAME_COUNT_EACH_CHILD); for child_index in 0..CHILD_COUNT { @@ -645,7 +644,7 @@ mod tests { let c_path = crate::ipc::channel::os_c_string(std::ffi::OsStr::new(&shm_name)).unwrap(); let child_mapping = fspy_shm::open(c_path.as_c_str().as_thin()).unwrap().map().unwrap(); // SAFETY: see `real_shm_across_processes`. - let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(child_mapping) }.unwrap(); + let writer = unsafe { ShmWriter::new(child_mapping, S) }.unwrap(); let mut frame = writer.claim_frame(5.try_into().unwrap()).unwrap(); frame[..3].copy_from_slice(b"wor"); // Signal the parent that the frame is claimed and partially @@ -672,11 +671,11 @@ mod tests { // A surviving writer keeps working after the kill. It borrows the // mapping so the seal below can take it over. // SAFETY: see `real_shm_across_processes`. - let writer: ShmWriter<_, S> = unsafe { ShmWriter::new(&mapping) }.unwrap(); + let writer = unsafe { ShmWriter::new(&mapping, S) }.unwrap(); assert!(writer.try_write_frame(b"alive")); // SAFETY: see `real_shm_across_processes`. - let frames = unsafe { ShmReader::seal::(mapping) }.unwrap(); + let frames = unsafe { ShmReader::seal(mapping, S) }.unwrap(); let mut iter = frames.iter(); assert!(iter.next().unwrap() == b"alive"); assert!(iter.next() == None); diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs index e01d4d630..a2b399d2d 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/reader.rs @@ -83,17 +83,18 @@ impl ShmReader { /// whole region for the reader's lifetime. /// - The region must have been zero-initialized when it was created and /// accessed only through this protocol since. + /// - `slots` must be the count the region was created with. /// /// # Errors /// /// [`SealError`]: the mapping cannot hold the protocol, or the channel /// was already closed before this call. - pub unsafe fn seal(mem: M) -> Result { + pub unsafe fn seal(mem: M, slots: usize) -> Result { // SAFETY: forwarded from this function's contract, which keeps the // region valid for as long as the reader lives, and so for every // use of the pointers, which are stored in the reader and dropped // with it. - let Some(mapped) = (unsafe { MappedLayout::::new(mem.as_raw_slice()) }) else { + let Some(mapped) = (unsafe { MappedLayout::new(mem.as_raw_slice(), slots) }) else { return Err(SealError::UnsupportedRegion); }; @@ -124,12 +125,10 @@ impl ShmReader { let admitted = slots.get(..to_usize(claims)).unwrap_or(slots); // The admitted slots, kept as a raw pointer so the reader needs - // no lifetime and no `SLOTS`. + // no lifetime. // SAFETY of every later read through it: it points into the - // mapping this reader owns, and every byte of `Meta` is inside an - // `AtomicU64`, so a writer storing a descriptor never invalidates - // it. That last part stops holding if `Meta` ever gains a field - // that is not an atomic. + // mapping this reader owns, and a slot is an `AtomicU64`, so a + // writer storing a descriptor never invalidates it. let table = NonNull::from_ref(admitted); Ok(Self { payload_start: mapped.payload_start, table, _mem: mem }) } @@ -144,10 +143,10 @@ impl ShmReader { Iter { payload_start: self.payload_start, // SAFETY: the slots live in the mapping this reader owns, and - // every byte of them is inside an `AtomicU64`, so writers - // storing descriptors through their own pointers never - // invalidate this borrow. It lasts no longer than `&self`, - // and so no longer than the mapping. + // each one is an `AtomicU64`, so writers storing descriptors + // through their own pointers never invalidate this borrow. It + // lasts no longer than `&self`, and so no longer than the + // mapping. table: unsafe { self.table.as_ref() }, } } diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs index bc9a17aee..9cda0ab33 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/writer.rs @@ -16,8 +16,8 @@ use super::{ /// once. Each frame is reserved atomically, filled in a span no one else /// can touch, and published with one atomic write (the ordering contract /// in [`super::layout`]). -pub struct ShmWriter { - mapped: MappedLayout, +pub struct ShmWriter { + mapped: MappedLayout, /// Owns the region the pointers point into. Declared after them: /// fields drop in order, and the borrower must go first. _mem: M, @@ -27,10 +27,10 @@ pub struct ShmWriter { // atomics, which synchronize access from any thread; the stored pointers // point into the mapped memory, which is owned separately and does not // move, not into the writer itself. -unsafe impl Send for ShmWriter {} +unsafe impl Send for ShmWriter {} // SAFETY: see the `Send` impl; the writer's shared-reference API is // internally synchronized by the protocol. -unsafe impl Sync for ShmWriter {} +unsafe impl Sync for ShmWriter {} /// Why a frame could not be claimed. #[derive(thiserror::Error, Clone, Copy, PartialEq, Eq, Debug)] @@ -47,10 +47,11 @@ pub enum ClaimError { Capacity, } -impl ShmWriter { - /// Creates a writer on a shared-memory region, or `None` when the - /// region cannot hold the protocol (see [`MappedLayout::new`]), as a - /// truncated or unrelated file cannot. +impl ShmWriter { + /// Creates a writer on a shared-memory region whose table holds + /// `slots` descriptors, or `None` when the region cannot hold the + /// protocol (see [`MappedLayout::new`]), as a truncated or unrelated + /// file cannot. /// /// # Safety /// @@ -58,12 +59,13 @@ impl ShmWriter { /// whole region for the writer's lifetime. /// - The region must have been zero-initialized when it was created and /// accessed only through this protocol since. - pub unsafe fn new(mem: M) -> Option { + /// - `slots` must be the count the region was created with. + pub unsafe fn new(mem: M, slots: usize) -> Option { // SAFETY: forwarded from this function's contract, which keeps the // region valid, and used only by this protocol, for as long as the // writer lives, and so for every use of the pointers, which are // stored in the writer and dropped with it. - let mapped = unsafe { MappedLayout::new(mem.as_raw_slice()) }?; + let mapped = unsafe { MappedLayout::new(mem.as_raw_slice(), slots) }?; Some(Self { mapped, _mem: mem }) } diff --git a/crates/fspy_shared/src/ipc/mod.rs b/crates/fspy_shared/src/ipc/mod.rs index 9c49e7371..43a127196 100644 --- a/crates/fspy_shared/src/ipc/mod.rs +++ b/crates/fspy_shared/src/ipc/mod.rs @@ -8,6 +8,22 @@ pub use fspy_ipc_str::IpcStr; pub use ipc_path::IpcPath; use wincode::{SchemaRead, SchemaWrite}; +/// How much shared memory a channel gets, and how many records fit in it. +/// +/// Both numbers come from the caller: this crate has no way to guess how +/// many records a workload makes. Both ends of one channel must agree on +/// the slot count, which the receiver passes to `channel` and every sender +/// reads back out of the `ChannelConf`. +#[derive(Clone, Copy, Debug)] +pub struct ChannelSize { + /// Bytes of shared memory. The descriptor table takes the front of it + /// and payloads take the rest. + pub capacity: usize, + /// Descriptor slots, one per record. A record past this many is + /// refused just like one the payload area has no room for. + pub slots: usize, +} + #[derive(SchemaWrite, SchemaRead, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)] pub struct AccessMode(u8); diff --git a/crates/subprocess_test/src/lib.rs b/crates/subprocess_test/src/lib.rs index f9f81b970..a674da293 100644 --- a/crates/subprocess_test/src/lib.rs +++ b/crates/subprocess_test/src/lib.rs @@ -24,10 +24,15 @@ impl From for StdCommand { } } +/// Shared memory for a test subprocess's file-access records: sparse +/// address space, so a generous size costs nothing until it is used. +#[cfg(feature = "fspy")] +const TEST_SHM: fspy::ChannelSize = fspy::ChannelSize { capacity: 1 << 30, slots: 1 << 24 }; + #[cfg(feature = "fspy")] impl From for fspy::Command { fn from(cmd: Command) -> Self { - let mut fspy_cmd = Self::new(cmd.program); + let mut fspy_cmd = Self::new(cmd.program, TEST_SHM); fspy_cmd.args(cmd.args).envs(cmd.envs); fspy_cmd.current_dir(cmd.cwd); fspy_cmd diff --git a/crates/vt/src/session/event.rs b/crates/vt/src/session/event.rs index be9210e18..e59f58186 100644 --- a/crates/vt/src/session/event.rs +++ b/crates/vt/src/session/event.rs @@ -99,6 +99,12 @@ pub enum CacheNotUpdatedReason { /// A runner-aware tool explicitly requested that this run not be cached /// (e.g. vite dev-server, a watch task). ToolRequested, + /// A tracked process could not record a file access it went on to + /// perform, because the task made more of them than the tracking + /// channel had room for. The accesses that did arrive are a subset of + /// what the task touched, so caching from them would bake in inputs + /// and outputs that are not all of them. + TrackingIncomplete, } #[derive(Debug)] diff --git a/crates/vt/src/session/execute/cache_update.rs b/crates/vt/src/session/execute/cache_update.rs index f13d3f1df..7ebad7c24 100644 --- a/crates/vt/src/session/execute/cache_update.rs +++ b/crates/vt/src/session/execute/cache_update.rs @@ -89,6 +89,14 @@ pub(super) async fn update_cache( return (CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::NonZeroExitStatus), None); } + if tracking_fell_short(outcome, metadata, fspy) { + // The task made more file accesses than the tracking channel had + // room for, so what arrived is a subset of what it touched. An + // entry built from a subset would replay with inputs and outputs + // missing. + return (CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::TrackingIncomplete), None); + } + let fspy_outcome = observe_fspy( outcome, metadata, @@ -184,6 +192,31 @@ pub(super) async fn update_cache( } } +/// Whether the run's tracking came up short of what it needs to be +/// cached: the accesses this run inferred are incomplete, and the task's +/// config asks for inferred ones. +/// +/// A task that spells out every input and output does not consult them, so +/// a short trace costs it nothing. +fn tracking_fell_short( + outcome: &ChildOutcome, + metadata: &CacheMetadata, + fspy: Option<&super::FspyTracking<'_>>, +) -> bool { + #[cfg(fspy)] + { + let infers = metadata.input_config.includes_auto || metadata.output_config.includes_auto; + fspy.is_some() + && infers + && outcome.path_accesses.as_ref().is_some_and(|raw| !raw.is_complete()) + } + #[cfg(not(fspy))] + { + let _ = (outcome, metadata, fspy); + false + } +} + /// Summarize the run's fspy observations. `Some` iff tracking was both /// requested (`tracking.fspy.is_some()`) and compiled in (`cfg(fspy)`). On a /// `cfg(not(fspy))` build this is always `None`, and [`update_cache`] diff --git a/crates/vt/src/session/execute/spawn.rs b/crates/vt/src/session/execute/spawn.rs index adff8aac9..0a6c5294c 100644 --- a/crates/vt/src/session/execute/spawn.rs +++ b/crates/vt/src/session/execute/spawn.rs @@ -14,6 +14,37 @@ use tokio::process::{ChildStderr, ChildStdout}; use tokio_util::sync::CancellationToken; use vt_plan::SpawnCommand; +/// Shared memory for one tracked task's file-access records. 4 GiB of +/// sparse address space: none of it becomes real memory until records +/// land in it, and it leaves room for tens of millions of accesses. +#[cfg(fspy)] +const FSPY_SHM_CAPACITY: usize = 4 << 30; + +/// Bytes of the region per record: 8 for its descriptor slot and 56 for +/// its payload. Path records run a few hundred bytes each, so payload +/// space runs out well before slots do. +#[cfg(fspy)] +const FSPY_SHM_BYTES_PER_RECORD: usize = 64; + +/// Overrides [`FSPY_SHM_CAPACITY`] with a byte count. Internal: it exists +/// so tests can shrink the region until a task overruns it, and nothing +/// outside this repository should set it. +#[cfg(fspy)] +const FSPY_SHM_CAPACITY_ENV: &str = "VP_RUN_INTERNAL_FSPY_SHM_CAPACITY"; + +/// The shared memory each tracked task gets. Read once, since a run's +/// tasks all get the same size. +#[cfg(fspy)] +static FSPY_SHM: std::sync::LazyLock = std::sync::LazyLock::new(|| { + let capacity = std::env::var_os(FSPY_SHM_CAPACITY_ENV).map_or(FSPY_SHM_CAPACITY, |value| { + value + .to_str() + .and_then(|value| value.parse().ok()) + .unwrap_or_else(|| panic!("{FSPY_SHM_CAPACITY_ENV} is not a byte count: {value:?}")) + }); + fspy::ChannelSize { capacity, slots: capacity / FSPY_SHM_BYTES_PER_RECORD } +}); + /// How the child's stdin/stdout/stderr are configured. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SpawnStdio { @@ -99,7 +130,7 @@ where K: AsRef, V: AsRef, { - let mut fspy_cmd = fspy::Command::new(cmd.program_path.as_path()); + let mut fspy_cmd = fspy::Command::new(cmd.program_path.as_path(), *FSPY_SHM); fspy_cmd.args(cmd.args.iter().map(vt_str::Str::as_str)); fspy_cmd.envs(cmd.spawn_envs.iter()); fspy_cmd.envs(extra_envs); diff --git a/crates/vt/src/session/reporter/summary.rs b/crates/vt/src/session/reporter/summary.rs index c9da7dff2..595dac868 100644 --- a/crates/vt/src/session/reporter/summary.rs +++ b/crates/vt/src/session/reporter/summary.rs @@ -111,6 +111,12 @@ pub enum SpawnOutcome { /// Rendered message of the IPC server error that caused the cache to /// be skipped, if any. ipc_server_error: Option, + /// `true` when the task made more file accesses than tracking had + /// room for, so the inferred inputs and outputs were a subset of + /// what it touched. Task ran successfully but cache was not + /// updated. + #[serde(default)] + tracking_incomplete: bool, /// Set when a runner-aware tool called `disableCache()`, skipping /// cache update. tool_disabled_cache: bool, @@ -343,6 +349,10 @@ impl TaskResult { cache_update_status, CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::ToolRequested) ); + let tracking_incomplete = matches!( + cache_update_status, + CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::TrackingIncomplete) + ); match cache_status { CacheStatus::Hit { replayed_duration } => { @@ -358,6 +368,7 @@ impl TaskResult { fspy_unsupported, ipc_server_error, tool_disabled_cache, + tracking_incomplete, ), }, CacheStatus::Miss(cache_miss) => Self::Spawned { @@ -371,6 +382,7 @@ impl TaskResult { fspy_unsupported, ipc_server_error, tool_disabled_cache, + tracking_incomplete, ), }, } @@ -385,6 +397,7 @@ fn spawn_outcome_from_execution( fspy_unsupported: bool, ipc_server_error: Option, tool_disabled_cache: bool, + tracking_incomplete: bool, ) -> SpawnOutcome { match (exit_status, saved_error) { // Spawn error — process never ran @@ -396,6 +409,7 @@ fn spawn_outcome_from_execution( fspy_unsupported, ipc_server_error, tool_disabled_cache, + tracking_incomplete, }, // Process exited with non-zero code (Some(status), _) => { @@ -416,6 +430,7 @@ fn spawn_outcome_from_execution( fspy_unsupported: false, ipc_server_error: None, tool_disabled_cache: false, + tracking_incomplete: false, }, } } @@ -554,6 +569,17 @@ impl TaskResult { { return vt_str::format!("→ Not cached: read and wrote '{path}'"); } + // Tracking came up short, so the inferred inputs and outputs would + // have been a subset of what the task touched. + if let Self::Spawned { + outcome: SpawnOutcome::Success { tracking_incomplete: true, .. }, + .. + } = self + { + return Str::from( + "→ Not cached: the task made more file accesses than could be tracked", + ); + } // fspy-unsupported-on-this-OS message — same overrides precedence as above if let Self::Spawned { outcome: SpawnOutcome::Success { fspy_unsupported: true, .. }, .. diff --git a/crates/vt_bin/src/vtt/main.rs b/crates/vt_bin/src/vtt/main.rs index 65a12f8b9..262840135 100644 --- a/crates/vt_bin/src/vtt/main.rs +++ b/crates/vt_bin/src/vtt/main.rs @@ -27,6 +27,7 @@ mod rm; mod small_dev_shm; mod stat_file; mod stat_long_filename; +mod stat_many; mod touch_file; mod write_file; @@ -35,7 +36,7 @@ fn main() { if args.len() < 2 { eprintln!("Usage: vtt [args...]"); eprintln!( - "Subcommands: barrier, check-tty, cp, exit, exit-on-ctrlc, grep-file, list-dir, mkdir, pipe-stdin, print, print-color, print-cwd, print-env, print-file, read-stdin, replace-file-content, rm, small_dev_shm, stat-file, stat_long_filename, touch-file, write-file" + "Subcommands: barrier, check-tty, cp, exit, exit-on-ctrlc, grep-file, list-dir, mkdir, pipe-stdin, print, print-color, print-cwd, print-env, print-file, read-stdin, replace-file-content, rm, small_dev_shm, stat-file, stat_long_filename, stat-many, touch-file, write-file" ); std::process::exit(1); } @@ -76,6 +77,7 @@ fn main() { Ok(()) } "stat_long_filename" => stat_long_filename::run(&args[2..]), + "stat-many" => stat_many::run(&args[2..]), "touch-file" => touch_file::run(&args[2..]), "write-file" => write_file::run(&args[2..]), other => { diff --git a/crates/vt_bin/src/vtt/stat_many.rs b/crates/vt_bin/src/vtt/stat_many.rs new file mode 100644 index 000000000..d59e3265e --- /dev/null +++ b/crates/vt_bin/src/vtt/stat_many.rs @@ -0,0 +1,19 @@ +//! Stats a run of generated names, to make a known number of tracked file +//! accesses. The names are missing on purpose: an access is recorded +//! whether or not the file is there, and nothing is left behind. + +use std::error::Error; + +const USAGE: &str = "Usage: vtt stat-many "; + +pub fn run(args: &[String]) -> Result<(), Box> { + let [count] = args else { return Err(USAGE.into()) }; + let count: usize = count.parse().map_err(|_| USAGE)?; + for index in 0..count { + let _ = std::fs::metadata(format!("vtt-stat-many-{index}")); + } + // Printing last proves the process survived every one of them, which + // is what a channel that stops accepting records must not disturb. + println!("stat {count}"); + Ok(()) +} diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/package.json b/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/package.json new file mode 100644 index 000000000..5fe27cb1c --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/package.json @@ -0,0 +1,4 @@ +{ + "name": "fspy-shm-capacity", + "private": true +} diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/snapshots.toml b/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/snapshots.toml new file mode 100644 index 000000000..d1deb04e4 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/snapshots.toml @@ -0,0 +1,24 @@ +[[e2e]] +name = "capacity_exhaustion_leaves_the_task_alone" +comment = """ +A task that reports more file accesses than its tracking channel can hold keeps running to the end: the accesses go unrecorded, but nothing kills the process over it. The run is not cached, because the accesses that did arrive are only some of the ones the task made, and the second run is a miss for the same reason rather than replaying a wrong entry. +""" +steps = [ + { argv = [ + "vt", + "run", + "-v", + "stat", + ], envs = [ + [ + "VP_RUN_INTERNAL_FSPY_SHM_CAPACITY", + "65536", + ], + ], comment = "channel too small for the task's accesses" }, + { argv = [ + "vt", + "run", + "-v", + "stat", + ], comment = "nothing was cached to replay" }, +] diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/snapshots/capacity_exhaustion_leaves_the_task_alone.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/snapshots/capacity_exhaustion_leaves_the_task_alone.md new file mode 100644 index 000000000..d511ade8c --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/snapshots/capacity_exhaustion_leaves_the_task_alone.md @@ -0,0 +1,49 @@ +# capacity_exhaustion_leaves_the_task_alone + +A task that reports more file accesses than its tracking channel can hold keeps running to the end: the accesses go unrecorded, but nothing kills the process over it. The run is not cached, because the accesses that did arrive are only some of the ones the task made, and the second run is a miss for the same reason rather than replaying a wrong entry. + +## `VP_RUN_INTERNAL_FSPY_SHM_CAPACITY=65536 vt run -v stat` + +channel too small for the task's accesses + +``` +$ vtt stat-many 20000 +stat 20000 + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Vite+ Task Runner • Execution Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Statistics: 1 tasks • 0 cache hits • 1 cache misses +Performance: 0% cache hit rate + +Task Details: +──────────────────────────────────────────────── + [1] fspy-shm-capacity#stat: $ vtt stat-many 20000 ✓ + → Not cached: the task made more file accesses than could be tracked +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` + +## `vt run -v stat` + +nothing was cached to replay + +``` +$ vtt stat-many 20000 +stat 20000 + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Vite+ Task Runner • Execution Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Statistics: 1 tasks • 0 cache hits • 1 cache misses +Performance: 0% cache hit rate + +Task Details: +──────────────────────────────────────────────── + [1] fspy-shm-capacity#stat: $ vtt stat-many 20000 ✓ + → Cache miss: no previous cache entry found +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/vite-task.json b/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/vite-task.json new file mode 100644 index 000000000..fce05d408 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/vite-task.json @@ -0,0 +1,8 @@ +{ + "tasks": { + "stat": { + "command": "vtt stat-many 20000", + "cache": true + } + } +} From f7a228472bb813456d0af209a93c7fbbb8ceac81 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Mon, 17 Aug 2026 14:23:23 +0800 Subject: [PATCH 77/92] feat(fspy): let the caller size the tracking channel The shared memory a tracked run reports its file accesses through was a constant in `fspy`, four gibibytes wide. How many accesses a program makes is the runner's business rather than the tracer's, and nothing could ask for a different size, so no test could put a task in front of a channel too small for it. `Command::shm_capacity` sets it, and the runner reads `VP_RUN_INTERNAL_FSPY_SHM_CAPACITY` for the value, keeping the same four gibibytes when the variable is unset. The variable is internal: it exists so a test can shrink the channel until a task overruns it, and nothing outside this repository should set it. A builder method rather than a second argument to `Command::new`, because the benchmark measures both revisions of `fspy` with a single launcher, overlaying the head's launcher source onto the baseline checkout. A launcher calling a signature only the head has cannot build the baseline arm. Leaving `new` alone also keeps the e2e tool, the examples and fspy's own tests out of this, since none of them care what size they get. The e2e case that comes with it stats one 2 MiB path, the largest single record tracking can be asked to hold, under a 64 MiB channel. That leaves room to spare, so the run caches like any other, which is what tells us the size arrived. The interesting case, a channel with no room for the record, has to wait: today it aborts the task process, and the panic it prints carries a thread id, a toolchain path, a backtrace and a platform's own abort code, none of which snapshot the same way twice. `vtt stat_long_filename` needed one fix to run there at all. Windows reports an over-long name as `ERROR_FILENAME_EXCED_RANGE`, which arrives as `InvalidFilename` rather than the `ENAMETOOLONG` unix returns, so the command exited 1 where it means to carry on: it exists to have the access attempted and recorded, not to find a file. Co-Authored-By: Claude Opus 5 --- crates/fspy/src/command.rs | 26 ++++++++++ crates/fspy/src/ipc.rs | 5 -- crates/fspy/src/lib.rs | 2 +- crates/fspy/src/unix/mod.rs | 4 +- crates/fspy/src/windows/mod.rs | 9 ++-- crates/vt/src/session/execute/spawn.rs | 19 +++++++ crates/vt_bin/src/vtt/stat_long_filename.rs | 19 ++++--- .../fixtures/fspy_shm_capacity/package.json | 4 ++ .../fixtures/fspy_shm_capacity/snapshots.toml | 26 ++++++++++ ...capacity_env_sizes_the_tracking_channel.md | 49 +++++++++++++++++++ .../fixtures/fspy_shm_capacity/vite-task.json | 8 +++ 11 files changed, 152 insertions(+), 19 deletions(-) create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/package.json create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/snapshots.toml create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/snapshots/shm_capacity_env_sizes_the_tracking_channel.md create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/vite-task.json diff --git a/crates/fspy/src/command.rs b/crates/fspy/src/command.rs index fb150b26c..4684a01bb 100644 --- a/crates/fspy/src/command.rs +++ b/crates/fspy/src/command.rs @@ -12,9 +12,23 @@ use tokio_util::sync::CancellationToken; use crate::{SPY_IMPL, TrackedChild, error::SpawnError}; +/// Shared memory for a tracked run's file-access records when the caller +/// does not say otherwise. +/// +/// 4 GiB of sparse address space: none of it becomes real memory until +/// records land in it, and it leaves room for tens of millions of +/// accesses. +pub const DEFAULT_SHM_CAPACITY: usize = 4 << 30; + #[derive(derive_more::Debug)] pub struct Command { program: OsString, + /// Bytes of shared memory for this run's file-access records. + #[cfg_attr( + target_env = "musl", + expect(dead_code, reason = "musl builds track through seccomp, with no channel to size") + )] + pub(crate) shm_capacity: usize, args: Vec, envs: FxHashMap, cwd: Option, @@ -37,6 +51,7 @@ impl Command { pub fn new>(program: P) -> Self { Self { program: program.as_ref().to_os_string(), + shm_capacity: DEFAULT_SHM_CAPACITY, args: Vec::new(), envs: FxHashMap::default(), cwd: None, @@ -113,6 +128,17 @@ impl Command { self } + /// Sizes the shared memory this run's file-access records go through, + /// in bytes. + /// + /// How many accesses a program makes is the caller's business rather + /// than this crate's, so a caller that knows better than + /// [`DEFAULT_SHM_CAPACITY`] says so here. + pub const fn shm_capacity(&mut self, bytes: usize) -> &mut Self { + self.shm_capacity = bytes; + self + } + pub fn env(&mut self, key: K, val: V) -> &mut Self where K: AsRef, diff --git a/crates/fspy/src/ipc.rs b/crates/fspy/src/ipc.rs index 51d498600..af10a29b3 100644 --- a/crates/fspy/src/ipc.rs +++ b/crates/fspy/src/ipc.rs @@ -6,11 +6,6 @@ use fspy_shared::ipc::{ }; use tokio::task::spawn_blocking; -// Shared memory size for storing path accesses. -// 4 GiB is large enough to store path accesses in almost any realistic scenario. -// This doesn't allocate physical memory until it's actually used. -pub const SHM_CAPACITY: usize = 4 * 1024 * 1024 * 1024; - #[ouroboros::self_referencing] pub struct OwnedReceiverLockGuard { /// Owns the shared memory diff --git a/crates/fspy/src/lib.rs b/crates/fspy/src/lib.rs index 6c89414ba..d8b58b46c 100644 --- a/crates/fspy/src/lib.rs +++ b/crates/fspy/src/lib.rs @@ -19,7 +19,7 @@ mod command; use std::{env::temp_dir, fs::create_dir, io, process::ExitStatus, sync::LazyLock}; -pub use command::Command; +pub use command::{Command, DEFAULT_SHM_CAPACITY}; pub use fspy_shared::ipc::{AccessMode, PathAccess}; use futures_util::future::BoxFuture; pub use os_impl::PathAccessIterable; diff --git a/crates/fspy/src/unix/mod.rs b/crates/fspy/src/unix/mod.rs index f1d657436..2c97aad42 100644 --- a/crates/fspy/src/unix/mod.rs +++ b/crates/fspy/src/unix/mod.rs @@ -25,7 +25,7 @@ use tokio::task::spawn_blocking; use tokio_util::sync::CancellationToken; #[cfg(not(target_env = "musl"))] -use crate::ipc::{OwnedReceiverLockGuard, SHM_CAPACITY}; +use crate::ipc::OwnedReceiverLockGuard; use crate::{ChildTermination, Command, TrackedChild, arena::PathAccessArena, error::SpawnError}; #[derive(Debug)] @@ -80,7 +80,7 @@ impl SpyImpl { #[cfg(not(target_env = "musl"))] let (ipc_channel_conf, ipc_receiver) = - channel(SHM_CAPACITY).map_err(SpawnError::ChannelCreation)?; + channel(command.shm_capacity).map_err(SpawnError::ChannelCreation)?; let payload = Payload { #[cfg(not(target_env = "musl"))] diff --git a/crates/fspy/src/windows/mod.rs b/crates/fspy/src/windows/mod.rs index c468888a6..66966e67f 100644 --- a/crates/fspy/src/windows/mod.rs +++ b/crates/fspy/src/windows/mod.rs @@ -21,10 +21,8 @@ use winapi::{ use winsafe::co::{CP, WC}; use crate::{ - ChildTermination, TrackedChild, - command::Command, - error::SpawnError, - ipc::{OwnedReceiverLockGuard, SHM_CAPACITY}, + ChildTermination, TrackedChild, command::Command, error::SpawnError, + ipc::OwnedReceiverLockGuard, }; const INTERPOSE_CDYLIB: Artifact = @@ -82,12 +80,13 @@ impl SpyImpl { ) -> Result { let ansi_dll_path_with_nul = Arc::clone(&self.ansi_dll_path_with_nul); command.env("FSPY", "1"); + let shm_capacity = command.shm_capacity; let mut command = command.into_tokio_command(); command.creation_flags(CREATE_SUSPENDED); let (channel_conf, receiver) = - channel(SHM_CAPACITY).map_err(SpawnError::ChannelCreation)?; + channel(shm_capacity).map_err(SpawnError::ChannelCreation)?; let mut spawn_success = false; let spawn_success = &mut spawn_success; diff --git a/crates/vt/src/session/execute/spawn.rs b/crates/vt/src/session/execute/spawn.rs index adff8aac9..fe1dbd58a 100644 --- a/crates/vt/src/session/execute/spawn.rs +++ b/crates/vt/src/session/execute/spawn.rs @@ -14,6 +14,24 @@ use tokio::process::{ChildStderr, ChildStdout}; use tokio_util::sync::CancellationToken; use vt_plan::SpawnCommand; +/// Sets the shared memory a tracked task reports its file accesses +/// through, in bytes, in place of the size fspy would pick. Internal: it +/// exists so tests can shrink the channel until a task overruns it, and +/// nothing outside this repository should set it. +#[cfg(fspy)] +const FSPY_SHM_CAPACITY_ENV: &str = "VP_RUN_INTERNAL_FSPY_SHM_CAPACITY"; + +/// The shared memory each tracked task gets. Read once, since a run's +/// tasks all get the same size. +#[cfg(fspy)] +static FSPY_SHM_CAPACITY: std::sync::LazyLock = std::sync::LazyLock::new(|| { + std::env::var_os(FSPY_SHM_CAPACITY_ENV).map_or(fspy::DEFAULT_SHM_CAPACITY, |value| { + value.to_str().and_then(|value| value.parse().ok()).unwrap_or_else(|| { + panic!("{FSPY_SHM_CAPACITY_ENV} is not a byte count: {}", value.display()) + }) + }) +}); + /// How the child's stdin/stdout/stderr are configured. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SpawnStdio { @@ -100,6 +118,7 @@ where V: AsRef, { let mut fspy_cmd = fspy::Command::new(cmd.program_path.as_path()); + fspy_cmd.shm_capacity(*FSPY_SHM_CAPACITY); fspy_cmd.args(cmd.args.iter().map(vt_str::Str::as_str)); fspy_cmd.envs(cmd.spawn_envs.iter()); fspy_cmd.envs(extra_envs); diff --git a/crates/vt_bin/src/vtt/stat_long_filename.rs b/crates/vt_bin/src/vtt/stat_long_filename.rs index 1b44b1453..1a370ef7e 100644 --- a/crates/vt_bin/src/vtt/stat_long_filename.rs +++ b/crates/vt_bin/src/vtt/stat_long_filename.rs @@ -24,16 +24,23 @@ fn access_generated_path( let path = generated_path(count); match metadata(&path) { Ok(()) => Ok(()), - Err(error) - if error.kind() == io::ErrorKind::NotFound - || error.raw_os_error() == Some(libc::ENAMETOOLONG) => - { - Ok(()) - } + Err(error) if is_absent_or_too_long(&error) => Ok(()), Err(error) => Err(error), } } +/// Whether the platform said the file is not there, or that the name is +/// longer than it accepts. Either is the expected answer: this command +/// exists to have the access attempted and recorded, not to find a file. +/// +/// Windows reports an over-long name as `ERROR_FILENAME_EXCED_RANGE`, +/// which reaches here as [`io::ErrorKind::InvalidFilename`] rather than as +/// the `ENAMETOOLONG` unix returns. +fn is_absent_or_too_long(error: &io::Error) -> bool { + matches!(error.kind(), io::ErrorKind::NotFound | io::ErrorKind::InvalidFilename) + || error.raw_os_error() == Some(libc::ENAMETOOLONG) +} + fn metadata(path: &str) -> io::Result<()> { std::fs::metadata(path).map(|_| ()) } diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/package.json b/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/package.json new file mode 100644 index 000000000..5fe27cb1c --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/package.json @@ -0,0 +1,4 @@ +{ + "name": "fspy-shm-capacity", + "private": true +} diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/snapshots.toml b/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/snapshots.toml new file mode 100644 index 000000000..7402b876e --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/snapshots.toml @@ -0,0 +1,26 @@ +[[e2e]] +name = "shm_capacity_env_sizes_the_tracking_channel" +comment = """ +`VP_RUN_INTERNAL_FSPY_SHM_CAPACITY` sizes the shared memory a tracked task reports its file accesses through. The task stats one 2 MiB path, which is the largest single record tracking can be asked to hold, and 64 MiB holds it comfortably, so the run caches like any other. + +Setting the capacity below that record is what the knob exists for. That case has to wait: a channel with no room for a record currently aborts the task process, and the panic it prints carries a thread id, a toolchain path, a backtrace and a platform's own abort code, none of which snapshot the same way twice. +""" +steps = [ + { argv = [ + "vt", + "run", + "-v", + "stat", + ], envs = [ + [ + "VP_RUN_INTERNAL_FSPY_SHM_CAPACITY", + "67108864", + ], + ], comment = "64 MiB, room to spare for a 2 MiB record" }, + { argv = [ + "vt", + "run", + "-v", + "stat", + ], comment = "replayed from the entry the first run stored" }, +] diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/snapshots/shm_capacity_env_sizes_the_tracking_channel.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/snapshots/shm_capacity_env_sizes_the_tracking_channel.md new file mode 100644 index 000000000..c44129291 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/snapshots/shm_capacity_env_sizes_the_tracking_channel.md @@ -0,0 +1,49 @@ +# shm_capacity_env_sizes_the_tracking_channel + +`VP_RUN_INTERNAL_FSPY_SHM_CAPACITY` sizes the shared memory a tracked task reports its file accesses through. The task stats one 2 MiB path, which is the largest single record tracking can be asked to hold, and 64 MiB holds it comfortably, so the run caches like any other. + +Setting the capacity below that record is what the knob exists for. That case has to wait: a channel with no room for a record currently aborts the task process, and the panic it prints carries a thread id, a toolchain path, a backtrace and a platform's own abort code, none of which snapshot the same way twice. + +## `VP_RUN_INTERNAL_FSPY_SHM_CAPACITY=67108864 vt run -v stat` + +64 MiB, room to spare for a 2 MiB record + +``` +$ vtt stat_long_filename 2097152 + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Vite+ Task Runner • Execution Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Statistics: 1 tasks • 0 cache hits • 1 cache misses +Performance: 0% cache hit rate + +Task Details: +──────────────────────────────────────────────── + [1] fspy-shm-capacity#stat: $ vtt stat_long_filename 2097152 ✓ + → Cache miss: no previous cache entry found +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` + +## `vt run -v stat` + +replayed from the entry the first run stored + +``` +$ vtt stat_long_filename 2097152 ◉ cache hit, replaying + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Vite+ Task Runner • Execution Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Statistics: 1 tasks • 1 cache hits • 0 cache misses +Performance: 100% cache hit rate + +Task Details: +──────────────────────────────────────────────── + [1] fspy-shm-capacity#stat: $ vtt stat_long_filename 2097152 ✓ + → Cache hit - output replayed - +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/vite-task.json b/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/vite-task.json new file mode 100644 index 000000000..c9075fd16 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/vite-task.json @@ -0,0 +1,8 @@ +{ + "tasks": { + "stat": { + "command": "vtt stat_long_filename 2097152", + "cache": true + } + } +} From 9eb5f30a5504be50a7f1751b7a3c11718d010aea Mon Sep 17 00:00:00 2001 From: wan9chi Date: Mon, 17 Aug 2026 14:27:32 +0800 Subject: [PATCH 78/92] fix(fspy): quiet two checks the first push tripped Win32 spells `ERROR_FILENAME_EXCED_RANGE` without the second E, and the comment naming it is more use to a reader than the spelling checker is, so the word joins the allowed list beside the other Windows one. The `shm_capacity` field needed no musl exemption after all. It is read there, by the setter, so claiming it is dead made the expectation unfulfilled instead. Co-Authored-By: Claude Opus 5 --- .typos.toml | 2 ++ crates/fspy/src/command.rs | 4 ---- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/.typos.toml b/.typos.toml index 141848dc3..e7642038d 100644 --- a/.typos.toml +++ b/.typos.toml @@ -1,6 +1,8 @@ [default.extend-words] ratatui = "ratatui" PUNICODE = "PUNICODE" +# Win32 spells `ERROR_FILENAME_EXCED_RANGE` this way. +EXCED = "EXCED" [files] extend-exclude = [ diff --git a/crates/fspy/src/command.rs b/crates/fspy/src/command.rs index 4684a01bb..1ff2c4a9a 100644 --- a/crates/fspy/src/command.rs +++ b/crates/fspy/src/command.rs @@ -24,10 +24,6 @@ pub const DEFAULT_SHM_CAPACITY: usize = 4 << 30; pub struct Command { program: OsString, /// Bytes of shared memory for this run's file-access records. - #[cfg_attr( - target_env = "musl", - expect(dead_code, reason = "musl builds track through seccomp, with no channel to size") - )] pub(crate) shm_capacity: usize, args: Vec, envs: FxHashMap, From 4c47ca9655c3481d8a9b5b78f14386a4195303ab Mon Sep 17 00:00:00 2001 From: wan9chi Date: Mon, 17 Aug 2026 14:37:37 +0800 Subject: [PATCH 79/92] test(e2e): drive the channel by record count, not record size Windows never overran the small channel the first version of this case set up, because a path record cannot get large enough there. A path reaches the tracer through a `UNICODE_STRING`, whose length field is a `u16`, so however long a name the caller asks for, no single record exceeds 64 KiB. Its 1 MiB channel had room to spare, tracking came back complete, and the run cached. Record count is the portable lever, and the slot table makes it exact: one slot per 64 bytes of the region, so a channel of a given size admits a known number of records whatever their paths look like. `vtt stat-many` makes as many accesses as asked for, under distinct names so none can fold into one record, and prints last to show the process outlived them. The case skips musl, which has no preload: those builds collect through the seccomp supervisor, on the runner's own side of the boundary, so there is no shared-memory channel there to fill. Co-Authored-By: Claude Opus 5 --- crates/vt_bin/src/vtt/main.rs | 4 ++- crates/vt_bin/src/vtt/stat_many.rs | 25 +++++++++++++++++++ .../fixtures/fspy_shm_capacity/snapshots.toml | 9 ++++--- ...capacity_env_sizes_the_tracking_channel.md | 18 +++++++------ .../fixtures/fspy_shm_capacity/vite-task.json | 2 +- 5 files changed, 46 insertions(+), 12 deletions(-) create mode 100644 crates/vt_bin/src/vtt/stat_many.rs diff --git a/crates/vt_bin/src/vtt/main.rs b/crates/vt_bin/src/vtt/main.rs index 65a12f8b9..262840135 100644 --- a/crates/vt_bin/src/vtt/main.rs +++ b/crates/vt_bin/src/vtt/main.rs @@ -27,6 +27,7 @@ mod rm; mod small_dev_shm; mod stat_file; mod stat_long_filename; +mod stat_many; mod touch_file; mod write_file; @@ -35,7 +36,7 @@ fn main() { if args.len() < 2 { eprintln!("Usage: vtt [args...]"); eprintln!( - "Subcommands: barrier, check-tty, cp, exit, exit-on-ctrlc, grep-file, list-dir, mkdir, pipe-stdin, print, print-color, print-cwd, print-env, print-file, read-stdin, replace-file-content, rm, small_dev_shm, stat-file, stat_long_filename, touch-file, write-file" + "Subcommands: barrier, check-tty, cp, exit, exit-on-ctrlc, grep-file, list-dir, mkdir, pipe-stdin, print, print-color, print-cwd, print-env, print-file, read-stdin, replace-file-content, rm, small_dev_shm, stat-file, stat_long_filename, stat-many, touch-file, write-file" ); std::process::exit(1); } @@ -76,6 +77,7 @@ fn main() { Ok(()) } "stat_long_filename" => stat_long_filename::run(&args[2..]), + "stat-many" => stat_many::run(&args[2..]), "touch-file" => touch_file::run(&args[2..]), "write-file" => write_file::run(&args[2..]), other => { diff --git a/crates/vt_bin/src/vtt/stat_many.rs b/crates/vt_bin/src/vtt/stat_many.rs new file mode 100644 index 000000000..1ecf31b01 --- /dev/null +++ b/crates/vt_bin/src/vtt/stat_many.rs @@ -0,0 +1,25 @@ +//! Stats a run of generated names, to make a known number of tracked file +//! accesses. The names are missing on purpose, and each one differs from +//! the last: an access is recorded whether or not the file is there, and +//! distinct names cannot be folded into one record. +//! +//! A count is the portable way to give tracking more than it can hold. +//! Record size is not: on Windows a path arrives through a +//! `UNICODE_STRING`, whose length is a `u16`, so no single record there can +//! exceed 64 KiB however long a name the caller asks for. + +use std::error::Error; + +const USAGE: &str = "Usage: vtt stat-many "; + +pub fn run(args: &[String]) -> Result<(), Box> { + let [count] = args else { return Err(USAGE.into()) }; + let count: usize = count.parse().map_err(|_| USAGE)?; + for index in 0..count { + let _ = std::fs::metadata(format!("vtt-stat-many-{index}")); + } + // Printing last proves the process survived every one of them, which is + // what a channel that has stopped accepting records must not disturb. + println!("stat {count}"); + Ok(()) +} diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/snapshots.toml b/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/snapshots.toml index 7402b876e..6ce6bb943 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/snapshots.toml +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/snapshots.toml @@ -1,10 +1,13 @@ [[e2e]] name = "shm_capacity_env_sizes_the_tracking_channel" comment = """ -`VP_RUN_INTERNAL_FSPY_SHM_CAPACITY` sizes the shared memory a tracked task reports its file accesses through. The task stats one 2 MiB path, which is the largest single record tracking can be asked to hold, and 64 MiB holds it comfortably, so the run caches like any other. +`VP_RUN_INTERNAL_FSPY_SHM_CAPACITY` sizes the shared memory a tracked task reports its file accesses through. The task makes twenty thousand of them, and 64 MiB holds every one, so the run caches like any other. -Setting the capacity below that record is what the knob exists for. That case has to wait: a channel with no room for a record currently aborts the task process, and the panic it prints carries a thread id, a toolchain path, a backtrace and a platform's own abort code, none of which snapshot the same way twice. +Setting the capacity below what the task needs is what the knob exists for. That case has to wait: a channel with no room for a record currently aborts the task process, and the panic it prints carries a thread id, a toolchain path, a backtrace and a platform's own abort code, none of which snapshot the same way twice. + +Not on musl, which has no preload: those builds collect through the seccomp supervisor, on the runner's own side of the boundary, so they have no shared-memory channel to fill. """ +cfg = 'not(target_env = "musl")' steps = [ { argv = [ "vt", @@ -16,7 +19,7 @@ steps = [ "VP_RUN_INTERNAL_FSPY_SHM_CAPACITY", "67108864", ], - ], comment = "64 MiB, room to spare for a 2 MiB record" }, + ], comment = "64 MiB, room for every access" }, { argv = [ "vt", "run", diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/snapshots/shm_capacity_env_sizes_the_tracking_channel.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/snapshots/shm_capacity_env_sizes_the_tracking_channel.md index c44129291..f28d778bc 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/snapshots/shm_capacity_env_sizes_the_tracking_channel.md +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/snapshots/shm_capacity_env_sizes_the_tracking_channel.md @@ -1,15 +1,18 @@ # shm_capacity_env_sizes_the_tracking_channel -`VP_RUN_INTERNAL_FSPY_SHM_CAPACITY` sizes the shared memory a tracked task reports its file accesses through. The task stats one 2 MiB path, which is the largest single record tracking can be asked to hold, and 64 MiB holds it comfortably, so the run caches like any other. +`VP_RUN_INTERNAL_FSPY_SHM_CAPACITY` sizes the shared memory a tracked task reports its file accesses through. The task makes twenty thousand of them, and 64 MiB holds every one, so the run caches like any other. -Setting the capacity below that record is what the knob exists for. That case has to wait: a channel with no room for a record currently aborts the task process, and the panic it prints carries a thread id, a toolchain path, a backtrace and a platform's own abort code, none of which snapshot the same way twice. +Setting the capacity below what the task needs is what the knob exists for. That case has to wait: a channel with no room for a record currently aborts the task process, and the panic it prints carries a thread id, a toolchain path, a backtrace and a platform's own abort code, none of which snapshot the same way twice. + +Not on musl, which has no preload: those builds collect through the seccomp supervisor, on the runner's own side of the boundary, so they have no shared-memory channel to fill. ## `VP_RUN_INTERNAL_FSPY_SHM_CAPACITY=67108864 vt run -v stat` -64 MiB, room to spare for a 2 MiB record +64 MiB, room for every access ``` -$ vtt stat_long_filename 2097152 +$ vtt stat-many 20000 +stat 20000 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @@ -21,7 +24,7 @@ Performance: 0% cache hit rate Task Details: ──────────────────────────────────────────────── - [1] fspy-shm-capacity#stat: $ vtt stat_long_filename 2097152 ✓ + [1] fspy-shm-capacity#stat: $ vtt stat-many 20000 ✓ → Cache miss: no previous cache entry found ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ``` @@ -31,7 +34,8 @@ Task Details: replayed from the entry the first run stored ``` -$ vtt stat_long_filename 2097152 ◉ cache hit, replaying +$ vtt stat-many 20000 ◉ cache hit, replaying +stat 20000 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @@ -43,7 +47,7 @@ Performance: 100% cache hit rate Task Details: ──────────────────────────────────────────────── - [1] fspy-shm-capacity#stat: $ vtt stat_long_filename 2097152 ✓ + [1] fspy-shm-capacity#stat: $ vtt stat-many 20000 ✓ → Cache hit - output replayed - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/vite-task.json b/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/vite-task.json index c9075fd16..fce05d408 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/vite-task.json +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/vite-task.json @@ -1,7 +1,7 @@ { "tasks": { "stat": { - "command": "vtt stat_long_filename 2097152", + "command": "vtt stat-many 20000", "cache": true } } From a781d16f7d77e231cb63a2f5d5d77960f34a6258 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Mon, 17 Aug 2026 14:57:31 +0800 Subject: [PATCH 80/92] style(fspy): spell the default capacity as a byte count `4 << 30` says how the number is built; `4 * 1024 * 1024 * 1024` says what it is. Co-Authored-By: Claude Opus 5 --- crates/fspy/src/command.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/fspy/src/command.rs b/crates/fspy/src/command.rs index 1ff2c4a9a..7481bd12a 100644 --- a/crates/fspy/src/command.rs +++ b/crates/fspy/src/command.rs @@ -18,7 +18,7 @@ use crate::{SPY_IMPL, TrackedChild, error::SpawnError}; /// 4 GiB of sparse address space: none of it becomes real memory until /// records land in it, and it leaves room for tens of millions of /// accesses. -pub const DEFAULT_SHM_CAPACITY: usize = 4 << 30; +pub const DEFAULT_SHM_CAPACITY: usize = 4 * 1024 * 1024 * 1024; #[derive(derive_more::Debug)] pub struct Command { From d29e9ad74420bd5f5b6f1161af1dc8d469240287 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Mon, 17 Aug 2026 15:05:09 +0800 Subject: [PATCH 81/92] refactor(fspy): read the channel size where the channel is made The size arrived through a builder on `fspy::Command`, a public default constant, and a `LazyLock` in the runner that read the override and passed it down. Three places to look, for a number with exactly one consumer. It now reads the override next to the `channel` call that uses it, and falls back to the default there. `Command` goes back to what it was, and so do the e2e tool, the examples, the benchmark launcher and fspy's own tests, none of which ever wanted a say in the size. The runner no longer names the variable at all, which also settles the musl question: `fspy::ipc` is already `cfg(not(target_env = "musl"))`, so the size lives behind the same gate as the channel it sizes. Co-Authored-By: Claude Opus 5 --- crates/fspy/src/command.rs | 22 --------------------- crates/fspy/src/ipc.rs | 27 ++++++++++++++++++++++++++ crates/fspy/src/lib.rs | 2 +- crates/fspy/src/unix/mod.rs | 2 +- crates/fspy/src/windows/mod.rs | 3 +-- crates/vt/src/session/execute/spawn.rs | 19 ------------------ 6 files changed, 30 insertions(+), 45 deletions(-) diff --git a/crates/fspy/src/command.rs b/crates/fspy/src/command.rs index 7481bd12a..fb150b26c 100644 --- a/crates/fspy/src/command.rs +++ b/crates/fspy/src/command.rs @@ -12,19 +12,9 @@ use tokio_util::sync::CancellationToken; use crate::{SPY_IMPL, TrackedChild, error::SpawnError}; -/// Shared memory for a tracked run's file-access records when the caller -/// does not say otherwise. -/// -/// 4 GiB of sparse address space: none of it becomes real memory until -/// records land in it, and it leaves room for tens of millions of -/// accesses. -pub const DEFAULT_SHM_CAPACITY: usize = 4 * 1024 * 1024 * 1024; - #[derive(derive_more::Debug)] pub struct Command { program: OsString, - /// Bytes of shared memory for this run's file-access records. - pub(crate) shm_capacity: usize, args: Vec, envs: FxHashMap, cwd: Option, @@ -47,7 +37,6 @@ impl Command { pub fn new>(program: P) -> Self { Self { program: program.as_ref().to_os_string(), - shm_capacity: DEFAULT_SHM_CAPACITY, args: Vec::new(), envs: FxHashMap::default(), cwd: None, @@ -124,17 +113,6 @@ impl Command { self } - /// Sizes the shared memory this run's file-access records go through, - /// in bytes. - /// - /// How many accesses a program makes is the caller's business rather - /// than this crate's, so a caller that knows better than - /// [`DEFAULT_SHM_CAPACITY`] says so here. - pub const fn shm_capacity(&mut self, bytes: usize) -> &mut Self { - self.shm_capacity = bytes; - self - } - pub fn env(&mut self, key: K, val: V) -> &mut Self where K: AsRef, diff --git a/crates/fspy/src/ipc.rs b/crates/fspy/src/ipc.rs index af10a29b3..a804fa8c3 100644 --- a/crates/fspy/src/ipc.rs +++ b/crates/fspy/src/ipc.rs @@ -6,6 +6,33 @@ use fspy_shared::ipc::{ }; use tokio::task::spawn_blocking; +/// Shared memory for one tracked run's file-access records. +/// +/// 4 GiB of sparse address space: none of it becomes real memory until +/// records land in it, and it leaves room for tens of millions of +/// accesses. +const DEFAULT_SHM_CAPACITY: usize = 4 * 1024 * 1024 * 1024; + +/// Overrides [`DEFAULT_SHM_CAPACITY`] with a byte count. Internal: it +/// exists so a test can shrink the region until a run overruns it, and +/// nothing outside this repository should set it. +const SHM_CAPACITY_ENV: &str = "VP_RUN_INTERNAL_FSPY_SHM_CAPACITY"; + +/// How much shared memory to give the next tracked run. +/// +/// # Panics +/// +/// When the override is set to something that is not a byte count. It is +/// ours to set, so a value we cannot read is a mistake worth stopping for +/// rather than quietly ignoring. +pub fn shm_capacity() -> usize { + std::env::var_os(SHM_CAPACITY_ENV).map_or(DEFAULT_SHM_CAPACITY, |value| { + value.to_str().and_then(|value| value.parse().ok()).unwrap_or_else(|| { + panic!("{SHM_CAPACITY_ENV} is not a byte count: {}", value.display()) + }) + }) +} + #[ouroboros::self_referencing] pub struct OwnedReceiverLockGuard { /// Owns the shared memory diff --git a/crates/fspy/src/lib.rs b/crates/fspy/src/lib.rs index d8b58b46c..6c89414ba 100644 --- a/crates/fspy/src/lib.rs +++ b/crates/fspy/src/lib.rs @@ -19,7 +19,7 @@ mod command; use std::{env::temp_dir, fs::create_dir, io, process::ExitStatus, sync::LazyLock}; -pub use command::{Command, DEFAULT_SHM_CAPACITY}; +pub use command::Command; pub use fspy_shared::ipc::{AccessMode, PathAccess}; use futures_util::future::BoxFuture; pub use os_impl::PathAccessIterable; diff --git a/crates/fspy/src/unix/mod.rs b/crates/fspy/src/unix/mod.rs index 2c97aad42..c612d00b7 100644 --- a/crates/fspy/src/unix/mod.rs +++ b/crates/fspy/src/unix/mod.rs @@ -80,7 +80,7 @@ impl SpyImpl { #[cfg(not(target_env = "musl"))] let (ipc_channel_conf, ipc_receiver) = - channel(command.shm_capacity).map_err(SpawnError::ChannelCreation)?; + channel(crate::ipc::shm_capacity()).map_err(SpawnError::ChannelCreation)?; let payload = Payload { #[cfg(not(target_env = "musl"))] diff --git a/crates/fspy/src/windows/mod.rs b/crates/fspy/src/windows/mod.rs index 66966e67f..2e6470d47 100644 --- a/crates/fspy/src/windows/mod.rs +++ b/crates/fspy/src/windows/mod.rs @@ -80,13 +80,12 @@ impl SpyImpl { ) -> Result { let ansi_dll_path_with_nul = Arc::clone(&self.ansi_dll_path_with_nul); command.env("FSPY", "1"); - let shm_capacity = command.shm_capacity; let mut command = command.into_tokio_command(); command.creation_flags(CREATE_SUSPENDED); let (channel_conf, receiver) = - channel(shm_capacity).map_err(SpawnError::ChannelCreation)?; + channel(crate::ipc::shm_capacity()).map_err(SpawnError::ChannelCreation)?; let mut spawn_success = false; let spawn_success = &mut spawn_success; diff --git a/crates/vt/src/session/execute/spawn.rs b/crates/vt/src/session/execute/spawn.rs index fe1dbd58a..adff8aac9 100644 --- a/crates/vt/src/session/execute/spawn.rs +++ b/crates/vt/src/session/execute/spawn.rs @@ -14,24 +14,6 @@ use tokio::process::{ChildStderr, ChildStdout}; use tokio_util::sync::CancellationToken; use vt_plan::SpawnCommand; -/// Sets the shared memory a tracked task reports its file accesses -/// through, in bytes, in place of the size fspy would pick. Internal: it -/// exists so tests can shrink the channel until a task overruns it, and -/// nothing outside this repository should set it. -#[cfg(fspy)] -const FSPY_SHM_CAPACITY_ENV: &str = "VP_RUN_INTERNAL_FSPY_SHM_CAPACITY"; - -/// The shared memory each tracked task gets. Read once, since a run's -/// tasks all get the same size. -#[cfg(fspy)] -static FSPY_SHM_CAPACITY: std::sync::LazyLock = std::sync::LazyLock::new(|| { - std::env::var_os(FSPY_SHM_CAPACITY_ENV).map_or(fspy::DEFAULT_SHM_CAPACITY, |value| { - value.to_str().and_then(|value| value.parse().ok()).unwrap_or_else(|| { - panic!("{FSPY_SHM_CAPACITY_ENV} is not a byte count: {}", value.display()) - }) - }) -}); - /// How the child's stdin/stdout/stderr are configured. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SpawnStdio { @@ -118,7 +100,6 @@ where V: AsRef, { let mut fspy_cmd = fspy::Command::new(cmd.program_path.as_path()); - fspy_cmd.shm_capacity(*FSPY_SHM_CAPACITY); fspy_cmd.args(cmd.args.iter().map(vt_str::Str::as_str)); fspy_cmd.envs(cmd.spawn_envs.iter()); fspy_cmd.envs(extra_envs); From 538088755208889b05e6f4cdde36c6f68325f4a0 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Mon, 17 Aug 2026 15:05:10 +0800 Subject: [PATCH 82/92] refactor(vtt): fold stat_long_filename into stat-many Two commands stat generated names to be tracked; one varied the name's length and the other how many names. They are now `stat-many [name-length]`, which also puts the name in kebab case with every other subcommand. Count leads because it is the knob that travels. A long name only fills a channel on unix: on Windows a path reaches the tracer through a `UNICODE_STRING` whose length is a `u16`, so no single record there exceeds 64 KiB however long a name the caller asks for. Names now carry their index, so a run of them cannot collapse into one record, and padding fills out whatever length is asked for. The `/dev/shm` case keeps its one 1 MiB name as `stat-many 1 1048576`, and gains the trailing line that reports the process survived its accesses. Co-Authored-By: Claude Opus 5 --- crates/vt_bin/src/vtt/main.rs | 4 +- crates/vt_bin/src/vtt/stat_long_filename.rs | 46 ----------- crates/vt_bin/src/vtt/stat_many.rs | 81 +++++++++++++++---- .../snapshots/constrained_dev_shm.md | 3 +- .../constrained_dev_shm/vite-task.json | 2 +- 5 files changed, 70 insertions(+), 66 deletions(-) delete mode 100644 crates/vt_bin/src/vtt/stat_long_filename.rs diff --git a/crates/vt_bin/src/vtt/main.rs b/crates/vt_bin/src/vtt/main.rs index 262840135..dd36cadba 100644 --- a/crates/vt_bin/src/vtt/main.rs +++ b/crates/vt_bin/src/vtt/main.rs @@ -26,7 +26,6 @@ mod rm; #[cfg(target_os = "linux")] mod small_dev_shm; mod stat_file; -mod stat_long_filename; mod stat_many; mod touch_file; mod write_file; @@ -36,7 +35,7 @@ fn main() { if args.len() < 2 { eprintln!("Usage: vtt [args...]"); eprintln!( - "Subcommands: barrier, check-tty, cp, exit, exit-on-ctrlc, grep-file, list-dir, mkdir, pipe-stdin, print, print-color, print-cwd, print-env, print-file, read-stdin, replace-file-content, rm, small_dev_shm, stat-file, stat_long_filename, stat-many, touch-file, write-file" + "Subcommands: barrier, check-tty, cp, exit, exit-on-ctrlc, grep-file, list-dir, mkdir, pipe-stdin, print, print-color, print-cwd, print-env, print-file, read-stdin, replace-file-content, rm, small_dev_shm, stat-file, stat-many, touch-file, write-file" ); std::process::exit(1); } @@ -76,7 +75,6 @@ fn main() { stat_file::run(&args[2..]); Ok(()) } - "stat_long_filename" => stat_long_filename::run(&args[2..]), "stat-many" => stat_many::run(&args[2..]), "touch-file" => touch_file::run(&args[2..]), "write-file" => write_file::run(&args[2..]), diff --git a/crates/vt_bin/src/vtt/stat_long_filename.rs b/crates/vt_bin/src/vtt/stat_long_filename.rs deleted file mode 100644 index 1a370ef7e..000000000 --- a/crates/vt_bin/src/vtt/stat_long_filename.rs +++ /dev/null @@ -1,46 +0,0 @@ -use std::{error::Error, io}; - -const USAGE: &str = "Usage: vtt stat_long_filename "; - -pub fn run(args: &[String]) -> Result<(), Box> { - let count = parse_count(args)?; - access_generated_path(count, metadata)?; - Ok(()) -} - -fn parse_count(args: &[String]) -> Result { - let [count] = args else { return Err(USAGE.to_owned()) }; - count.parse().map_err(|_| USAGE.to_owned()) -} - -fn generated_path(count: usize) -> String { - "x".repeat(count) -} - -fn access_generated_path( - count: usize, - mut metadata: impl FnMut(&str) -> io::Result<()>, -) -> io::Result<()> { - let path = generated_path(count); - match metadata(&path) { - Ok(()) => Ok(()), - Err(error) if is_absent_or_too_long(&error) => Ok(()), - Err(error) => Err(error), - } -} - -/// Whether the platform said the file is not there, or that the name is -/// longer than it accepts. Either is the expected answer: this command -/// exists to have the access attempted and recorded, not to find a file. -/// -/// Windows reports an over-long name as `ERROR_FILENAME_EXCED_RANGE`, -/// which reaches here as [`io::ErrorKind::InvalidFilename`] rather than as -/// the `ENAMETOOLONG` unix returns. -fn is_absent_or_too_long(error: &io::Error) -> bool { - matches!(error.kind(), io::ErrorKind::NotFound | io::ErrorKind::InvalidFilename) - || error.raw_os_error() == Some(libc::ENAMETOOLONG) -} - -fn metadata(path: &str) -> io::Result<()> { - std::fs::metadata(path).map(|_| ()) -} diff --git a/crates/vt_bin/src/vtt/stat_many.rs b/crates/vt_bin/src/vtt/stat_many.rs index 1ecf31b01..777d71d90 100644 --- a/crates/vt_bin/src/vtt/stat_many.rs +++ b/crates/vt_bin/src/vtt/stat_many.rs @@ -1,25 +1,76 @@ -//! Stats a run of generated names, to make a known number of tracked file -//! accesses. The names are missing on purpose, and each one differs from -//! the last: an access is recorded whether or not the file is there, and -//! distinct names cannot be folded into one record. +//! Stats generated names, to make a known number of tracked file accesses. //! -//! A count is the portable way to give tracking more than it can hold. -//! Record size is not: on Windows a path arrives through a -//! `UNICODE_STRING`, whose length is a `u16`, so no single record there can -//! exceed 64 KiB however long a name the caller asks for. +//! The names are missing on purpose: an access is recorded whether or not +//! the file is there, and this exists to have the access attempted, not to +//! find a file. Each name differs from the last, so no two can fold into +//! one record. +//! +//! Both knobs give tracking more than it can hold, and only one of them +//! travels. A count works everywhere. A long name does not: on Windows a +//! path reaches the tracer through a `UNICODE_STRING`, whose length is a +//! `u16`, so however long a name this asks for, no single record there +//! exceeds 64 KiB. -use std::error::Error; +use std::{error::Error, io}; -const USAGE: &str = "Usage: vtt stat-many "; +const USAGE: &str = "Usage: vtt stat-many [name-length]"; pub fn run(args: &[String]) -> Result<(), Box> { - let [count] = args else { return Err(USAGE.into()) }; - let count: usize = count.parse().map_err(|_| USAGE)?; + let (count, name_length) = parse_args(args)?; for index in 0..count { - let _ = std::fs::metadata(format!("vtt-stat-many-{index}")); + access_generated_path(index, name_length, metadata)?; } - // Printing last proves the process survived every one of them, which is - // what a channel that has stopped accepting records must not disturb. + // Printing last proves the process survived every one of them, which a + // channel that has stopped accepting records must not disturb. println!("stat {count}"); Ok(()) } + +fn parse_args(args: &[String]) -> Result<(usize, usize), String> { + let (count, name_length) = match args { + [count] => (count, None), + [count, name_length] => (count, Some(name_length)), + _ => return Err(USAGE.to_owned()), + }; + let count = count.parse().map_err(|_| USAGE.to_owned())?; + let name_length = + name_length.map(|length| length.parse()).transpose().map_err(|_| USAGE.to_owned())?; + Ok((count, name_length.unwrap_or(0))) +} + +/// A name unique to `index`, padded out to `name_length` when that leaves +/// room for padding. A length short enough to truncate the index would +/// hand two accesses the same name, so the index always survives. +fn generated_path(index: usize, name_length: usize) -> String { + let name = std::format!("vtt-stat-many-{index}"); + let padding = name_length.saturating_sub(name.len()); + name + &"x".repeat(padding) +} + +fn access_generated_path( + index: usize, + name_length: usize, + mut metadata: impl FnMut(&str) -> io::Result<()>, +) -> io::Result<()> { + let path = generated_path(index, name_length); + match metadata(&path) { + Ok(()) => Ok(()), + Err(error) if is_absent_or_too_long(&error) => Ok(()), + Err(error) => Err(error), + } +} + +/// Whether the platform said the file is not there, or that the name is +/// longer than it accepts. Either is the expected answer. +/// +/// Windows reports an over-long name as `ERROR_FILENAME_EXCED_RANGE`, +/// which reaches here as [`io::ErrorKind::InvalidFilename`] rather than as +/// the `ENAMETOOLONG` unix returns. +fn is_absent_or_too_long(error: &io::Error) -> bool { + matches!(error.kind(), io::ErrorKind::NotFound | io::ErrorKind::InvalidFilename) + || error.raw_os_error() == Some(libc::ENAMETOOLONG) +} + +fn metadata(path: &str) -> io::Result<()> { + std::fs::metadata(path).map(|_| ()) +} diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/constrained_dev_shm/snapshots/constrained_dev_shm.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/constrained_dev_shm/snapshots/constrained_dev_shm.md index 4dd59fa3b..183835957 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/constrained_dev_shm/snapshots/constrained_dev_shm.md +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/constrained_dev_shm/snapshots/constrained_dev_shm.md @@ -5,5 +5,6 @@ With fspy's shared-memory backing moved to memfd, file-access tracking succeeds ## `vtt small_dev_shm vt run stress` ``` -$ vtt stat_long_filename 1048576 +$ vtt stat-many 1 1048576 +stat 1 ``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/constrained_dev_shm/vite-task.json b/crates/vt_bin/tests/e2e_snapshots/fixtures/constrained_dev_shm/vite-task.json index 0415ae320..aa51997cc 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/constrained_dev_shm/vite-task.json +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/constrained_dev_shm/vite-task.json @@ -1,7 +1,7 @@ { "tasks": { "stress": { - "command": "vtt stat_long_filename 1048576", + "command": "vtt stat-many 1 1048576", "cache": true } } From fe7086091ee58e8823ab555372e076a97e3d9d26 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Mon, 17 Aug 2026 15:11:45 +0800 Subject: [PATCH 83/92] docs: write the tracking changelog entries for users Both entries described the mechanism rather than what a user sees. Crashing "mid-record", closing "inherited file descriptors" and keeping "every completed record intact" are things the tracker does; what a user hit was `vp run` hanging or failing, and a task getting killed partway through. The second entry also cites #533, the report of the abort. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b317f3cf..a0d93dcf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ # Changelog -- **Fixed** Automatic file-access tracking no longer panics or blocks task completion when a tracked process crashes mid-record, closes inherited file descriptors, or keeps running after the task exits; such runs now finish immediately with every completed record intact ([#544](https://github.com/voidzero-dev/vite-task/issues/544), [#675](https://github.com/voidzero-dev/vite-task/pull/675)). -- **Fixed** A task that makes more file accesses than automatic tracking has room for now runs to completion instead of being aborted mid-run. The run is reported as not cached, since the accesses that were recorded are only some of the ones it made ([#675](https://github.com/voidzero-dev/vite-task/pull/675)). +- **Fixed** `vp run` no longer hangs or fails when a task leaves a process running behind it, such as a dev server or a background helper, or when one of a task's processes is killed. The run finishes as soon as the task itself does, and the files the task used are still recorded ([#544](https://github.com/voidzero-dev/vite-task/issues/544), [#675](https://github.com/voidzero-dev/vite-task/pull/675)). +- **Fixed** A task that reads or writes an unusually large number of files now runs to the end instead of being killed partway through. Vite+ reports the run as not cached, because it could not record every file the task used ([#533](https://github.com/voidzero-dev/vite-task/issues/533), [#675](https://github.com/voidzero-dev/vite-task/pull/675)). - **Fixed** Vite+ diagnostics now display individual paths and working directories without Rust debug formatting such as quoted paths or escaped Windows backslashes ([#534](https://github.com/voidzero-dev/vite-task/pull/534)). - **Fixed** Automatic file-access tracking now works inside the default Codex CLI and Claude Code sandboxes ([#562](https://github.com/voidzero-dev/vite-task/issues/562), [#563](https://github.com/voidzero-dev/vite-task/issues/563), [#576](https://github.com/voidzero-dev/vite-task/pull/576), [#569](https://github.com/voidzero-dev/vite-task/pull/569)). - **Fixed** Broad workspace globs no longer discover and run package scripts inside `node_modules` ([#539](https://github.com/voidzero-dev/vite-task/pull/539)). From b17256bed6af62947fd580ae7cc15bf85bad1ea0 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Mon, 17 Aug 2026 15:19:11 +0800 Subject: [PATCH 84/92] refactor(fspy): let the caller decide what an attach failure means MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sender` decided for itself: a missing backing file came back as an error, and every other failure panicked inside the call. That put the policy in the one place that cannot know the caller's situation. It now returns every failure. Two error kinds say the channel is simply over — the receiver removed the file, or sealed it just before this call — and a caller meeting those has lost nothing by recording nothing. Both preload clients skip on those and panic on anything else, which is the same behaviour as before, now written where the decision belongs. Neither client prints on the skip. A preload library writing to the traced process's stderr corrupts whatever that process is printing, and a channel that closed before this process started is not news. The benchmark launcher gains a small shim over `ChildTermination`'s accesses field. The benchmark compiles that one source against both this revision's fspy and the merge base's, so a change to the field's type stops the base arm building; the shim spans both shapes. CI caught this, and the fix is verified by compiling the head launcher against main. Co-Authored-By: Claude Opus 5 --- crates/fspy_benchmark_launcher/src/main.rs | 30 +++++++-- crates/fspy_client_unix/src/lib.rs | 26 ++++---- .../src/windows/client.rs | 26 ++++---- crates/fspy_shared/src/ipc/channel/mod.rs | 64 +++++++++---------- 4 files changed, 81 insertions(+), 65 deletions(-) diff --git a/crates/fspy_benchmark_launcher/src/main.rs b/crates/fspy_benchmark_launcher/src/main.rs index 1d72d291b..d3ec32637 100644 --- a/crates/fspy_benchmark_launcher/src/main.rs +++ b/crates/fspy_benchmark_launcher/src/main.rs @@ -10,7 +10,7 @@ use std::{env, ffi::OsString, process::Stdio, time::Instant}; -use fspy::Command; +use fspy::{Command, PathAccessIterable}; use tokio::{io::AsyncReadExt as _, process::ChildStdout, runtime::Builder}; use tokio_util::sync::CancellationToken; @@ -140,6 +140,30 @@ async fn report(mut launch: Launch) { /// In relative mode the captured path must come out identical — the tracker /// resolves the root working directory and joins the bare name back into /// [`MISSING_PATH`] — so the assertion below covers both modes. +/// Takes the accesses out of a [`fspy::ChildTermination`], whichever shape +/// that field has. +/// +/// The benchmark compiles this one source file against two revisions of +/// `fspy` — the pull request's and its merge base's — so that identical +/// launcher code times both arms. A revision that changes the field's type +/// would otherwise stop the other arm from building. Exactly one of these +/// impls applies per build; the other is inert. +trait TrackedAccesses { + fn tracked(self) -> PathAccessIterable; +} + +impl TrackedAccesses for PathAccessIterable { + fn tracked(self) -> PathAccessIterable { + self + } +} + +impl TrackedAccesses for Result { + fn tracked(self) -> PathAccessIterable { + self.expect("the tracking region holds every record this run makes") + } +} + async fn validate(target: &OsString, target_args: &[OsString], relative: bool) { let mut command = Command::new(target); command @@ -159,9 +183,7 @@ async fn validate(target: &OsString, target_args: &[OsString], relative: bool) { .await .expect("failed to wait for tracked target"); assert!(termination.status.success(), "benchmark target failed: {}", termination.status); - let path_accesses = - termination.path_accesses.expect("the tracking region holds every record this run makes"); - let captured_missing_access = path_accesses.iter().any(|access| { + let captured_missing_access = termination.path_accesses.tracked().iter().any(|access| { access.path.strip_path_prefix(MISSING_PATH, |result| { result.is_ok_and(|path| path.as_os_str().is_empty()) }) diff --git a/crates/fspy_client_unix/src/lib.rs b/crates/fspy_client_unix/src/lib.rs index 77167f444..951d47f30 100644 --- a/crates/fspy_client_unix/src/lib.rs +++ b/crates/fspy_client_unix/src/lib.rs @@ -8,7 +8,7 @@ pub mod convert; pub mod raw_exec; -use std::{ffi::OsStr, fmt::Debug, os::unix::ffi::OsStrExt as _, path::Path}; +use std::{ffi::OsStr, fmt::Debug, io::ErrorKind, os::unix::ffi::OsStrExt as _, path::Path}; use convert::{ToAbsolutePath, ToAccessMode}; use fspy_shared::ipc::{PathAccess, channel::Sender}; @@ -44,25 +44,25 @@ impl Client { /// /// # Panics /// - /// Panics when the payload is missing, malformed, or cannot be decoded. - #[expect( - clippy::print_stderr, - reason = "the client intentionally reports an unavailable supervisor channel" - )] + /// Panics when the payload is missing, malformed, or cannot be decoded, + /// and when the channel is there but cannot be attached to. A process + /// with no sender has no way to tell the receiver it recorded nothing, + /// and a trace that silently omits every access it made is worse than + /// no trace. pub fn from_env(envs: impl Iterator) -> Self { let encoded_payload = decode_payload_from_env(envs).unwrap(); let ipc_sender = match encoded_payload.payload.ipc_channel_conf.sender() { Ok(sender) => Some(sender), - // The only failure `sender` returns is a channel that has - // already closed, which happens when this process starts after - // the root target exited. Everything it does from here is past - // the receiver's boundary, so recording nothing loses nothing. - // Anything worse stops the process inside `sender` instead. - Err(err) => { - eprintln!("fspy: the trace channel has closed: {err}"); + // The channel is over: this process started after the root + // target exited, so everything it does from here happens past + // the receiver's boundary and recording nothing loses nothing. + // Silently, because a preload library writing to the traced + // process's stderr corrupts whatever that process is printing. + Err(error) if matches!(error.kind(), ErrorKind::NotFound | ErrorKind::BrokenPipe) => { None } + Err(error) => panic!("fspy: cannot attach to the trace channel: {error}"), }; Self { encoded_payload, ipc_sender } diff --git a/crates/fspy_preload_windows/src/windows/client.rs b/crates/fspy_preload_windows/src/windows/client.rs index ee435ae8f..f45ca1984 100644 --- a/crates/fspy_preload_windows/src/windows/client.rs +++ b/crates/fspy_preload_windows/src/windows/client.rs @@ -1,4 +1,4 @@ -use std::{cell::SyncUnsafeCell, ffi::CStr, mem::MaybeUninit}; +use std::{cell::SyncUnsafeCell, ffi::CStr, io::ErrorKind, mem::MaybeUninit}; use fspy_detours_sys::DetourCopyPayloadToProcess; use fspy_shared::{ @@ -18,21 +18,19 @@ impl<'a> Client<'a> { let ipc_sender = match payload.channel_conf.sender() { Ok(sender) => Some(sender), - // The only failure `sender` returns is a channel that has - // already closed, which happens when this process starts after - // the root target exited. Everything it does from here is past - // the receiver's boundary, so recording nothing loses nothing. - // Anything worse stops the process inside `sender` instead. - Err(err) => { - #[expect( - clippy::print_stderr, - reason = "preload library uses stderr for debug diagnostics" - )] - { - eprintln!("fspy: the trace channel has closed: {err}"); - } + // The channel is over: this process started after the root + // target exited, so everything it does from here happens past + // the receiver's boundary and recording nothing loses nothing. + // Silently, because a detours DLL writing to the traced + // process's stderr corrupts whatever that process is printing. + Err(error) if matches!(error.kind(), ErrorKind::NotFound | ErrorKind::BrokenPipe) => { None } + // The channel is there but cannot be attached to. A process + // with no sender has no way to tell the receiver it recorded + // nothing, and a trace that silently omits every access it made + // is worse than no trace. + Err(error) => panic!("fspy: cannot attach to the trace channel: {error}"), }; Self { payload, ipc_sender } diff --git a/crates/fspy_shared/src/ipc/channel/mod.rs b/crates/fspy_shared/src/ipc/channel/mod.rs index dc83d6ebe..5d008d5b6 100644 --- a/crates/fspy_shared/src/ipc/channel/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/mod.rs @@ -159,50 +159,46 @@ impl Drop for ShmKeeper { impl ChannelConf { /// Creates a sender. /// - /// Never blocks. Fails only when the channel is already over: the - /// receiver removed the backing file, or it sealed the region before - /// removing it and a sender caught the gate in between. Either way - /// whatever this process does next happens past the receiver's - /// boundary, so skipping its records loses nothing. + /// Never blocks. /// - /// # Panics + /// # Errors + /// + /// Every way attaching can fail, for the caller to sort out. Two of + /// them say the channel is simply over, and a caller that meets them + /// has lost nothing by recording nothing, because whatever it does + /// next happens past the receiver's boundary: /// - /// When the channel is there but cannot be attached to: the file - /// refuses to open or map, or it cannot hold the protocol. A process - /// with no writer has no way to tell the receiver it recorded nothing, - /// and a trace that silently omits every access it made is worse than - /// no trace, so it stops here instead. - #[expect( - clippy::missing_errors_doc, - reason = "error conditions are self-evident from return type" - )] + /// - [`io::ErrorKind::NotFound`]: the receiver removed the backing + /// file. + /// - [`io::ErrorKind::BrokenPipe`]: the receiver sealed the region + /// before removing it, and this call caught the gate in between. + /// + /// Anything else means the channel is there but cannot be attached to, + /// which is a caller with no way to report what it then fails to + /// record. pub fn sender(&self) -> io::Result { // The arena never touches the process heap, so this stays safe in // the preload contexts that create senders (pre-`main` constructors, // the Windows loader lock). let arena = fspy_nostd_alloc::arena(); - let shm_path = self - .shm_id - .to_os_c_string_in(&arena) - .expect("the channel's own shared-memory path is not a valid C string"); - let handle = match fspy_shm::open(shm_path.as_c_str().as_thin()) { - Ok(handle) => handle, - Err(error) => { - let error = shm_error_to_io(error); - // The receiver removed the backing file, so it has already - // stopped collecting. - if error.kind() == io::ErrorKind::NotFound { - return Err(error); - } - panic!("cannot open the shared-memory channel: {error}"); - } - }; - let mapping = handle.map().expect("cannot map the shared-memory channel"); + let shm_path = self.shm_id.to_os_c_string_in(&arena).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "the channel's shared-memory path is not a valid C string", + ) + })?; + let mapping = fspy_shm::open(shm_path.as_c_str().as_thin()) + .and_then(|handle| handle.map()) + .map_err(shm_error_to_io)?; // SAFETY: `mapping` is a freshly mapped shared memory region created // zero-initialized by `channel` and accessed only through the // `shm_io` protocol by every attached process. - let writer = unsafe { ShmWriter::new(mapping, to_usize(self.slots)) } - .expect("the shared-memory region cannot hold the channel"); + let writer = unsafe { ShmWriter::new(mapping, to_usize(self.slots)) }.ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "the shared-memory region cannot hold the channel", + ) + })?; if writer.is_closed() { return Err(io::Error::new( io::ErrorKind::BrokenPipe, From aa075b2fc4d0f9f7aece1501f626cd6d4ea6284a Mon Sep 17 00:00:00 2001 From: wan9chi Date: Mon, 17 Aug 2026 15:27:20 +0800 Subject: [PATCH 85/92] refactor(fspy-shm): state the table's alignment instead of implying it The table's `SAFETY` comment said its alignment "follows from the start address being aligned for `Counters`, whose size is a multiple of that alignment". The claim is true, but its second half was nowhere asserted: it came out of `size_of::() == 2 * size_of::()` and the fact that an `AtomicU64` is never aligned more strictly than its own width. A reader had to reconstruct that, and a later field could quietly break it. There is now an assert for exactly the step in question, and the comment names both asserts it stands on rather than restating the argument. A test checks the pointers `new` actually builds, across slot counts, since the asserts argue about the types and not the arithmetic. `meta_len` was left over from the `Meta` struct that used to hold both parts. It is `payloads_at` now, which is what it measures. `table_len` became `table_bytes`, so it stops reading like the slot count that `table().len()` returns. Co-Authored-By: Claude Opus 5 --- .../src/ipc/channel/shm_io/layout.rs | 46 +++++++++++++++---- 1 file changed, 36 insertions(+), 10 deletions(-) diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs index 57ac0fd59..c5278aa5f 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs @@ -34,10 +34,16 @@ pub struct Counters { } // The mapping starts at a `u64`-aligned address and is cast to -// `&Counters`, so no field in it may need more alignment than that. The -// descriptor table then starts at a multiple of that alignment too. +// `&Counters`, so no field in it may need more alignment than that. This +// is also what makes the check on the start address serve both parts: an +// address aligned for the counters is aligned for a slot. const _: () = assert!(align_of::() == align_of::()); +// The descriptor table starts right after the counters, so their size has +// to leave it aligned: an address good for a slot must still be good for +// one this many bytes later. +const _: () = assert!(size_of::().is_multiple_of(align_of::())); + // Both endpoints keep raw pointers into the region and read through them // long after the references they came from are gone, which is sound only // because every byte they point at sits inside an atomic. This catches a @@ -209,10 +215,11 @@ impl MappedLayout { /// created, and accessed only through this protocol since. pub unsafe fn new(mem: *mut [u8], slots: usize) -> Option { let mem_start = mem.cast::(); - // The mapping must hold the counters and the whole table. - let table_len = slots.checked_mul(size_of::())?; - let meta_len = size_of::().checked_add(table_len)?; - let payload_len = mem.len().checked_sub(meta_len)?; + // The mapping must hold the counters and the whole table. Their + // sizes together are where payloads begin. + let table_bytes = slots.checked_mul(size_of::())?; + let payloads_at = size_of::().checked_add(table_bytes)?; + let payload_len = mem.len().checked_sub(payloads_at)?; // A descriptor holds a 32-bit offset, so the payload area can be // no longer than a `u32`. Keeping the converted value is what lets // later bounds checks stay in 32 bits. @@ -223,13 +230,14 @@ impl MappedLayout { // The table sits right after the counters, and the payload area // after the table. - // SAFETY: the mapping holds both (checked above), and the table's - // alignment follows from the start address being aligned for - // `Counters`, whose size is a multiple of that alignment. + // SAFETY: the mapping holds both, checked above. The slots are + // aligned because the start address is (the conversion above) and + // the counters are a whole number of slots wide (the assert near + // `Counters`). Payload bytes need no alignment. let table_start = NonNull::new(unsafe { mem_start.add(size_of::()) })?; let table = NonNull::slice_from_raw_parts(table_start.cast::(), slots); // SAFETY: as above. - let payload_start = NonNull::new(unsafe { mem_start.add(meta_len) })?; + let payload_start = NonNull::new(unsafe { mem_start.add(payloads_at) })?; Some(Self { counters, table, payload_start, payload_len }) } @@ -281,6 +289,24 @@ mod tests { assert!(matches!(SlotState::decode(42), SlotState::Unfinished)); } + /// Every slot the reader and writer touch is dereferenced as an + /// `AtomicU64`, so `new`'s arithmetic has to land the table on that + /// alignment. The const asserts near `Counters` argue it; this checks + /// the pointers `new` actually builds. + #[test] + fn the_table_lands_aligned_for_a_slot() { + let mut mem = [0u64; 64]; + let raw = std::ptr::slice_from_raw_parts_mut(mem.as_mut_ptr().cast::(), 512); + for slots in 0..8 { + // SAFETY: the array is live, aligned and zeroed, and nothing + // else touches it. + let mapped = unsafe { MappedLayout::new(raw, slots) }.unwrap(); + for slot in mapped.table() { + assert!(std::ptr::from_ref(slot).addr().is_multiple_of(align_of::())); + } + } + } + #[test] fn a_region_too_small_for_the_table_is_rejected() { let mut mem = [0u64; 4]; From eb5e2ec8fcd9b4ccd856a2c20d270759fdceaeb3 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Mon, 17 Aug 2026 15:35:07 +0800 Subject: [PATCH 86/92] refactor(fspy): let sender say whether there is a sender Handing every failure to the caller gave both preload clients the same fifteen lines: skip on two error kinds, panic on the rest. Two copies of one policy, and a signature that made a caller re-derive it from `io::ErrorKind` before it could act. `sender` returns `Option` now and decides for itself. `None` means the channel is already over, which is the only outcome a caller can do anything about, and it does the same thing either way: record nothing. Everything else stops the process where the cause is known, with the error in the message. Both clients are one line and a comment. Co-Authored-By: Claude Opus 5 --- crates/fspy_client_unix/src/lib.rs | 25 ++----- .../src/windows/client.rs | 23 ++---- crates/fspy_shared/src/ipc/channel/mod.rs | 75 +++++++++---------- 3 files changed, 50 insertions(+), 73 deletions(-) diff --git a/crates/fspy_client_unix/src/lib.rs b/crates/fspy_client_unix/src/lib.rs index 951d47f30..3ba3877a1 100644 --- a/crates/fspy_client_unix/src/lib.rs +++ b/crates/fspy_client_unix/src/lib.rs @@ -8,7 +8,7 @@ pub mod convert; pub mod raw_exec; -use std::{ffi::OsStr, fmt::Debug, io::ErrorKind, os::unix::ffi::OsStrExt as _, path::Path}; +use std::{ffi::OsStr, fmt::Debug, os::unix::ffi::OsStrExt as _, path::Path}; use convert::{ToAbsolutePath, ToAccessMode}; use fspy_shared::ipc::{PathAccess, channel::Sender}; @@ -45,25 +45,16 @@ impl Client { /// # Panics /// /// Panics when the payload is missing, malformed, or cannot be decoded, - /// and when the channel is there but cannot be attached to. A process - /// with no sender has no way to tell the receiver it recorded nothing, - /// and a trace that silently omits every access it made is worse than - /// no trace. + /// and when the channel is there but cannot be attached to (see + /// [`ChannelConf::sender`](fspy_shared::ipc::channel::ChannelConf::sender)). pub fn from_env(envs: impl Iterator) -> Self { let encoded_payload = decode_payload_from_env(envs).unwrap(); - let ipc_sender = match encoded_payload.payload.ipc_channel_conf.sender() { - Ok(sender) => Some(sender), - // The channel is over: this process started after the root - // target exited, so everything it does from here happens past - // the receiver's boundary and recording nothing loses nothing. - // Silently, because a preload library writing to the traced - // process's stderr corrupts whatever that process is printing. - Err(error) if matches!(error.kind(), ErrorKind::NotFound | ErrorKind::BrokenPipe) => { - None - } - Err(error) => panic!("fspy: cannot attach to the trace channel: {error}"), - }; + // `None` when the channel is already over, which happens when this + // process starts after the root target exited. Nothing is said + // about it: a preload library writing to the traced process's + // stderr corrupts whatever that process is printing. + let ipc_sender = encoded_payload.payload.ipc_channel_conf.sender(); Self { encoded_payload, ipc_sender } } diff --git a/crates/fspy_preload_windows/src/windows/client.rs b/crates/fspy_preload_windows/src/windows/client.rs index f45ca1984..bbe4e0c11 100644 --- a/crates/fspy_preload_windows/src/windows/client.rs +++ b/crates/fspy_preload_windows/src/windows/client.rs @@ -1,4 +1,4 @@ -use std::{cell::SyncUnsafeCell, ffi::CStr, io::ErrorKind, mem::MaybeUninit}; +use std::{cell::SyncUnsafeCell, ffi::CStr, mem::MaybeUninit}; use fspy_detours_sys::DetourCopyPayloadToProcess; use fspy_shared::{ @@ -16,22 +16,11 @@ impl<'a> Client<'a> { pub fn from_payload_bytes(payload_bytes: &'a [u8]) -> Self { let payload: Payload<'a> = wincode::deserialize_exact(payload_bytes).unwrap(); - let ipc_sender = match payload.channel_conf.sender() { - Ok(sender) => Some(sender), - // The channel is over: this process started after the root - // target exited, so everything it does from here happens past - // the receiver's boundary and recording nothing loses nothing. - // Silently, because a detours DLL writing to the traced - // process's stderr corrupts whatever that process is printing. - Err(error) if matches!(error.kind(), ErrorKind::NotFound | ErrorKind::BrokenPipe) => { - None - } - // The channel is there but cannot be attached to. A process - // with no sender has no way to tell the receiver it recorded - // nothing, and a trace that silently omits every access it made - // is worse than no trace. - Err(error) => panic!("fspy: cannot attach to the trace channel: {error}"), - }; + // `None` when the channel is already over, which happens when this + // process starts after the root target exited. Nothing is said + // about it: a detours DLL writing to the traced process's stderr + // corrupts whatever that process is printing. + let ipc_sender = payload.channel_conf.sender(); Self { payload, ipc_sender } } diff --git a/crates/fspy_shared/src/ipc/channel/mod.rs b/crates/fspy_shared/src/ipc/channel/mod.rs index 5d008d5b6..d2bfaac41 100644 --- a/crates/fspy_shared/src/ipc/channel/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/mod.rs @@ -157,55 +157,52 @@ impl Drop for ShmKeeper { } impl ChannelConf { - /// Creates a sender. + /// Creates a sender, or `None` when the channel is already over. /// - /// Never blocks. + /// Never blocks. `None` means the receiver removed the backing file, + /// or sealed the region before removing it and this call caught the + /// gate in between. Either way whatever the caller does next happens + /// past the receiver's boundary, so recording nothing loses nothing. /// - /// # Errors - /// - /// Every way attaching can fail, for the caller to sort out. Two of - /// them say the channel is simply over, and a caller that meets them - /// has lost nothing by recording nothing, because whatever it does - /// next happens past the receiver's boundary: - /// - /// - [`io::ErrorKind::NotFound`]: the receiver removed the backing - /// file. - /// - [`io::ErrorKind::BrokenPipe`]: the receiver sealed the region - /// before removing it, and this call caught the gate in between. + /// # Panics /// - /// Anything else means the channel is there but cannot be attached to, - /// which is a caller with no way to report what it then fails to - /// record. - pub fn sender(&self) -> io::Result { + /// When the channel is there but cannot be attached to: its path is + /// unreadable, the file refuses to open or map, or the region cannot + /// hold the protocol. A process with no sender has no way to tell the + /// receiver it recorded nothing, and a trace that silently omits every + /// access a process made is worse than no trace, so it stops here. + #[must_use] + pub fn sender(&self) -> Option { // The arena never touches the process heap, so this stays safe in // the preload contexts that create senders (pre-`main` constructors, // the Windows loader lock). let arena = fspy_nostd_alloc::arena(); - let shm_path = self.shm_id.to_os_c_string_in(&arena).ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidData, - "the channel's shared-memory path is not a valid C string", - ) - })?; - let mapping = fspy_shm::open(shm_path.as_c_str().as_thin()) - .and_then(|handle| handle.map()) - .map_err(shm_error_to_io)?; + let shm_path = self + .shm_id + .to_os_c_string_in(&arena) + .expect("the channel's shared-memory path is not a valid C string"); + let mapping = match fspy_shm::open(shm_path.as_c_str().as_thin()) { + Ok(handle) => handle.map().expect("cannot map the shared-memory channel"), + Err(error) => { + let error = shm_error_to_io(error); + // The receiver removed the backing file, so it has already + // stopped collecting. + if error.kind() == io::ErrorKind::NotFound { + return None; + } + panic!("cannot open the shared-memory channel: {error}"); + } + }; // SAFETY: `mapping` is a freshly mapped shared memory region created // zero-initialized by `channel` and accessed only through the // `shm_io` protocol by every attached process. - let writer = unsafe { ShmWriter::new(mapping, to_usize(self.slots)) }.ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidData, - "the shared-memory region cannot hold the channel", - ) - })?; + let writer = unsafe { ShmWriter::new(mapping, to_usize(self.slots)) } + .expect("the shared-memory region cannot hold the channel"); + // The receiver sealed the region but has not removed it yet. if writer.is_closed() { - return Err(io::Error::new( - io::ErrorKind::BrokenPipe, - "the channel has been closed by the receiver", - )); + return None; } - Ok(Sender { writer }) + Some(Sender { writer }) } } @@ -439,7 +436,7 @@ mod tests { let _frames = receiver.close().unwrap(); let cmd = command_for_fn!(conf, |conf: ChannelConf| { - print!("{}", conf.sender().is_ok()); + print!("{}", conf.sender().is_some()); }); let output = std::process::Command::from(cmd).output().unwrap(); assert!(B(&output.stdout) == B("false")); @@ -452,7 +449,7 @@ mod tests { drop(receiver); let cmd = command_for_fn!(conf, |conf: ChannelConf| { - print!("{}", conf.sender().is_ok()); + print!("{}", conf.sender().is_some()); }); let output = std::process::Command::from(cmd).output().unwrap(); assert!(B(&output.stdout) == B("false")); From 97a6c40b26b1ec245c12fc31b60f8e6c94076445 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Mon, 17 Aug 2026 15:42:26 +0800 Subject: [PATCH 87/92] test(fspy): pin the check that lets the rest of the channel assume a region MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `channel` attaches a throwaway writer to prove the region can host the protocol. Nothing tested it, and two panics depend on it: `sender` and `Receiver::close` both treat a region that cannot hold the protocol as impossible, on the grounds that creation already refused it. Removing the check and running this test shows what it buys: `channel` accepts the size, and the panic lands in `sender` instead — which runs in every traced process's preload, where the cause is furthest from the config that caused it. The size comes from an environment variable, so that is reachable by configuration, not only by a bug. Co-Authored-By: Claude Opus 5 --- crates/fspy_shared/src/ipc/channel/mod.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/fspy_shared/src/ipc/channel/mod.rs b/crates/fspy_shared/src/ipc/channel/mod.rs index d2bfaac41..b5fb33f47 100644 --- a/crates/fspy_shared/src/ipc/channel/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/mod.rs @@ -344,6 +344,19 @@ mod tests { /// until slots are touched. const SIZE: ChannelSize = ChannelSize { capacity: 1 << 30, slots: 1 << 24 }; + /// A size whose two halves do not fit has to fail here, at creation. + /// Everything downstream treats the region as able to host the + /// protocol: `sender` panics when it cannot, and so does + /// `Receiver::close`. + #[test] + fn a_capacity_too_small_for_the_table_fails_the_channel() { + // The counters alone need sixteen bytes, and each slot eight more. + let Err(error) = channel(ChannelSize { capacity: 8, slots: 1 }) else { + panic!("a region too small for the protocol made a channel"); + }; + assert!(error.kind() == io::ErrorKind::InvalidInput); + } + /// The shared-memory path is generated absolute, so a sender in a process /// with a different working directory and a relative temporary directory /// must still attach. From a9a30a4761d358a05ce2ec4ae1523911181b3afd Mon Sep 17 00:00:00 2001 From: wan9chi Date: Mon, 17 Aug 2026 16:25:55 +0800 Subject: [PATCH 88/92] refactor(vt): check the tracking error where the accesses are taken `tracking_fell_short` re-derived a condition its own caller already implied. fspy is attached only when `input` or `output` asks for inferred paths (`CacheState::new`), and the spawn flag is that same `fspy.is_some()`, so `path_accesses` is `Some` exactly when the task infers. The function tested `fspy.is_some() && infers && ...` where the first two are one fact. That mattered beyond the redundancy: because the check claimed to let a short trace past for a task that declares everything, `observe_fspy` had to be ready for an `Err` it could never see, and answered it by treating a lost trace as an empty one. A wrong trace and no trace are not the same thing, and nothing should have to decide that twice. One `let ... else` takes the accesses and turns the run away if they are not all of them. `observe_fspy` now receives what it uses, an `Option<&PathAccessIterable>`, and has nothing left to interpret. Co-Authored-By: Claude Opus 5 --- crates/vt/src/session/execute/cache_update.rs | 55 +++++-------------- 1 file changed, 13 insertions(+), 42 deletions(-) diff --git a/crates/vt/src/session/execute/cache_update.rs b/crates/vt/src/session/execute/cache_update.rs index 67a597ebb..6d320a083 100644 --- a/crates/vt/src/session/execute/cache_update.rs +++ b/crates/vt/src/session/execute/cache_update.rs @@ -89,16 +89,18 @@ pub(super) async fn update_cache( return (CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::NonZeroExitStatus), None); } - if tracking_fell_short(outcome, metadata, fspy) { - // The task made more file accesses than the tracking channel had - // room for, so what arrived is a subset of what it touched. An - // entry built from a subset would replay with inputs and outputs - // missing. + // The accesses, or `None` when the task was not tracked at all. An + // `Err` means it made more file accesses than the tracking channel had + // room for, so what arrived is a subset of what it touched, and an entry + // built from a subset would replay with inputs and outputs missing. + #[cfg(fspy)] + let Ok(path_accesses) = outcome.path_accesses.as_ref().map(Result::as_ref).transpose() else { return (CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::TrackingIncomplete), None); - } + }; let fspy_outcome = observe_fspy( - outcome, + #[cfg(fspy)] + path_accesses, metadata, fspy, &ignored_input_rels, @@ -192,29 +194,6 @@ pub(super) async fn update_cache( } } -/// Whether the run's tracking came up short of what it needs to be -/// cached: the accesses this run inferred are incomplete, and the task's -/// config asks for inferred ones. -/// -/// A task that spells out every input and output does not consult them, so -/// a short trace costs it nothing. -fn tracking_fell_short( - outcome: &ChildOutcome, - metadata: &CacheMetadata, - fspy: Option<&super::FspyTracking<'_>>, -) -> bool { - #[cfg(fspy)] - { - let infers = metadata.input_config.includes_auto || metadata.output_config.includes_auto; - fspy.is_some() && infers && outcome.path_accesses.as_ref().is_some_and(Result::is_err) - } - #[cfg(not(fspy))] - { - let _ = (outcome, metadata, fspy); - false - } -} - /// Summarize the run's fspy observations. `Some` iff tracking was both /// requested (`tracking.fspy.is_some()`) and compiled in (`cfg(fspy)`). On a /// `cfg(not(fspy))` build this is always `None`, and [`update_cache`] @@ -225,7 +204,7 @@ fn tracking_fell_short( /// `path_writes` is filtered by user-configured output negatives and /// tool-reported `ignoreOutput` paths before read-write overlap detection. fn observe_fspy( - outcome: &ChildOutcome, + #[cfg(fspy)] path_accesses: Option<&fspy::PathAccessIterable>, metadata: &CacheMetadata, fspy: Option<&super::FspyTracking<'_>>, ignored_input_rels: &FxHashSet, @@ -236,16 +215,8 @@ fn observe_fspy( { use super::tracked_accesses::TrackedPathAccesses; - outcome.path_accesses.as_ref().map(|raw| { - // A trace that came up short reads as empty. It never reaches - // here for a task that infers anything from it, because - // `tracking_fell_short` has already turned the run away; one - // that spells out its inputs and outputs ignores the trace - // either way. - let tracked = raw.as_ref().map_or_else( - |_| TrackedPathAccesses::default(), - |raw| TrackedPathAccesses::from_raw(raw, workspace_root), - ); + path_accesses.map(|raw| { + let tracked = TrackedPathAccesses::from_raw(raw, workspace_root); let filtered_path_reads: HashMap = // fspy can be attached for auto-output-only tasks. In that // mode reads must not become inferred inputs. @@ -293,7 +264,7 @@ fn observe_fspy( } #[cfg(not(fspy))] { - let _ = (outcome, metadata, fspy, ignored_input_rels, ignored_output_rels, workspace_root); + let _ = (metadata, fspy, ignored_input_rels, ignored_output_rels, workspace_root); None } } From 0adaa4acf6ca3ef9aa6e24ec4dc53355e497bcd2 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Tue, 18 Aug 2026 10:35:57 +0800 Subject: [PATCH 89/92] refactor(fspy): fix the slot count where both ends can read it The slot count travelled: chosen in `fspy`, passed into `channel`, written into the `ChannelConf` for senders to read back, and kept on the `Receiver` so it could seal. Both ends have to agree on it, and every hop was a chance to disagree. It is a constant in the channel layer now. `ChannelConf` carries only the shared-memory id again, `Receiver` only the mapping, and `channel` takes the capacity by itself. `ChannelSize` and `from_usize` existed to carry the number and are gone with it; `shm_io` still takes the count as an argument, since a protocol module should not know one caller's number. The value is the one the ratio produced at the size a tracked run gets: 2^26 slots, one per 56 payload bytes of 4 GiB. The table is half a gibibyte of sparse address space, untouched until slots are claimed, so a channel still pays only for the slots it uses. What changes is that a region now has to clear that table before it can hold anything: the e2e case asks for 512 MiB plus the 64 KiB of records it means to overrun, where it used to ask for 64 KiB flat. Co-Authored-By: Claude Opus 5 --- crates/fspy/src/ipc.rs | 14 ++-- crates/fspy/src/unix/mod.rs | 2 +- crates/fspy/src/windows/mod.rs | 2 +- crates/fspy_shared/src/ipc/channel/mod.rs | 70 ++++++++++--------- .../src/ipc/channel/shm_io/layout.rs | 24 ++----- .../fspy_shared/src/ipc/channel/shm_io/mod.rs | 1 - crates/fspy_shared/src/ipc/mod.rs | 16 ----- .../fixtures/fspy_shm_capacity/snapshots.toml | 8 +-- ...capacity_env_sizes_the_tracking_channel.md | 8 +-- 9 files changed, 56 insertions(+), 89 deletions(-) diff --git a/crates/fspy/src/ipc.rs b/crates/fspy/src/ipc.rs index fbb1e1485..da8f7828d 100644 --- a/crates/fspy/src/ipc.rs +++ b/crates/fspy/src/ipc.rs @@ -1,5 +1,5 @@ use fspy_shared::ipc::{ - ChannelSize, PathAccess, + PathAccess, channel::{FrameReader, Receiver}, }; @@ -17,23 +17,19 @@ const DEFAULT_SHM_CAPACITY: usize = 4 * 1024 * 1024 * 1024; /// nothing outside this repository should set it. const SHM_CAPACITY_ENV: &str = "VP_RUN_INTERNAL_FSPY_SHM_CAPACITY"; -/// How much shared memory to give the next tracked run, split at one -/// record per 64 bytes: 8 for its descriptor and 56 for its payload. -/// Records run a few hundred bytes each, so payload space runs out well -/// before slots do. +/// How much shared memory to give the next tracked run. /// /// # Panics /// /// When the override is set to something that is not a byte count. It is /// ours to set, so a value we cannot read is a mistake worth stopping for /// rather than quietly ignoring. -pub fn shm_size() -> ChannelSize { - let capacity = std::env::var_os(SHM_CAPACITY_ENV).map_or(DEFAULT_SHM_CAPACITY, |value| { +pub fn shm_capacity() -> usize { + std::env::var_os(SHM_CAPACITY_ENV).map_or(DEFAULT_SHM_CAPACITY, |value| { value.to_str().and_then(|value| value.parse().ok()).unwrap_or_else(|| { panic!("{SHM_CAPACITY_ENV} is not a byte count: {}", value.display()) }) - }); - ChannelSize { capacity, slots: capacity / 64 } + }) } /// The path accesses a run reported through the IPC channel. diff --git a/crates/fspy/src/unix/mod.rs b/crates/fspy/src/unix/mod.rs index 0ea6b413e..bc9dbca89 100644 --- a/crates/fspy/src/unix/mod.rs +++ b/crates/fspy/src/unix/mod.rs @@ -80,7 +80,7 @@ impl SpyImpl { #[cfg(not(target_env = "musl"))] let (ipc_channel_conf, ipc_receiver) = - channel(crate::ipc::shm_size()).map_err(SpawnError::ChannelCreation)?; + channel(crate::ipc::shm_capacity()).map_err(SpawnError::ChannelCreation)?; let payload = Payload { #[cfg(not(target_env = "musl"))] diff --git a/crates/fspy/src/windows/mod.rs b/crates/fspy/src/windows/mod.rs index eb568a9f5..d5a884e46 100644 --- a/crates/fspy/src/windows/mod.rs +++ b/crates/fspy/src/windows/mod.rs @@ -84,7 +84,7 @@ impl SpyImpl { command.creation_flags(CREATE_SUSPENDED); let (channel_conf, receiver) = - channel(crate::ipc::shm_size()).map_err(SpawnError::ChannelCreation)?; + channel(crate::ipc::shm_capacity()).map_err(SpawnError::ChannelCreation)?; let mut spawn_success = false; let spawn_success = &mut spawn_success; diff --git a/crates/fspy_shared/src/ipc/channel/mod.rs b/crates/fspy_shared/src/ipc/channel/mod.rs index b5fb33f47..6a0da0fde 100644 --- a/crates/fspy_shared/src/ipc/channel/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/mod.rs @@ -13,7 +13,7 @@ use allocator_api2::alloc::Global; use fspy_nostd::Fat; use fspy_nostd_alloc::OsCString; use fspy_shm::Mapping; -use shm_io::{SealError, ShmReader, ShmWriter, from_usize, to_usize}; +use shm_io::{SealError, ShmReader, ShmWriter}; /// Reads the committed frames of a sealed channel; borrows the shared /// mapping, which stays alive (and mapped) until this value drops. @@ -21,7 +21,7 @@ pub type FrameReader = shm_io::ShmReader; use uuid::Uuid; use wincode::{SchemaRead, SchemaWrite, Serialize as _, config::DefaultConfig}; -use super::{ChannelSize, IpcStr}; +use super::IpcStr; /// Prefix of shared-memory backing file names inside the system temporary /// directory. @@ -31,19 +31,24 @@ use super::{ChannelSize, IpcStr}; /// uniquely named `0o600` files in the sticky-bit temp directory avoid that. const SHM_BACKING_PREFIX: &str = "vite-task-fspy-"; +/// Descriptor slots in every channel, one per record. +/// +/// Fixed here so both ends agree on it without carrying it between them. +/// At the 4 GiB a tracked run gets it is one 8-byte slot per 56 payload +/// bytes, and records run a few hundred bytes each, so payload space runs +/// out long before slots do. The table is sparse address space until the +/// slots are claimed, so a channel pays only for the ones it uses. +const SLOTS: usize = 1 << 26; + /// Serializable configuration to create channel senders. #[derive(SchemaWrite, SchemaRead, Clone, Debug)] pub struct ChannelConf { shm_id: Box, - /// The slot count the region was created with, since a sender cannot - /// work it out from the mapping's size alone. - slots: u64, } /// Creates a mpsc IPC channel with one receiver and a `ChannelConf` that can be passed around processes and used to create multiple senders. #[expect(clippy::missing_errors_doc, reason = "non-vt crate: cannot use vt_str/vt_path types")] -pub fn channel(size: ChannelSize) -> io::Result<(ChannelConf, Receiver)> { - let ChannelSize { capacity, slots } = size; +pub fn channel(capacity: usize) -> io::Result<(ChannelConf, Receiver)> { let shm_c_path = os_c_string(shm_backing_path()?.as_os_str())?; let handle = fspy_shm::create(shm_c_path.as_c_str().as_thin(), capacity).map_err(shm_error_to_io)?; @@ -56,19 +61,16 @@ pub fn channel(size: ChannelSize) -> io::Result<(ChannelConf, Receiver)> { // task now, not at its first record. // SAFETY: the region was just created zero-initialized and is only // accessed through the `shm_io` protocol. - if unsafe { ShmWriter::new(&mapping, slots) }.is_none() { + if unsafe { ShmWriter::new(&mapping, SLOTS) }.is_none() { return Err(io::Error::new( io::ErrorKind::InvalidInput, - "the shared-memory capacity cannot hold that many slots", + "the shared-memory capacity has no room for the descriptor table", )); } - let conf = ChannelConf { - shm_id: IpcStr::from_os_c_str(keeper.path.as_c_str()).to_boxed(), - slots: from_usize(slots), - }; + let conf = ChannelConf { shm_id: IpcStr::from_os_c_str(keeper.path.as_c_str()).to_boxed() }; - Ok((conf, Receiver { _keeper: keeper, mapping, slots })) + Ok((conf, Receiver { _keeper: keeper, mapping })) } /// Encodes `path` as an owned NUL-terminated platform C string. @@ -196,7 +198,7 @@ impl ChannelConf { // SAFETY: `mapping` is a freshly mapped shared memory region created // zero-initialized by `channel` and accessed only through the // `shm_io` protocol by every attached process. - let writer = unsafe { ShmWriter::new(mapping, to_usize(self.slots)) } + let writer = unsafe { ShmWriter::new(mapping, SLOTS) } .expect("the shared-memory region cannot hold the channel"); // The receiver sealed the region but has not removed it yet. if writer.is_closed() { @@ -259,8 +261,6 @@ pub struct Receiver { /// may attach. _keeper: ShmKeeper, mapping: Mapping, - /// The slot count the region was created with, needed again to seal it. - slots: usize, } // SAFETY: `Receiver` only holds the mapping; it accesses it exclusively @@ -297,11 +297,11 @@ impl Receiver { /// When the region cannot hold the protocol, which [`channel`] proved /// it could before any sender saw it. pub fn close(self) -> Result { - let Self { _keeper: keeper, mapping, slots } = self; + let Self { _keeper: keeper, mapping } = self; // SAFETY: `mapping` was created zero-initialized by `channel`, its // address is stable and independently owned, and all attached // processes access it only through the `shm_io` protocol. - let sealed = unsafe { ShmReader::seal(mapping, slots) }; + let sealed = unsafe { ShmReader::seal(mapping, SLOTS) }; // Remove the backing file only after the gate is shut. A process // that attaches in between finds a closed channel and gives up // cleanly; one that found the file already gone could not attach at @@ -340,18 +340,20 @@ mod tests { use super::*; use crate::ipc::{AccessMode, IpcPath, PathAccess}; - /// A gibibyte of sparse address space, so its table costs nothing - /// until slots are touched. - const SIZE: ChannelSize = ChannelSize { capacity: 1 << 30, slots: 1 << 24 }; + /// A gibibyte, which clears the table's half of sparse address space + /// and leaves the rest for payloads. None of it costs real memory + /// until something is written there. + const CAPACITY: usize = 1 << 30; - /// A size whose two halves do not fit has to fail here, at creation. - /// Everything downstream treats the region as able to host the - /// protocol: `sender` panics when it cannot, and so does + /// A capacity with no room for the table has to fail here, at + /// creation. Everything downstream treats the region as able to host + /// the protocol: `sender` panics when it cannot, and so does /// `Receiver::close`. #[test] fn a_capacity_too_small_for_the_table_fails_the_channel() { - // The counters alone need sixteen bytes, and each slot eight more. - let Err(error) = channel(ChannelSize { capacity: 8, slots: 1 }) else { + // The counters alone need sixteen bytes, and the table needs eight + // per slot on top. + let Err(error) = channel(8) else { panic!("a region too small for the protocol made a channel"); }; assert!(error.kind() == io::ErrorKind::InvalidInput); @@ -362,7 +364,7 @@ mod tests { /// must still attach. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn sender_ignores_changed_temp_and_working_directory() { - let (conf, receiver) = channel(SIZE).unwrap(); + let (conf, receiver) = channel(CAPACITY).unwrap(); let changed_cwd = temp_dir().join(format!("fspy-ipc-changed-cwd-{}", Uuid::new_v4())); fs::create_dir(&changed_cwd).unwrap(); @@ -392,7 +394,7 @@ mod tests { /// here rather than in a build. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn sender_round_trips_records() { - let (conf, receiver) = channel(SIZE).unwrap(); + let (conf, receiver) = channel(CAPACITY).unwrap(); let sender = conf.sender().unwrap(); // A record path carries the platform's own string form: bytes on // unix, UTF-16 on Windows. @@ -423,7 +425,7 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn smoke() { - let (conf, receiver) = channel(SIZE).unwrap(); + let (conf, receiver) = channel(CAPACITY).unwrap(); let cmd = command_for_fn!(conf, |conf: ChannelConf| { let sender = conf.sender().unwrap(); let frame_size = NonZeroUsize::new(2).unwrap(); @@ -445,7 +447,7 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[expect(clippy::print_stdout, reason = "test diagnostics")] async fn forbid_new_senders_after_close() { - let (conf, receiver) = channel(SIZE).unwrap(); + let (conf, receiver) = channel(CAPACITY).unwrap(); let _frames = receiver.close().unwrap(); let cmd = command_for_fn!(conf, |conf: ChannelConf| { @@ -458,7 +460,7 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[expect(clippy::print_stdout, reason = "test diagnostics")] async fn forbid_new_senders_after_receiver_dropped() { - let (conf, receiver) = channel(SIZE).unwrap(); + let (conf, receiver) = channel(CAPACITY).unwrap(); drop(receiver); let cmd = command_for_fn!(conf, |conf: ChannelConf| { @@ -472,7 +474,7 @@ mod tests { /// claim any new frame afterwards. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn attached_sender_cannot_claim_after_close() { - let (conf, receiver) = channel(SIZE).unwrap(); + let (conf, receiver) = channel(CAPACITY).unwrap(); let sender = conf.sender().unwrap(); let mut frame = sender.writer.claim_frame(NonZeroUsize::new(2).unwrap()).unwrap(); @@ -490,7 +492,7 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn concurrent_senders() { - let (conf, receiver) = channel(SIZE).unwrap(); + let (conf, receiver) = channel(CAPACITY).unwrap(); for i in 0u16..200 { let cmd = command_for_fn!((conf.clone(), i), |(conf, i): (ChannelConf, u16)| { let sender = conf.sender().unwrap(); diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs index c5278aa5f..93e94d81c 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/layout.rs @@ -102,32 +102,18 @@ const _: () = assert!(size_of::() == 2 * size_of::()); // 3. **Receiver read.** `Iter` loads each descriptor with `Acquire`, so a // descriptor it sees brings the payload bytes along. -/// Requires the target's `usize` to be as wide as a `u64`, which is what -/// makes [`to_usize`] and [`from_usize`] lossless. Stated once, and cited -/// by both. -const EQUAL_WIDTHS: () = - assert!(size_of::() == size_of::(), "requires a 64-bit target"); - /// Converts an integer into a `usize`. /// -/// Never loses bits: the bound takes only what fits a `u64`, and -/// [`EQUAL_WIDTHS`] lets only targets whose `usize` is that wide build -/// this module. Those asserts are why the casts are safe, so the pair sits -/// here rather than at module scope. They are the only `as` in the -/// protocol. +/// Never loses bits: the bound takes only what fits a `u64`, and the +/// assert lets only targets whose `usize` is that wide build this module. +/// That assert is why the cast is safe, so it sits here rather than at +/// module scope. It is the only `as` in the protocol. #[expect(clippy::cast_possible_truncation, reason = "the assert allows only equal widths")] pub fn to_usize(value: impl Into) -> usize { - const { EQUAL_WIDTHS }; + const { assert!(size_of::() == size_of::(), "requires a 64-bit target") }; value.into() as usize } -/// Converts a `usize` into a `u64`; the inverse of [`to_usize`]. -#[expect(clippy::as_conversions, reason = "the assert allows only equal widths")] -pub const fn from_usize(value: usize) -> u64 { - const { EQUAL_WIDTHS }; - value as u64 -} - /// Casts to a pointer of another type, returning `None` when the pointer /// is not aligned for `U`: a stable stand-in for the still-unstable /// [`<*mut T>::try_cast_aligned`][std]. diff --git a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs index e815eef46..f8ed4b2a8 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io/mod.rs @@ -25,7 +25,6 @@ mod writer; use std::ptr::slice_from_raw_parts_mut; use fspy_shm::Mapping; -pub use layout::{from_usize, to_usize}; pub use reader::{SealError, ShmReader}; // Only tests name a claim's failure; a sender skips the record either // way, so production matches on `Ok`/`Err` alone. diff --git a/crates/fspy_shared/src/ipc/mod.rs b/crates/fspy_shared/src/ipc/mod.rs index 43a127196..9c49e7371 100644 --- a/crates/fspy_shared/src/ipc/mod.rs +++ b/crates/fspy_shared/src/ipc/mod.rs @@ -8,22 +8,6 @@ pub use fspy_ipc_str::IpcStr; pub use ipc_path::IpcPath; use wincode::{SchemaRead, SchemaWrite}; -/// How much shared memory a channel gets, and how many records fit in it. -/// -/// Both numbers come from the caller: this crate has no way to guess how -/// many records a workload makes. Both ends of one channel must agree on -/// the slot count, which the receiver passes to `channel` and every sender -/// reads back out of the `ChannelConf`. -#[derive(Clone, Copy, Debug)] -pub struct ChannelSize { - /// Bytes of shared memory. The descriptor table takes the front of it - /// and payloads take the rest. - pub capacity: usize, - /// Descriptor slots, one per record. A record past this many is - /// refused just like one the payload area has no room for. - pub slots: usize, -} - #[derive(SchemaWrite, SchemaRead, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)] pub struct AccessMode(u8); diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/snapshots.toml b/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/snapshots.toml index 5ea5c6527..c17310656 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/snapshots.toml +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/snapshots.toml @@ -1,7 +1,7 @@ [[e2e]] name = "shm_capacity_env_sizes_the_tracking_channel" comment = """ -`VP_RUN_INTERNAL_FSPY_SHM_CAPACITY` sizes the shared memory a tracked task reports its file accesses through. The task makes twenty thousand of them, and a 64 KiB channel holds a thousand: one descriptor slot per 64 bytes of the region. +`VP_RUN_INTERNAL_FSPY_SHM_CAPACITY` sizes the shared memory a tracked task reports its file accesses through. The task makes twenty thousand of them, and this run leaves 64 KiB for them once the descriptor table has taken its half-gibibyte of sparse address space, which is nowhere near enough. The task runs to the end anyway: recording must never stop the program doing the work, so the accesses past that go unrecorded and the process carries on to a clean exit, printing its last line. What the run cannot claim is that it saw every file the task touched, so it is not cached, and the second run says the same rather than replaying an entry built from part of a trace. @@ -17,9 +17,9 @@ steps = [ ], envs = [ [ "VP_RUN_INTERNAL_FSPY_SHM_CAPACITY", - "65536", + "536936448", ], - ], comment = "64 KiB, a thousand slots for twenty thousand accesses" }, + ], comment = "512 MiB of table and 64 KiB of room for records" }, { argv = [ "vt", "run", @@ -28,7 +28,7 @@ steps = [ ], envs = [ [ "VP_RUN_INTERNAL_FSPY_SHM_CAPACITY", - "65536", + "536936448", ], ], comment = "nothing was cached to replay" }, ] diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/snapshots/shm_capacity_env_sizes_the_tracking_channel.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/snapshots/shm_capacity_env_sizes_the_tracking_channel.md index abbc9b792..07c1d4ac5 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/snapshots/shm_capacity_env_sizes_the_tracking_channel.md +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/snapshots/shm_capacity_env_sizes_the_tracking_channel.md @@ -1,14 +1,14 @@ # shm_capacity_env_sizes_the_tracking_channel -`VP_RUN_INTERNAL_FSPY_SHM_CAPACITY` sizes the shared memory a tracked task reports its file accesses through. The task makes twenty thousand of them, and a 64 KiB channel holds a thousand: one descriptor slot per 64 bytes of the region. +`VP_RUN_INTERNAL_FSPY_SHM_CAPACITY` sizes the shared memory a tracked task reports its file accesses through. The task makes twenty thousand of them, and this run leaves 64 KiB for them once the descriptor table has taken its half-gibibyte of sparse address space, which is nowhere near enough. The task runs to the end anyway: recording must never stop the program doing the work, so the accesses past that go unrecorded and the process carries on to a clean exit, printing its last line. What the run cannot claim is that it saw every file the task touched, so it is not cached, and the second run says the same rather than replaying an entry built from part of a trace. Not on musl, which has no preload: those builds collect through the seccomp supervisor, on the runner's own side of the boundary, so they have no shared-memory channel to fill. -## `VP_RUN_INTERNAL_FSPY_SHM_CAPACITY=65536 vt run -v stat` +## `VP_RUN_INTERNAL_FSPY_SHM_CAPACITY=536936448 vt run -v stat` -64 KiB, a thousand slots for twenty thousand accesses +512 MiB of table and 64 KiB of room for records ``` $ vtt stat-many 20000 @@ -29,7 +29,7 @@ Task Details: ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ``` -## `VP_RUN_INTERNAL_FSPY_SHM_CAPACITY=65536 vt run -v stat` +## `VP_RUN_INTERNAL_FSPY_SHM_CAPACITY=536936448 vt run -v stat` nothing was cached to replay From e30b7f80cbf3bab312760824ce43acd11f7c7f61 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sun, 16 Aug 2026 23:11:17 +0800 Subject: [PATCH 90/92] test(fspy-benchmark): add a contended access row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The thread count was one constant shared by every suite, fixed at two — enough to represent a normal tracked process, but not enough to make writers fight over the channel's counters. Each record costs two atomic read-modify-writes on words every other thread is touching, and two threads barely provoke that. The count moves onto the suite, so the existing rows keep their two threads and their comparability, and a new `access-contended` row runs the same opens under eight. It halves the iterations over half the opens, so four times the threads cost about the same wall clock. Co-Authored-By: Claude Fable 5 --- crates/fspy_benchmark/src/main.rs | 32 +++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/crates/fspy_benchmark/src/main.rs b/crates/fspy_benchmark/src/main.rs index 17f69e6a4..51b2c574c 100644 --- a/crates/fspy_benchmark/src/main.rs +++ b/crates/fspy_benchmark/src/main.rs @@ -29,10 +29,6 @@ const DYNAMIC_TARGET: &str = env!("CARGO_BIN_FILE_FSPY_BENCHMARK_TARGET"); #[cfg(all(target_os = "linux", target_arch = "x86_64"))] const STATIC_TARGET: &str = env!("CARGO_BIN_FILE_FSPY_BENCHMARK_STATIC_TARGET"); -/// Threads the target runs, passed through to it. Two, because a process that -/// fspy tracks rarely accesses files from one thread. -const THREADS: &str = "2"; - /// What a suite reads out of its launches. #[derive(Clone, Copy)] enum Metric { @@ -44,6 +40,10 @@ enum Metric { struct Suite { name: &'static str, + /// Threads the target runs, passed through to it. Two for most suites, + /// because a process that fspy tracks rarely accesses files from one + /// thread. + threads: &'static str, /// Opens per target thread, passed through to it. opens: &'static str, /// Measured iterations. Each one launches every arm once. @@ -62,6 +62,7 @@ struct Suite { /// affords fewer of them in the same time. const LAUNCH_SUITE: Suite = Suite { name: "launch", + threads: "2", opens: "0", iterations: if cfg!(windows) { 150 } else { 300 }, warmup: 5, @@ -75,6 +76,7 @@ const LAUNCH_SUITE: Suite = Suite { /// joining on top. const ACCESS_SUITE: Suite = Suite { name: "access", + threads: "2", opens: "2048", iterations: 102, warmup: 3, @@ -84,6 +86,7 @@ const ACCESS_SUITE: Suite = Suite { const RELATIVE_ACCESS_SUITE: Suite = Suite { name: "access-relative", + threads: "2", opens: "2048", iterations: 102, warmup: 3, @@ -91,6 +94,22 @@ const RELATIVE_ACCESS_SUITE: Suite = Suite { relative: true, }; +/// The same opens under enough threads to make them fight over the tracker's +/// shared counters. Each record costs two atomic read-modify-writes on words +/// every other thread is also touching, so this is the row that prices how +/// that contention scales; the two-thread rows barely provoke it. Runs half +/// the iterations of the plain suite over half the opens, so four times the +/// threads cost about the same wall clock. +const CONTENDED_ACCESS_SUITE: Suite = Suite { + name: "access-contended", + threads: "8", + opens: "1024", + iterations: 54, + warmup: 3, + metric: Metric::Typical, + relative: false, +}; + struct Backend { name: &'static str, target: &'static str, @@ -114,7 +133,8 @@ fn main() { validate(base_launcher, backend.target, relative); } } - for suite in [&LAUNCH_SUITE, &ACCESS_SUITE, &RELATIVE_ACCESS_SUITE] { + for suite in [&LAUNCH_SUITE, &ACCESS_SUITE, &RELATIVE_ACCESS_SUITE, &CONTENDED_ACCESS_SUITE] + { run_suite(backend, suite, base_launcher.as_deref()); } } @@ -237,7 +257,7 @@ fn launch(launcher: &OsStr, mode: Option<&str>, backend: &Backend, suite: &Suite command.arg("--relative"); } let output = command - .args([backend.target, THREADS, suite.opens]) + .args([backend.target, suite.threads, suite.opens]) .stdin(Stdio::null()) .stderr(Stdio::inherit()) .output() From 6339ebadba9b1d462afd5917dd9014017f14bf97 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sun, 16 Aug 2026 23:11:17 +0800 Subject: [PATCH 91/92] test(fspy-benchmark): add a contended access row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The thread count was one constant shared by every suite, fixed at two — enough to represent a normal tracked process, but not enough to make writers fight over the channel's counters. Each record costs two atomic read-modify-writes on words every other thread is touching, and two threads barely provoke that. The count moves onto the suite, so the existing rows keep their two threads and their comparability, and a new `access-contended` row runs the same opens under eight. It halves the iterations over half the opens, so four times the threads cost about the same wall clock. Co-Authored-By: Claude Fable 5 --- crates/fspy_benchmark/src/main.rs | 32 +++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/crates/fspy_benchmark/src/main.rs b/crates/fspy_benchmark/src/main.rs index 17f69e6a4..51b2c574c 100644 --- a/crates/fspy_benchmark/src/main.rs +++ b/crates/fspy_benchmark/src/main.rs @@ -29,10 +29,6 @@ const DYNAMIC_TARGET: &str = env!("CARGO_BIN_FILE_FSPY_BENCHMARK_TARGET"); #[cfg(all(target_os = "linux", target_arch = "x86_64"))] const STATIC_TARGET: &str = env!("CARGO_BIN_FILE_FSPY_BENCHMARK_STATIC_TARGET"); -/// Threads the target runs, passed through to it. Two, because a process that -/// fspy tracks rarely accesses files from one thread. -const THREADS: &str = "2"; - /// What a suite reads out of its launches. #[derive(Clone, Copy)] enum Metric { @@ -44,6 +40,10 @@ enum Metric { struct Suite { name: &'static str, + /// Threads the target runs, passed through to it. Two for most suites, + /// because a process that fspy tracks rarely accesses files from one + /// thread. + threads: &'static str, /// Opens per target thread, passed through to it. opens: &'static str, /// Measured iterations. Each one launches every arm once. @@ -62,6 +62,7 @@ struct Suite { /// affords fewer of them in the same time. const LAUNCH_SUITE: Suite = Suite { name: "launch", + threads: "2", opens: "0", iterations: if cfg!(windows) { 150 } else { 300 }, warmup: 5, @@ -75,6 +76,7 @@ const LAUNCH_SUITE: Suite = Suite { /// joining on top. const ACCESS_SUITE: Suite = Suite { name: "access", + threads: "2", opens: "2048", iterations: 102, warmup: 3, @@ -84,6 +86,7 @@ const ACCESS_SUITE: Suite = Suite { const RELATIVE_ACCESS_SUITE: Suite = Suite { name: "access-relative", + threads: "2", opens: "2048", iterations: 102, warmup: 3, @@ -91,6 +94,22 @@ const RELATIVE_ACCESS_SUITE: Suite = Suite { relative: true, }; +/// The same opens under enough threads to make them fight over the tracker's +/// shared counters. Each record costs two atomic read-modify-writes on words +/// every other thread is also touching, so this is the row that prices how +/// that contention scales; the two-thread rows barely provoke it. Runs half +/// the iterations of the plain suite over half the opens, so four times the +/// threads cost about the same wall clock. +const CONTENDED_ACCESS_SUITE: Suite = Suite { + name: "access-contended", + threads: "8", + opens: "1024", + iterations: 54, + warmup: 3, + metric: Metric::Typical, + relative: false, +}; + struct Backend { name: &'static str, target: &'static str, @@ -114,7 +133,8 @@ fn main() { validate(base_launcher, backend.target, relative); } } - for suite in [&LAUNCH_SUITE, &ACCESS_SUITE, &RELATIVE_ACCESS_SUITE] { + for suite in [&LAUNCH_SUITE, &ACCESS_SUITE, &RELATIVE_ACCESS_SUITE, &CONTENDED_ACCESS_SUITE] + { run_suite(backend, suite, base_launcher.as_deref()); } } @@ -237,7 +257,7 @@ fn launch(launcher: &OsStr, mode: Option<&str>, backend: &Backend, suite: &Suite command.arg("--relative"); } let output = command - .args([backend.target, THREADS, suite.opens]) + .args([backend.target, suite.threads, suite.opens]) .stdin(Stdio::null()) .stderr(Stdio::inherit()) .output() From c2e0969b9548fe6d08048941c473f215ec88edc1 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Tue, 18 Aug 2026 11:10:24 +0800 Subject: [PATCH 92/92] fix(vt): say what to do about an untrackable task "tracking ran out of room for this task's file accesses" gave a reader three problems: `tracking` arrives as a bare noun for anyone who does not know Vite+ watches files, "ran out of room" reads as disk or memory and invites buying a bigger machine, and it left them nowhere to go. It now names the cause in the reader's own terms and points at the way out, the same shape as the message beside it for an OS without auto-inference. The advice works: a task that declares `input` and `output` never has tracking attached, so it cannot meet this at all. Co-Authored-By: Claude Opus 5 --- crates/vt/src/session/reporter/summary.rs | 2 +- .../snapshots/shm_capacity_env_sizes_the_tracking_channel.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/vt/src/session/reporter/summary.rs b/crates/vt/src/session/reporter/summary.rs index 2895e5c80..7bc36e5bb 100644 --- a/crates/vt/src/session/reporter/summary.rs +++ b/crates/vt/src/session/reporter/summary.rs @@ -577,7 +577,7 @@ impl TaskResult { } = self { return Str::from( - "→ Not cached: tracking ran out of room for this task's file accesses", + "→ Not cached: this task used more files than automatic tracking can record. Configure `input` and `output` manually to enable caching.", ); } // fspy-unsupported-on-this-OS message — same overrides precedence as above diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/snapshots/shm_capacity_env_sizes_the_tracking_channel.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/snapshots/shm_capacity_env_sizes_the_tracking_channel.md index 07c1d4ac5..63bd0394a 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/snapshots/shm_capacity_env_sizes_the_tracking_channel.md +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/fspy_shm_capacity/snapshots/shm_capacity_env_sizes_the_tracking_channel.md @@ -25,7 +25,7 @@ Performance: 0% cache hit rate Task Details: ──────────────────────────────────────────────── [1] fspy-shm-capacity#stat: $ vtt stat-many 20000 ✓ - → Not cached: tracking ran out of room for this task's file accesses + → Not cached: this task used more files than automatic tracking can record. Configure `input` and `output` manually to enable caching. ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ``` @@ -48,6 +48,6 @@ Performance: 0% cache hit rate Task Details: ──────────────────────────────────────────────── [1] fspy-shm-capacity#stat: $ vtt stat-many 20000 ✓ - → Not cached: tracking ran out of room for this task's file accesses + → Not cached: this task used more files than automatic tracking can record. Configure `input` and `output` manually to enable caching. ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ```