Skip to content
Draft
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 number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Changelog

- **Fixed** `vp run` no longer crashes with `SIGBUS` when fspy's path-access stream exceeds the size of `/dev/shm` (e.g. Docker's 64 MiB default). The shared-memory IPC channel now backs its mapping with a regular file in `temp_dir()` on Linux, which is bounded by the overlay/host filesystem (typically gigabytes) rather than the small `/dev/shm` tmpfs cap. macOS and Windows are unaffected ([#1453](https://github.com/voidzero-dev/vite-plus/issues/1453))
- **Fixed** `vp run` no longer aborts with `failed to prepare the command for injection: Invalid argument` when the user environment already has `LD_PRELOAD` (Linux) or `DYLD_INSERT_LIBRARIES` (macOS) set. The tracer shim is now appended to any existing value and placed last, so user preloads keep their symbol-interposition precedence ([#340](https://github.com/voidzero-dev/vite-task/issues/340))
- **Changed** Arguments passed after a task name (e.g. `vp run test some-filter`) are now forwarded only to that task. Tasks pulled in via `dependsOn` no longer receive them ([#324](https://github.com/voidzero-dev/vite-task/issues/324))
- **Fixed** Windows file access tracking no longer panics when a task touches malformed paths that cannot be represented as workspace-relative inputs ([#330](https://github.com/voidzero-dev/vite-task/pull/330))
Expand Down
16 changes: 14 additions & 2 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 @@ -79,6 +79,7 @@ fspy_preload_unix = { path = "crates/fspy_preload_unix", artifact = "cdylib", ta
fspy_preload_windows = { path = "crates/fspy_preload_windows", artifact = "cdylib", target = "target" }
fspy_seccomp_unotify = { path = "crates/fspy_seccomp_unotify" }
fspy_shared = { path = "crates/fspy_shared" }
fspy_shared_memory = { path = "crates/fspy_shared_memory" }
fspy_shared_unix = { path = "crates/fspy_shared_unix" }
futures = "0.3.31"
futures-util = "0.3.31"
Expand Down
7 changes: 5 additions & 2 deletions crates/fspy_shared/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ bitflags = { workspace = true }
bstr = { workspace = true }
bytemuck = { workspace = true, features = ["must_cast", "derive"] }
native_str = { workspace = true }
shared_memory = { workspace = true, features = ["logging"] }
fspy_shared_memory = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
uuid = { workspace = true, features = ["v4"] }
Expand All @@ -26,9 +26,12 @@ winapi = { workspace = true, features = ["std"] }
assert2 = { workspace = true }
ctor = { workspace = true }
rustc-hash = { workspace = true }
shared_memory = { workspace = true, features = ["logging"] }
fspy_shared_memory = { workspace = true }
subprocess_test = { workspace = true }

[target.'cfg(target_os = "linux")'.dev-dependencies]
nix = { workspace = true, features = ["mount", "sched", "user"] }

[lints]
workspace = true

Expand Down
145 changes: 120 additions & 25 deletions crates/fspy_shared/src/ipc/channel/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ mod shm_io;

use std::{env::temp_dir, fs::File, io, mem::MaybeUninit, ops::Deref, path::PathBuf, sync::Arc};

use shared_memory::{Shmem, ShmemConf};
use fspy_shared_memory::Shmem;
pub use shm_io::FrameMut;
use shm_io::{ShmReader, ShmWriter};
use tracing::debug;
Expand Down Expand Up @@ -63,22 +63,11 @@ 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()));

#[cfg_attr(
not(windows),
expect(unused_mut, reason = "mut required on Windows, unused on Unix")
)]
let mut conf = ShmemConf::new().size(capacity);
// On Windows, allow opening raw shared memory (without backing file) for DLL injection scenarios
#[cfg(target_os = "windows")]
{
conf = conf.allow_raw(true);
}

let shm = conf.create().map_err(io::Error::other)?;
let shm = Shmem::create(capacity)?;

let conf = ChannelConf {
lock_file_path: lock_file_path.as_os_str().into(),
shm_id: shm.get_os_id().into(),
shm_id: shm.os_id().into(),
shm_size: capacity,
};

Expand All @@ -98,17 +87,7 @@ impl ChannelConf {
let lock_file = File::open(self.lock_file_path.to_cow_os_str())?;
lock_file.try_lock_shared()?;

#[cfg_attr(
not(windows),
expect(unused_mut, reason = "mut required on Windows, unused on Unix")
)]
let mut conf = ShmemConf::new().size(self.shm_size).os_id(&self.shm_id);
// On Windows, allow opening raw shared memory (without backing file) for DLL injection scenarios
#[cfg(target_os = "windows")]
{
conf = conf.allow_raw(true);
}
let shm = conf.open().map_err(io::Error::other)?;
let shm = Shmem::open(&self.shm_id, self.shm_size)?;
// SAFETY: `shm` is a freshly opened shared memory region with valid pointer and size.
// Exclusive write access is ensured by the shared file lock held by this sender.
let writer = unsafe { ShmWriter::new(shm) };
Expand Down Expand Up @@ -299,4 +278,120 @@ mod tests {
received_values.sort_unstable();
assert_eq!(received_values, (0u16..200).collect::<Vec<u16>>());
}

/// Regression test for <https://github.com/voidzero-dev/vite-plus/issues/1453>.
///
/// The current implementation backs the channel with POSIX shared memory
/// (`shm_open`), which stores its file under `/dev/shm`. On hosts where
/// `/dev/shm` is size-capped (e.g. Docker's 64 MiB default) a workload
/// whose path-access stream exceeds that cap triggers `SIGBUS` in the
/// sender when tmpfs can't allocate the next page. `cache: false` works
/// around it by skipping fspy entirely.
///
/// This test reproduces the crash without `sudo` and without needing the
/// test environment itself to have a small `/dev/shm`: it enters an
/// unprivileged user+mount namespace in a subprocess and remounts
/// `/dev/shm` as a 1 MiB tmpfs, then writes past the cap via the real
/// `channel()` API. The test asserts the subprocess completes cleanly;
/// today it dies from `SIGBUS`. Switching the backing store to
/// `memfd_create` (which is sized against RAM + overcommit, not
/// `/dev/shm`) will let this test pass unchanged — the subprocess's
/// `/dev/shm` constraint becomes irrelevant.
#[test]
#[cfg(target_os = "linux")]
#[cfg_attr(miri, ignore = "miri can't mmap or unshare")]
fn channel_survives_constrained_dev_shm() {
use std::os::unix::process::ExitStatusExt;

// Capacity chosen to comfortably exceed the 1 MiB tmpfs cap. The
// `ftruncate` inside `shared_memory` is lazy on tmpfs, so this
// allocation itself succeeds; the crash happens when the sender
// later writes into pages that tmpfs can no longer back.
const CAPACITY: usize = 16 * 1024 * 1024;

let cmd = command_for_fn!((), |(): ()| {
enter_userns_with_small_dev_shm();

let (conf, _receiver) = super::channel(CAPACITY).expect("channel creation");
let sender = conf.sender().expect("sender creation");

// Claim a single 4 MiB frame and fill it byte-by-byte. The
// first ~1 MiB of writes fit within the tmpfs quota; the next
// byte faults on an un-backed page -> SIGBUS.
let frame_size = NonZeroUsize::new(4 * 1024 * 1024).unwrap();
let mut frame = sender.claim_frame(frame_size).expect("claim_frame");
frame.fill(0xAB);
});

let status = std::process::Command::from(cmd).status().unwrap();

assert!(
status.success(),
"channel writes should survive a constrained /dev/shm, but the \
subprocess exited abnormally: code={:?} signal={:?}. \
SIGBUS ({sigbus}) indicates the issue #1453 reproduction: tmpfs \
page allocation failed on a write to the shm-backed mapping.",
status.code(),
status.signal(),
sigbus = nix::sys::signal::Signal::SIGBUS as i32,
);
}

/// Procfs files must be opened without `O_CREAT` — synthetic inodes
/// reject the create bit on some hosts with `EACCES`. `std::fs::write`
/// uses `File::create` (which sets `O_CREAT`), so we can't use it here.
#[cfg(target_os = "linux")]
fn write_procfs(path: &str, content: &str) -> std::io::Result<()> {
use std::io::Write;
let mut f = std::fs::OpenOptions::new().write(true).open(path)?;
f.write_all(content.as_bytes())
}

/// Enter a fresh user + mount namespace in which the current uid is
/// mapped to 0, then remount `/dev/shm` as a 1 MiB tmpfs. Must be called
/// before any threads are spawned in the current process. Panics on any
/// failure — unprivileged user namespace support is a hard requirement
/// for this reproduction.
#[cfg(target_os = "linux")]
fn enter_userns_with_small_dev_shm() {
use nix::{
mount::{MsFlags, mount},
sched::{CloneFlags, unshare},
unistd::{Gid, Uid},
};

let uid = Uid::current().as_raw();
let gid = Gid::current().as_raw();

unshare(CloneFlags::CLONE_NEWUSER | CloneFlags::CLONE_NEWNS)
.expect("unshare(CLONE_NEWUSER|CLONE_NEWNS)");

// Inside the new user namespace the current process starts as
// "nobody" until the id maps are written.
write_procfs("/proc/self/uid_map", &std::format!("0 {uid} 1\n"))
.expect("write /proc/self/uid_map");
// setgroups must be denied before an unprivileged gid_map write
// will be accepted (user_namespaces(7)). An absent
// /proc/self/setgroups means setgroups(2) is already permanently
// denied in an ancestor user namespace, so the gid_map
// precondition is already satisfied — not an environment skip.
match write_procfs("/proc/self/setgroups", "deny") {
Ok(()) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => panic!("write /proc/self/setgroups: {err}"),
}
write_procfs("/proc/self/gid_map", &std::format!("0 {gid} 1\n"))
.expect("write /proc/self/gid_map");

// Make the root mount private recursively so tmpfs mounts inside
// this namespace don't propagate back to the host.
mount(None::<&str>, "/", None::<&str>, MsFlags::MS_REC | MsFlags::MS_PRIVATE, None::<&str>)
.expect("mount --make-rprivate /");

// Remount /dev/shm as a 1 MiB tmpfs. The size= option is honored by
// tmpfs and enforced at page-fault time: accesses to pages the
// tmpfs can't back raise SIGBUS.
mount(Some("tmpfs"), "/dev/shm", Some("tmpfs"), MsFlags::empty(), Some("size=1m"))
.expect("mount tmpfs size=1m at /dev/shm");
}
}
10 changes: 5 additions & 5 deletions crates/fspy_shared/src/ipc/channel/shm_io.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use std::{
};

use bytemuck::must_cast;
use shared_memory::Shmem;
use fspy_shared_memory::Shmem;
use wincode::{SchemaWrite, Serialize as _, config::DefaultConfig};

// `ShmWriter` writes headers using atomic operations to prevent partial writes due to crashes,
Expand Down Expand Up @@ -665,21 +665,21 @@ mod tests {
#[test]
#[cfg(not(miri))]
fn real_shm_across_processes() {
use shared_memory::ShmemConf;
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 = ShmemConf::new().size(1024 * 1024).create().unwrap();
let shm_name = shm.get_os_id().to_owned();
let shm = Shmem::create(SHM_SIZE).unwrap();
let shm_name = shm.os_id().to_owned();

let children: Vec<Child> = (0..CHILD_COUNT)
.map(|child_index| {
let cmd = command_for_fn!(
(shm_name.clone(), child_index),
|(shm_name, child_index): (String, usize)| {
let shm = ShmemConf::new().os_id(shm_name).open().unwrap();
let shm = Shmem::open(&shm_name, SHM_SIZE).unwrap();
// SAFETY: `shm` is a freshly opened shared memory region with a valid
// pointer and size. Concurrent write access is safe because `ShmWriter`
// uses atomic operations.
Expand Down
1 change: 1 addition & 0 deletions crates/fspy_shared_memory/.clippy.toml
29 changes: 29 additions & 0 deletions crates/fspy_shared_memory/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
[package]
name = "fspy_shared_memory"
version = "0.0.0"
edition.workspace = true
license.workspace = true
publish = false

[dependencies]
uuid = { workspace = true, features = ["v4"] }

[target.'cfg(target_os = "linux")'.dependencies]
nix = { workspace = true, features = ["mman"] }

[target.'cfg(any(target_os = "macos", target_os = "windows"))'.dependencies]
shared_memory = { workspace = true }

[dev-dependencies]
assert2 = { workspace = true }
ctor = { workspace = true }
subprocess_test = { workspace = true }

[lints]
workspace = true

[lib]
doctest = false

[package.metadata.cargo-shear]
ignored = ["ctor"]
52 changes: 52 additions & 0 deletions crates/fspy_shared_memory/src/external.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
use std::{io, num::NonZeroUsize};

use shared_memory::ShmemError;

pub struct Inner {
shm: shared_memory::Shmem,
}

impl Inner {
pub fn create(size: NonZeroUsize) -> io::Result<Self> {
let shm = shared_memory::ShmemConf::new().size(size.get()).create().map_err(to_io_error)?;
Ok(Self { shm })
}

pub fn open(os_id: &str, size: NonZeroUsize) -> io::Result<Self> {
let shm = shared_memory::ShmemConf::new()
.size(size.get())
.os_id(os_id)
.open()
.map_err(to_io_error)?;
Ok(Self { shm })
}

pub fn os_id(&self) -> &str {
self.shm.get_os_id()
}

pub fn as_ptr(&self) -> *mut u8 {
self.shm.as_ptr()
}

pub fn len(&self) -> usize {
self.shm.len()
}
}

/// Translate `shared_memory`'s opaque error into an `io::Error`, preserving
/// the OS error code when present so callers can dispatch on `ErrorKind`
/// (e.g. `NotFound` for "creator dropped the region").
fn to_io_error(err: ShmemError) -> io::Error {
match err {
ShmemError::MapCreateFailed(code) | ShmemError::MapOpenFailed(code) =>
{
#[expect(
clippy::cast_possible_wrap,
reason = "OS error codes are small positive integers; the cast is exact"
)]
io::Error::from_raw_os_error(code as i32)
}
other => io::Error::other(other),
}
}
Loading