Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)).
Expand Down
8 changes: 8 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions crates/fspy_shared/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 20 additions & 14 deletions crates/fspy_shared/src/ipc/channel/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Expand All @@ -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 {}
Expand Down Expand Up @@ -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();
Expand All @@ -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();

Expand All @@ -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);

Expand All @@ -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)| {
Expand Down
12 changes: 12 additions & 0 deletions crates/fspy_shared/src/ipc/channel/shm_io.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
12 changes: 11 additions & 1 deletion crates/fspy_shm/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions crates/fspy_shm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
35 changes: 25 additions & 10 deletions crates/fspy_shm/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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,
}
Expand All @@ -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<Shm> {
let conf = ShmemConf::new().size(size);
#[cfg(target_os = "windows")]
Expand All @@ -36,6 +45,7 @@ pub fn create(size: usize) -> io::Result<Shm> {
/// # 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<Shm> {
let conf = ShmemConf::new().os_id(id);
#[cfg(target_os = "windows")]
Expand All @@ -45,6 +55,7 @@ pub fn open(id: &str) -> io::Result<Shm> {
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.
Expand Down Expand Up @@ -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::<usize>(), 0);
Expand All @@ -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);

Expand All @@ -117,21 +128,25 @@ 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);

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();
Expand Down
24 changes: 24 additions & 0 deletions crates/fspy_shm/src/linux/README.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading