diff --git a/CHANGELOG.md b/CHANGELOG.md index c387a4be..113574b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Changelog +- **Fixed** Automatic file-access tracking on Linux now works in containers and Kubernetes runners with limited `/dev/shm` space ([#353](https://github.com/voidzero-dev/vite-task/issues/353), [#523](https://github.com/voidzero-dev/vite-task/pull/523)). - **Fixed** Windows automatic input tracking now records executable image reads when process creation performs the lookup inside `NtCreateUserProcess` ([#518](https://github.com/voidzero-dev/vite-task/pull/518)). - **Fixed** Failures while waiting for a started task process to exit no longer incorrectly say the process failed to spawn ([#515](https://github.com/voidzero-dev/vite-task/pull/515)). - **Fixed** Missing env vars requested through `@voidzero-dev/vite-task-client` now return `undefined` instead of `null`, preserving Vite production `NODE_ENV` semantics when builds run through `vp run` ([#508](https://github.com/voidzero-dev/vite-task/pull/508)). diff --git a/Cargo.lock b/Cargo.lock index df0e4d76..2205bf2d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1358,6 +1358,7 @@ dependencies = [ "rustc-hash", "subprocess_test", "thiserror 2.0.18", + "tokio", "tracing", "uuid", "vite_path", @@ -1387,8 +1388,15 @@ name = "fspy_shm" version = "0.0.0" dependencies = [ "ctor", + "memmap2", + "passfd", + "rustix", "shared_memory", "subprocess_test", + "tokio", + "tokio-util", + "tracing", + "uuid", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 0a6c1845..b1091578 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -116,6 +116,7 @@ ref-cast = "1.0.24" regex = "1.11.3" rusqlite = "0.39.0" rustc-hash = "2.1.1" +rustix = "1.1" # SeccompAction::UserNotif (SECCOMP_RET_USER_NOTIF) was added after the latest published release (v0.5.0) seccompiler = { git = "https://github.com/rust-vmm/seccompiler", rev = "08587106340b8e3cb361c7561411510039436857" } serde = "1.0.219" diff --git a/crates/fspy_shared/Cargo.toml b/crates/fspy_shared/Cargo.toml index d8f6c38b..c854d023 100644 --- a/crates/fspy_shared/Cargo.toml +++ b/crates/fspy_shared/Cargo.toml @@ -27,6 +27,7 @@ assert2 = { workspace = true } ctor = { workspace = true } rustc-hash = { workspace = true } subprocess_test = { workspace = true } +tokio = { workspace = true, features = ["macros", "net", "rt-multi-thread", "time"] } [lints] workspace = true diff --git a/crates/fspy_shared/src/ipc/channel/mod.rs b/crates/fspy_shared/src/ipc/channel/mod.rs index 24031136..d08c9529 100644 --- a/crates/fspy_shared/src/ipc/channel/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/mod.rs @@ -113,9 +113,12 @@ impl Deref for Sender { } } -#[expect( - clippy::non_send_fields_in_send_ty, - reason = "`Sender` holds a shared file lock that ensures there's no reader, so `shm` can be safely written to" +#[cfg_attr( + not(target_os = "linux"), + expect( + clippy::non_send_fields_in_send_ty, + reason = "`Sender` holds a shared file lock that ensures there's no reader, so `shm` can be safely written to" + ) )] /// SAFETY: `Sender` holds a shared file lock that ensures there's no reader, so `shm` can be safely written to. unsafe impl Send for Sender {} @@ -131,9 +134,12 @@ pub struct Receiver { shm: Shm, } -#[expect( - clippy::non_send_fields_in_send_ty, - reason = "Receiver doesn't read or write `shm`. It only pass it to `ReceiverLockGuard` under the lock" +#[cfg_attr( + not(target_os = "linux"), + expect( + clippy::non_send_fields_in_send_ty, + reason = "Receiver doesn't read or write `shm`. It only passes it to `ReceiverLockGuard` under the lock" + ) )] /// SAFETY: `Receiver` doesn't read or write `shm`. It only passes it to `ReceiverLockGuard` under the lock. unsafe impl Send for Receiver {} @@ -200,8 +206,8 @@ mod tests { use super::*; - #[test] - fn smoke() { + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn smoke() { let (conf, receiver) = channel(100).unwrap(); let cmd = command_for_fn!(conf, |conf: ChannelConf| { let sender = conf.sender().unwrap(); @@ -220,9 +226,9 @@ mod tests { assert!(frames.next().is_none()); } - #[test] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[expect(clippy::print_stdout, reason = "test diagnostics")] - fn forbid_new_senders_after_locked() { + async fn forbid_new_senders_after_locked() { let (conf, receiver) = channel(42).unwrap(); let _lock = receiver.lock().unwrap(); @@ -233,9 +239,9 @@ mod tests { assert_eq!(B(&output.stdout), B("false")); } - #[test] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[expect(clippy::print_stdout, reason = "test diagnostics")] - fn forbid_new_senders_after_receiver_dropped() { + async fn forbid_new_senders_after_receiver_dropped() { let (conf, receiver) = channel(42).unwrap(); drop(receiver); @@ -246,8 +252,8 @@ mod tests { assert_eq!(B(&output.stdout), B("false")); } - #[test] - fn concurrent_senders() { + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn concurrent_senders() { let (conf, receiver) = channel(8192).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.rs b/crates/fspy_shared/src/ipc/channel/shm_io.rs index 537a42a5..62d927cd 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io.rs @@ -672,6 +672,18 @@ mod tests { const SHM_SIZE: usize = 1024 * 1024; + // On Linux, `fspy_shm::create` spawns the mapping's broker onto the + // ambient tokio runtime, which serves the child processes' opens. + #[cfg(target_os = "linux")] + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(1) + .enable_io() + .enable_time() + .build() + .unwrap(); + #[cfg(target_os = "linux")] + let _guard = runtime.enter(); + let shm = fspy_shm::create(SHM_SIZE).unwrap(); let shm_name = shm.id().to_owned(); diff --git a/crates/fspy_shm/Cargo.toml b/crates/fspy_shm/Cargo.toml index 39ee88ed..ece633d8 100644 --- a/crates/fspy_shm/Cargo.toml +++ b/crates/fspy_shm/Cargo.toml @@ -7,12 +7,22 @@ license.workspace = true publish = false rust-version.workspace = true -[dependencies] +[target.'cfg(not(target_os = "linux"))'.dependencies] shared_memory = { workspace = true, features = ["logging"] } +[target.'cfg(target_os = "linux")'.dependencies] +memmap2 = { workspace = true } +passfd = { workspace = true, features = ["async"] } +rustix = { workspace = true, features = ["fs", "net", "process"] } +tokio = { workspace = true, features = ["macros", "net", "rt", "time"] } +tokio-util = { workspace = true } +tracing = { workspace = true } +uuid = { workspace = true, features = ["v4"] } + [dev-dependencies] ctor = { workspace = true } subprocess_test = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } [lints] workspace = true diff --git a/crates/fspy_shm/README.md b/crates/fspy_shm/README.md index de20e060..9ae93f39 100644 --- a/crates/fspy_shm/README.md +++ b/crates/fspy_shm/README.md @@ -29,6 +29,10 @@ The public API is defined in [`src/lib.rs`](src/lib.rs). The channel hides that difference with its lock file. [`ChannelConf::sender`](../fspy_shared/src/ipc/channel/mod.rs) opens and locks the receiver's exact lock-file path before it calls `fspy_shm::open`. The receiver removes that path before dropping the owner, so a sender that starts later fails before opening shared memory. -## Backend boundary +## Platform designs -At this point in the stack, `fspy_shm` delegates mapping creation and opening to the [`shared_memory`](https://crates.io/crates/shared_memory) crate. Because callers use only the API above, later changes can replace the backend on each platform without changing the channel protocol. +Each platform keeps its implementation rationale beside its source: + +- [Linux: `memfd` with a descriptor broker](src/linux/README.md) + +At this point in the stack, Windows and macOS still delegate mapping creation and opening to the [`shared_memory`](https://crates.io/crates/shared_memory) crate. diff --git a/crates/fspy_shm/src/lib.rs b/crates/fspy_shm/src/lib.rs index b684719a..8656890a 100644 --- a/crates/fspy_shm/src/lib.rs +++ b/crates/fspy_shm/src/lib.rs @@ -1,10 +1,18 @@ #![doc = include_str!("../README.md")] +#[cfg(not(target_os = "linux"))] use std::io; +#[cfg(target_os = "linux")] +mod linux; + +#[cfg(target_os = "linux")] +pub use linux::{Shm, create, open}; +#[cfg(not(target_os = "linux"))] use shared_memory::{Shmem, ShmemConf}; /// An owned shared-memory mapping. +#[cfg(not(target_os = "linux"))] pub struct Shm { inner: Shmem, } @@ -18,6 +26,7 @@ pub struct Shm { /// # Errors /// /// Returns an error if the platform cannot create or map the region. +#[cfg(not(target_os = "linux"))] pub fn create(size: usize) -> io::Result { let conf = ShmemConf::new().size(size); #[cfg(target_os = "windows")] @@ -36,6 +45,7 @@ pub fn create(size: usize) -> io::Result { /// # Errors /// /// Returns an error if the mapping does not exist or cannot be mapped. +#[cfg(not(target_os = "linux"))] pub fn open(id: &str) -> io::Result { let conf = ShmemConf::new().os_id(id); #[cfg(target_os = "windows")] @@ -45,6 +55,7 @@ pub fn open(id: &str) -> io::Result { Ok(Shm { inner }) } +#[cfg(not(target_os = "linux"))] #[expect(clippy::len_without_is_empty, reason = "shared-memory mappings are always non-empty")] impl Shm { /// Returns this mapping's opaque platform identifier. @@ -86,11 +97,11 @@ mod tests { use super::{Shm, create, open}; - // Page-aligned on supported macOS and Windows targets. + // Page-aligned on all supported targets. const SIZE: usize = 64 * 1024; - #[test] - fn create_and_open_are_shared() { + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn create_and_open_are_shared() { let owner = create(SIZE).unwrap(); assert_eq!(owner.len(), SIZE); assert_eq!(owner.as_ptr() as usize % align_of::(), 0); @@ -107,8 +118,8 @@ mod tests { assert_eq!(read_byte(&owner, SIZE - 1), 29); } - #[test] - fn mapping_is_visible_across_processes() { + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn mapping_is_visible_across_processes() { let owner = create(SIZE).unwrap(); write_byte(&owner, 0, 17); @@ -117,12 +128,16 @@ mod tests { assert_eq!(read_byte(&opened, 0), 17); write_byte(&opened, SIZE - 1, 29); }); - assert!(Command::from(command).status().unwrap().success()); + let success = + tokio::task::spawn_blocking(move || Command::from(command).status().unwrap().success()) + .await + .unwrap(); + assert!(success); assert_eq!(read_byte(&owner, SIZE - 1), 29); } - #[test] - fn owner_drop_prevents_new_opens() { + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn owner_drop_prevents_new_opens() { let owner = create(SIZE).unwrap(); let id = owner.id().to_owned(); drop(owner); @@ -130,8 +145,8 @@ mod tests { assert!(open(&id).is_err()); } - #[test] - fn opened_mapping_survives_owner_drop() { + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn opened_mapping_survives_owner_drop() { let owner = create(SIZE).unwrap(); let id = owner.id().to_owned(); let opened = open(&id).unwrap(); diff --git a/crates/fspy_shm/src/linux/README.md b/crates/fspy_shm/src/linux/README.md new file mode 100644 index 00000000..5dee2565 --- /dev/null +++ b/crates/fspy_shm/src/linux/README.md @@ -0,0 +1,24 @@ +# Linux backend + +The Linux backend stores data in a sealed `memfd`. A process opens the mapping by connecting to an abstract Unix-domain socket and receiving the descriptor from a broker. It then accesses the mapped memory directly. + +The backend must avoid `/dev/shm` quotas, expose a large mapping without allocating every page up front, support synchronous opens from preload code, and stop new opens when the owner is dropped without invalidating existing views. + +## Options considered + +| Option | Decision | +| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------- | +| POSIX shared memory | Rejected because Linux stores it in `/dev/shm`, so it shares that mount's size limit. | +| System V shared memory | Rejected because IPC namespace limits affect availability and the owner must explicitly remove the segment. | +| Sparse temporary file | Rejected because dirty pages may reach disk, and sharing the path and deleting the file require additional handling. | +| `memfd` with descriptor broker | Selected. It avoids `/dev/shm` and System V limits. The kernel keeps it alive while a descriptor or mapping refers to it. | + +The broker accepts and serves clients with Tokio. Opening is synchronous because it can run before `main`, so creating an owner must occur inside a Tokio runtime. + +## Why not `shared_memory` + +`shared_memory` uses POSIX `shm_open` on Linux and cannot construct a mapping from a `memfd`, so it retains the `/dev/shm` dependency. + +## Lifetime semantics + +Dropping the owner stops the broker. Existing views remain valid; later opens fail. diff --git a/crates/fspy_shm/src/linux/broker.rs b/crates/fspy_shm/src/linux/broker.rs new file mode 100644 index 00000000..fd539b47 --- /dev/null +++ b/crates/fspy_shm/src/linux/broker.rs @@ -0,0 +1,233 @@ +use std::{ + ffi::OsStr, + future::Future, + io, + os::{ + fd::{AsRawFd, FromRawFd, OwnedFd}, + unix::ffi::OsStrExt, + }, + sync::Arc, +}; + +use passfd::{FdPassingExt as SyncFdPassingExt, tokio::FdPassingExt as AsyncFdPassingExt}; +use rustix::{ + io::Errno, + net::{ + AddressFamily, SocketAddrUnix, SocketFlags, SocketType, connect, socket_with, + sockopt::socket_peercred, + }, + process::{Uid, geteuid}, +}; +use tokio::{net::UnixListener, task::JoinSet}; +use tokio_util::sync::{CancellationToken, DropGuard}; +use tracing::{debug, warn}; +use uuid::Uuid; + +/// Creates the broker listener for `memfd` and returns the mapping id, the +/// broker task, and the guard whose drop stops the task. +/// +/// The listener is bound eagerly, so the id is usable as soon as the task is +/// spawned; connections arriving earlier wait in the listen backlog. +pub(super) fn new( + memfd: OwnedFd, +) -> io::Result<(String, impl Future + Send + 'static, DropGuard)> { + let id = Uuid::new_v4().simple().to_string(); + // Tokio treats a NUL-prefixed Unix socket path as a Linux abstract address. + // This keeps the broker out of the filesystem and makes the id the complete address. + let mut address = Vec::with_capacity(id.len() + 1); + address.push(0); + address.extend_from_slice(id.as_bytes()); + let listener = UnixListener::bind(OsStr::from_bytes(&address))?; + + let stop = CancellationToken::new(); + let service = run_broker(listener, memfd, geteuid(), stop.clone()); + Ok((id, service, stop.drop_guard())) +} + +async fn run_broker( + listener: UnixListener, + memfd: OwnedFd, + owner_uid: Uid, + stop: CancellationToken, +) { + let memfd = Arc::new(memfd); + let mut sends = JoinSet::new(); + loop { + tokio::select! { + biased; + () = stop.cancelled() => return, + _result = sends.join_next(), if !sends.is_empty() => {} + client = listener.accept() => match client { + Ok((client, _address)) => { + // Abstract sockets have no filesystem permissions, so authenticate the + // connecting process with the kernel-provided SO_PEERCRED credentials: + // https://man7.org/linux/man-pages/man7/unix.7.html + // D-Bus prefers the same mechanism because it requires no peer cooperation: + // https://gitlab.freedesktop.org/dbus/dbus/-/blob/958bf9db2100553bcd2fe2a854e1ebb42e886054/dbus/dbus-sysdeps-unix.c#L2296-2303 + let credentials = match socket_peercred(&client) { + Ok(credentials) => credentials, + Err(error) => { + debug!("shared-memory broker failed to read peer credentials: {error}"); + continue; + } + }; + if credentials.uid != owner_uid { + debug!("shared-memory broker rejected a client owned by another user"); + continue; + } + let memfd = Arc::clone(&memfd); + sends.spawn(async move { + if let Err(error) = + AsyncFdPassingExt::send_fd(&client, memfd.as_raw_fd()).await + { + debug!("shared-memory broker failed to send a descriptor: {error}"); + } + }); + } + Err(error) => { + warn!("shared-memory broker failed to accept a connection: {error}"); + return; + } + }, + } + } +} + +pub(super) fn request_memfd(id: &str) -> rustix::io::Result { + // Prevent the broker connection from leaking into later execs. + let socket = socket_with(AddressFamily::UNIX, SocketType::STREAM, SocketFlags::CLOEXEC, None)?; + let address = SocketAddrUnix::new_abstract_name(id.as_bytes())?; + connect(&socket, &address)?; + // `SCM_RIGHTS` does not preserve descriptor flags; `passfd` sets + // `FD_CLOEXEC` on the received descriptor before returning it. + let descriptor = SyncFdPassingExt::recv_fd(&socket.as_raw_fd()) + .map_err(|error| Errno::from_io_error(&error).unwrap_or(Errno::IO))?; + // SAFETY: passfd returns a newly received descriptor owned by the caller. + Ok(unsafe { OwnedFd::from_raw_fd(descriptor) }) +} + +#[cfg(test)] +mod tests { + use std::{io, process::Command}; + + use rustix::{ + fs::{SealFlags, fcntl_get_seals}, + io::{FdFlags, fcntl_getfd}, + }; + use subprocess_test::command_for_fn; + + use super::*; + + #[test] + fn broker_construction_is_independent_of_long_tmpdir() { + let command = command_for_fn!((), |(): ()| { + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(1) + .enable_io() + .enable_time() + .build() + .unwrap(); + let _guard = runtime.enter(); + let _owner = crate::create(4096).unwrap(); + }); + let status = std::thread::spawn(move || { + Command::from(command) + .env("TMPDIR", format!("/tmp/{}", "x".repeat(4096))) + .status() + .unwrap() + }) + .join() + .unwrap(); + assert!(status.success()); + } + + #[test] + fn create_without_runtime_fails() { + let error = match crate::create(4096) { + Ok(_owner) => panic!("create without a runtime should fail"), + Err(error) => error, + }; + assert!(error.to_string().contains("tokio runtime")); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn broker_stops_when_guard_drops() { + let memfd = rustix::fs::memfd_create("shared-memory-test", rustix::fs::MemfdFlags::CLOEXEC) + .map_err(io::Error::from) + .unwrap(); + let (_id, service, guard) = new(memfd).unwrap(); + let broker = tokio::spawn(service); + tokio::task::yield_now().await; + assert!(!broker.is_finished()); + + drop(guard); + tokio::time::timeout(std::time::Duration::from_secs(10), broker) + .await + .expect("broker should stop when the guard drops") + .unwrap(); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn received_descriptor_is_close_on_exec() { + let owner = crate::create(4096).unwrap(); + let id = owner.id().to_owned(); + let descriptor = request_memfd_blocking(id).await.unwrap(); + assert!(fcntl_getfd(&descriptor).unwrap().contains(FdFlags::CLOEXEC)); + assert_eq!( + fcntl_get_seals(&descriptor).unwrap(), + SealFlags::GROW | SealFlags::SHRINK | SealFlags::SEAL + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn broker_serves_concurrent_opens() { + let owner = crate::create(4096).unwrap(); + let id = owner.id().to_owned(); + let clients = (0..12) + .map(|_| { + let id = id.clone(); + tokio::task::spawn_blocking(move || crate::open(&id)) + }) + .collect::>(); + + for client in clients { + assert_eq!(client.await.unwrap().unwrap().len(), 4096); + } + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn brokers_are_isolated_by_abstract_name() { + let first = crate::create(4096).unwrap(); + let second = crate::create(4096).unwrap(); + let first_id = first.id().to_owned(); + let second_id = second.id().to_owned(); + + let first_opened = open_blocking(first_id).await.unwrap(); + let second_opened = open_blocking(second_id).await.unwrap(); + // SAFETY: Both mappings are live and the accesses are in bounds and synchronized. + unsafe { + first.as_ptr().write(17); + second.as_ptr().write(29); + assert_eq!(first_opened.as_ptr().read(), 17); + assert_eq!(second_opened.as_ptr().read(), 29); + } + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn unavailable_ids_are_rejected() { + let owner = crate::create(4096).unwrap(); + let id = owner.id().to_owned(); + + assert!(open_blocking("not-a-broker-id".to_owned()).await.is_err()); + assert!(open_blocking("x".repeat(108)).await.is_err()); + assert!(open_blocking(id).await.is_ok()); + } + + async fn request_memfd_blocking(id: String) -> rustix::io::Result { + tokio::task::spawn_blocking(move || request_memfd(&id)).await.unwrap() + } + + async fn open_blocking(id: String) -> io::Result { + tokio::task::spawn_blocking(move || crate::open(&id)).await.unwrap() + } +} diff --git a/crates/fspy_shm/src/linux/mod.rs b/crates/fspy_shm/src/linux/mod.rs new file mode 100644 index 00000000..298ee0f5 --- /dev/null +++ b/crates/fspy_shm/src/linux/mod.rs @@ -0,0 +1,114 @@ +#![doc = include_str!("README.md")] + +mod broker; + +use std::{io, slice}; + +use memmap2::{MmapOptions, MmapRaw}; +use rustix::fs::{MemfdFlags, SealFlags, fcntl_add_seals, fstat, ftruncate, memfd_create}; +use tokio_util::sync::DropGuard; + +/// An owned Linux shared-memory mapping. +pub struct Shm { + id: String, + mapping: MmapRaw, + /// Stops the owner's broker on drop. `None` for opened views. + _service: Option, +} + +/// Creates a sealed memfd mapping of `size` bytes and returns its owner. +/// +/// The memfd is handed out to other processes by a broker task spawned onto +/// the ambient tokio runtime. The broker stops on its own when the owner is +/// dropped, after which new [`open`] calls fail while already-open views stay +/// usable (see the [ownership semantics](crate)). +/// +/// # Errors +/// +/// Returns an error if no tokio runtime is active or the memfd, mapping, or +/// broker listener cannot be created. +pub fn create(size: usize) -> io::Result { + let runtime = tokio::runtime::Handle::try_current() + .map_err(|_| io::Error::other("creating Linux shared memory requires a tokio runtime"))?; + let size_u64 = valid_size(size)?; + // Prevent the descriptor from leaking across exec while permitting the + // size and seal set to be locked after initialization. + let memfd = + memfd_create("vite-task-shared-memory", MemfdFlags::CLOEXEC | MemfdFlags::ALLOW_SEALING)?; + ftruncate(&memfd, size_u64)?; + // Keep the initialized size fixed and prevent removal of these seals; + // writes through the shared mapping remain allowed. + fcntl_add_seals(&memfd, SealFlags::GROW | SealFlags::SHRINK | SealFlags::SEAL)?; + let mapping = MmapOptions::new().len(size).map_raw(&memfd)?; + let (id, service, guard) = broker::new(memfd)?; + runtime.spawn(service); + + Ok(Shm { id, mapping, _service: Some(guard) }) +} + +/// Opens a view of the memfd mapping identified by `id` through its broker. +/// +/// Guaranteed to succeed only while the mapping's owner is alive; the +/// returned view stays usable independently of the owner afterwards (see the +/// [ownership semantics](crate)). +/// +/// # Errors +/// +/// Returns an error if the identifier is invalid, the broker is gone, or the +/// received memfd has an invalid size. +pub fn open(id: &str) -> io::Result { + let memfd = broker::request_memfd(id)?; + let stat = fstat(&memfd)?; + let size = usize::try_from(stat.st_size) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid shared-memory size"))?; + if size == 0 { + return Err(io::Error::new(io::ErrorKind::InvalidData, "shared-memory size is zero")); + } + let mapping = MmapOptions::new().len(size).map_raw(&memfd)?; + Ok(Shm { id: id.to_owned(), mapping, _service: None }) +} + +fn valid_size(size: usize) -> io::Result { + if size == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "shared-memory size must be nonzero", + )); + } + u64::try_from(size) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "shared-memory size exceeds u64")) +} + +#[expect(clippy::len_without_is_empty, reason = "shared-memory mappings are always non-empty")] +impl Shm { + /// Returns this mapping's opaque broker identifier. + #[must_use] + pub fn id(&self) -> &str { + &self.id + } + + /// Returns the mapped length in bytes. + #[must_use] + pub fn len(&self) -> usize { + self.mapping.len() + } + + /// Returns a raw pointer to the first mapped byte. + #[must_use] + pub fn as_ptr(&self) -> *mut u8 { + self.mapping.as_mut_ptr() + } + + /// Returns the mapped bytes as a shared slice. + /// + /// # Safety + /// + /// The caller must ensure that no process or thread mutates the mapping for + /// the lifetime of the returned slice. + #[must_use] + pub unsafe fn as_slice(&self) -> &[u8] { + // SAFETY: The mapping is valid for its full length, and the caller + // guarantees that it is not mutated while the slice is borrowed. + unsafe { slice::from_raw_parts(self.mapping.as_ptr(), self.mapping.len()) } + } +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/constrained_dev_shm/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/constrained_dev_shm/snapshots.toml index 972e350b..ca240d76 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/constrained_dev_shm/snapshots.toml +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/constrained_dev_shm/snapshots.toml @@ -1,7 +1,7 @@ [[e2e]] name = "constrained_dev_shm" comment = """ -Mounting a one-page `/dev/shm` reproduces the SIGBUS seen before fspy moved its shared-memory backing to memfd. +With fspy's shared-memory backing moved to memfd, file-access tracking succeeds without consuming a one-page `/dev/shm` mount. """ platform = "linux-gnu" ignore = true diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/constrained_dev_shm/snapshots/constrained_dev_shm.md b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/constrained_dev_shm/snapshots/constrained_dev_shm.md index 6f3bee3e..4dd59fa3 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/constrained_dev_shm/snapshots/constrained_dev_shm.md +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/constrained_dev_shm/snapshots/constrained_dev_shm.md @@ -1,11 +1,9 @@ # constrained_dev_shm -Mounting a one-page `/dev/shm` reproduces the SIGBUS seen before fspy moved its shared-memory backing to memfd. +With fspy's shared-memory backing moved to memfd, file-access tracking succeeds without consuming a one-page `/dev/shm` mount. ## `vtt small_dev_shm vt run stress` -**Exit code:** 135 - ``` $ vtt stat_long_filename 1048576 ```