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** Automatic file-access tracking on Linux and macOS now captures accesses from forked children that continue running without an `exec` ([#544](https://github.com/voidzero-dev/vite-task/issues/544)).
- **Fixed** `vp run` no longer fails while setting up task communication in default Codex CLI and Claude Code sandboxes that block Unix-domain sockets; Unix now uses named FIFOs ([#562](https://github.com/voidzero-dev/vite-task/issues/562)).
- **Fixed** The task cache now supports much larger automatically tracked input sets without hitting wincode's default 4 MiB sequence preallocation limit ([#554](https://github.com/voidzero-dev/vite-task/pull/554)).
- **Fixed** npm workspace patterns beginning with `./` now discover matching packages correctly ([vite-plus#2201](https://github.com/voidzero-dev/vite-plus/issues/2201), [#547](https://github.com/voidzero-dev/vite-task/pull/547)).
Expand Down
162 changes: 162 additions & 0 deletions crates/fspy/tests/fork.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
#![cfg(all(any(target_os = "linux", target_os = "macos"), not(target_env = "musl")))]

mod test_utils;

use std::{ffi::CString, fs, os::unix::ffi::OsStrExt as _, path::Path, time::Duration};

use fspy::AccessMode;
use test_log::test;
use test_utils::assert_contains;

#[test(tokio::test)]
async fn captures_access_after_fork_without_exec() -> anyhow::Result<()> {
let tempdir = tempfile::tempdir()?;
let prime_path = tempdir.path().join("prime");
let after_fork_path = tempdir.path().join("after-fork");
fs::write(&prime_path, [])?;
fs::write(&after_fork_path, [])?;

let accesses = track_fn!(
(
prime_path.as_os_str().as_bytes().to_vec(),
after_fork_path.as_os_str().as_bytes().to_vec(),
),
|(prime_path, after_fork_path): (Vec<u8>, Vec<u8>)| {
let prime_path = CString::new(prime_path).unwrap();
let after_fork_path = CString::new(after_fork_path).unwrap();
let inherited_fds = inherited_open_fds();
let ready = new_pipe();
let parent_alive = new_pipe();

// SAFETY: The process is single-threaded at this point in the test
// function, and both branches restrict post-fork work to libc calls.
let pid = unsafe { libc::fork() };
assert_ne!(pid, -1);

if pid == 0 {
// SAFETY: All descriptors and C strings were prepared before
// fork and are valid in the child.
unsafe {
libc::close(ready.read);
libc::close(parent_alive.write);
for fd in inherited_fds {
libc::close(fd);
}

// The first child access must acquire a sender backed by
// this process's own lock-file description.
open_and_close(&prime_path);
write_byte(ready.write);
libc::close(ready.write);

wait_for_eof(parent_alive.read);
libc::close(parent_alive.read);
// Without a child-owned sender, the receiver can finish
// as soon as the direct parent exits. Give it time to do
// so before making the access this test cares about.
sleep(Duration::from_millis(250));

open_and_close(&after_fork_path);
libc::_exit(0);
}
}

// SAFETY: These are the parent's valid pipe endpoints.
unsafe {
libc::close(ready.write);
libc::close(parent_alive.read);
read_byte(ready.read);
libc::close(ready.read);
}

// Keep `parent_alive.write` open. subprocess_test exits this direct
// parent as soon as the closure returns, which releases the child.
}
)
.await?;

assert_contains(&accesses, Path::new(&after_fork_path), AccessMode::READ);
Ok(())
}

#[derive(Clone, Copy)]
struct Pipe {
read: libc::c_int,
write: libc::c_int,
}

fn new_pipe() -> Pipe {
let mut fds = [-1; 2];
// SAFETY: `fds` points to two writable c_int values.
assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0);
Pipe { read: fds[0], write: fds[1] }
}

fn inherited_open_fds() -> Vec<libc::c_int> {
#[cfg(target_os = "linux")]
const FD_DIRECTORY: &str = "/proc/self/fd";
#[cfg(target_os = "macos")]
const FD_DIRECTORY: &str = "/dev/fd";

let mut fds = fs::read_dir(FD_DIRECTORY)
.unwrap()
.map(|entry| entry.unwrap().file_name().to_string_lossy().parse::<libc::c_int>().unwrap())
.filter(|fd| *fd > libc::STDERR_FILENO)
.collect::<Vec<_>>();

// Reading the directory temporarily opens an fd that may appear in its own
// listing. Remove descriptors that closed with the iterator before pipes
// are created and can reuse their numbers.
fds.retain(|fd| {
// SAFETY: F_GETFD only examines the numeric descriptor.
(unsafe { libc::fcntl(*fd, libc::F_GETFD) }) != -1
});
fds
}

unsafe fn open_and_close(path: &CString) {
// SAFETY: `path` is a valid NUL-terminated string.
let fd = unsafe { libc::open(path.as_ptr(), libc::O_RDONLY) };
if fd == -1 {
// SAFETY: Immediate process termination is valid in the fork child.
unsafe { libc::_exit(10) };
}
// SAFETY: `fd` was returned by open above.
unsafe { libc::close(fd) };
}

unsafe fn write_byte(fd: libc::c_int) {
let byte = [1_u8];
// SAFETY: `byte` is readable for one byte and `fd` is the pipe write end.
if unsafe { libc::write(fd, byte.as_ptr().cast(), byte.len()) } != 1 {
// SAFETY: Immediate process termination is valid in the fork child.
unsafe { libc::_exit(11) };
}
}

unsafe fn read_byte(fd: libc::c_int) {
let mut byte = [0_u8];
// SAFETY: `byte` is writable for one byte and `fd` is the pipe read end.
assert_eq!(unsafe { libc::read(fd, byte.as_mut_ptr().cast(), byte.len()) }, 1);
}

unsafe fn wait_for_eof(fd: libc::c_int) {
let mut byte = [0_u8];
// SAFETY: `byte` is writable for one byte and `fd` is the pipe read end.
if unsafe { libc::read(fd, byte.as_mut_ptr().cast(), byte.len()) } != 0 {
// SAFETY: Immediate process termination is valid in the fork child.
unsafe { libc::_exit(12) };
}
}

unsafe fn sleep(duration: Duration) {
let request = libc::timespec {
tv_sec: libc::time_t::try_from(duration.as_secs()).unwrap(),
tv_nsec: libc::c_long::from(duration.subsec_nanos()),
};
// SAFETY: `request` is a valid timespec and no remainder is requested.
if unsafe { libc::nanosleep(&raw const request, std::ptr::null_mut()) } != 0 {
// SAFETY: Immediate process termination is valid in the fork child.
unsafe { libc::_exit(13) };
}
}
95 changes: 95 additions & 0 deletions crates/fspy_preload_unix/src/client/fork_tracker/linux.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
use std::{
num::NonZeroUsize,
ptr::NonNull,
sync::atomic::{AtomicUsize, Ordering},
};

use nix::sys::mman::{MapFlags, MmapAdvise, ProtFlags, madvise, mmap_anonymous, munmap};

const INITIAL_GENERATION: usize = 1;
const MARKER_SIZE: NonZeroUsize = NonZeroUsize::new(size_of::<AtomicUsize>()).unwrap();

#[derive(Clone, Copy, Eq, PartialEq)]
pub struct ForkGeneration(usize);

pub struct ForkTracker {
marker: NonNull<AtomicUsize>,
next_generation: AtomicUsize,
}

impl ForkTracker {
#[cfg_attr(
test,
expect(dead_code, reason = "the preload constructor is disabled in unit-test builds")
)]
pub fn new() -> nix::Result<Self> {
// SAFETY: This creates a private anonymous mapping with a nonzero size.
let marker = unsafe {
mmap_anonymous(
None,
MARKER_SIZE,
ProtFlags::PROT_READ | ProtFlags::PROT_WRITE,
MapFlags::MAP_PRIVATE,
)
}?;

if let Err(error) = unsafe {
// SAFETY: `marker` identifies the mapping created above for its
// entire length. MADV_WIPEONFORK leaves the mapping in children
// but zeroes it.
madvise(marker, MARKER_SIZE.get(), MmapAdvise::MADV_WIPEONFORK)
} {
// SAFETY: The mapping is still owned here and has its original size.
let _ = unsafe { munmap(marker, MARKER_SIZE.get()) };
return Err(error);
}

let marker = marker.cast::<AtomicUsize>();
// SAFETY: The mapping is writable, aligned to a page boundary, and
// large enough for one AtomicUsize.
unsafe { marker.as_ptr().write(AtomicUsize::new(INITIAL_GENERATION)) };

Ok(Self { marker, next_generation: AtomicUsize::new(INITIAL_GENERATION) })
}

pub fn generation(&self) -> ForkGeneration {
let marker = self.marker();
let current = marker.load(Ordering::Acquire);
if current != 0 {
return ForkGeneration(current);
}

loop {
let candidate = self.next_generation.fetch_add(1, Ordering::Relaxed).wrapping_add(1);
if candidate == 0 {
continue;
}

match marker.compare_exchange(0, candidate, Ordering::AcqRel, Ordering::Acquire) {
Ok(_) => return ForkGeneration(candidate),
Err(current) => {
debug_assert_ne!(current, 0);
return ForkGeneration(current);
}
}
}
}

const fn marker(&self) -> &AtomicUsize {
// SAFETY: `self.marker` remains mapped for the lifetime of `self`.
unsafe { self.marker.as_ref() }
}
}

impl Drop for ForkTracker {
fn drop(&mut self) {
// SAFETY: `self.marker` owns the mapping with this exact size.
let _ = unsafe { munmap(self.marker.cast(), MARKER_SIZE.get()) };
}
}

// SAFETY: The mapping contains one AtomicUsize and all access uses atomic
// operations. The mapping address remains stable for the tracker's lifetime.
unsafe impl Send for ForkTracker {}
// SAFETY: See the Send implementation; concurrent access is atomic.
unsafe impl Sync for ForkTracker {}
27 changes: 27 additions & 0 deletions crates/fspy_preload_unix/src/client/fork_tracker/macos.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#[derive(Clone, Copy, Eq, PartialEq)]
pub struct ForkGeneration(libc::pid_t);

pub struct ForkTracker;

impl ForkTracker {
#[expect(
clippy::unnecessary_wraps,
reason = "keeps construction identical to the fallible Linux fork tracker"
)]
#[cfg_attr(
test,
expect(dead_code, reason = "the preload constructor is disabled in unit-test builds")
)]
pub const fn new() -> nix::Result<Self> {
Ok(Self)
}

#[expect(
clippy::unused_self,
reason = "keeps generation lookup identical to the stateful Linux fork tracker"
)]
pub fn generation(&self) -> ForkGeneration {
// SAFETY: getpid has no preconditions and always succeeds.
ForkGeneration(unsafe { libc::getpid() })
}
}
9 changes: 9 additions & 0 deletions crates/fspy_preload_unix/src/client/fork_tracker/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#[cfg(target_os = "linux")]
mod linux;
#[cfg(target_os = "macos")]
mod macos;

#[cfg(target_os = "linux")]
pub(super) use linux::{ForkGeneration, ForkTracker};
#[cfg(target_os = "macos")]
pub(super) use macos::{ForkGeneration, ForkTracker};
32 changes: 12 additions & 20 deletions crates/fspy_preload_unix/src/client/mod.rs
Original file line number Diff line number Diff line change
@@ -1,30 +1,36 @@
pub mod convert;
pub mod raw_exec;

mod fork_tracker;
mod process_sender;

use std::{
cell::Cell, ffi::OsStr, fmt::Debug, num::NonZeroUsize, os::unix::ffi::OsStrExt as _,
path::Path, sync::OnceLock,
};

use convert::{ToAbsolutePath, ToAccessMode};
use fspy_shared::ipc::{PathAccess, channel::Sender};
use fspy_shared::ipc::PathAccess;
use fspy_shared_unix::{
exec::ExecResolveConfig,
payload::EncodedPayload,
spawn::{PreExec, handle_exec},
};
use process_sender::ProcessSender;
use raw_exec::RawExec;
use wincode::Serialize as _;

pub struct Client {
encoded_payload: EncodedPayload,
ipc_sender: Option<Sender>,
ipc_sender: ProcessSender,
}

// SAFETY: Client fields are only mutated during initialization in the ctor; after that, all access is read-only
// SAFETY: ProcessSender publishes immutable sender states through atomics, and
// the remaining Client state is read-only after initialization.
#[cfg(target_os = "macos")]
unsafe impl Sync for Client {}
// SAFETY: Client is only sent once during initialization; after that it lives in a static OnceLock
// SAFETY: Client is moved into the static OnceLock during initialization. Its
// ProcessSender and read-only payload may then be shared between threads.
#[cfg(target_os = "macos")]
unsafe impl Send for Client {}

Expand All @@ -35,32 +41,18 @@ impl Debug for Client {
}

impl Client {
#[expect(
clippy::print_stderr,
reason = "preload library intentionally uses stderr for error reporting"
)]
#[cfg(not(test))]
fn from_env() -> Self {
use fspy_shared_unix::payload::decode_payload_from_env;

let encoded_payload = decode_payload_from_env().unwrap();

let ipc_sender = match encoded_payload.payload.ipc_channel_conf.sender() {
Ok(sender) => Some(sender),
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.
eprintln!("fspy: failed to create ipc sender: {err}");
None
}
};
let ipc_sender = ProcessSender::new(&encoded_payload.payload.ipc_channel_conf).unwrap();

Self { encoded_payload, ipc_sender }
}

fn send(&self, mode: fspy_shared::ipc::AccessMode, path: &Path) -> anyhow::Result<()> {
let Some(ipc_sender) = &self.ipc_sender else {
let Some(ipc_sender) = self.ipc_sender.sender() else {
// ipc channel not available, skip sending
return Ok(());
};
Expand Down
Loading
Loading