Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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