Skip to content
Open
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

- **Changed** Automatic file-access tracking stops when a task's root process exits. Accesses from leftover descendants are no longer recorded, collecting the trace no longer waits for them to exit, and a run with a traced process still mid-write at that instant is not cached ([#544](https://github.com/voidzero-dev/vite-task/issues/544), [#396](https://github.com/voidzero-dev/vite-task/issues/396), [#577](https://github.com/voidzero-dev/vite-task/pull/577)).
- **Fixed** `vp run` no longer fails while setting up task communication in default Codex CLI and Claude Code sandboxes that block Unix domain sockets ([#562](https://github.com/voidzero-dev/vite-task/issues/562), [#569](https://github.com/voidzero-dev/vite-task/pull/569)).
- **Fixed** Automatic file-access tracking now works inside coding-agent sandboxes, including the default Codex CLI and Claude Code sandboxes ([#563](https://github.com/voidzero-dev/vite-task/issues/563), [#576](https://github.com/voidzero-dev/vite-task/pull/576)).
- **Added** Tasks now run with `VP_RUN=1` set, so tools can tell they are running under `vp run` instead of being invoked directly ([#570](https://github.com/voidzero-dev/vite-task/pull/570)).
Expand Down
3 changes: 0 additions & 3 deletions Cargo.lock

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

34 changes: 0 additions & 34 deletions crates/fspy/src/ipc.rs
Original file line number Diff line number Diff line change
@@ -1,38 +1,4 @@
use std::io;

use fspy_shared::ipc::{
PathAccess,
channel::{Receiver, ReceiverLockGuard},
};
use tokio::task::spawn_blocking;

// Shared memory size for storing path accesses.
// 4 GiB is large enough to store path accesses in almost any realistic scenario.
// This doesn't allocate physical memory until it's actually used.
pub const SHM_CAPACITY: usize = 4 * 1024 * 1024 * 1024;

#[ouroboros::self_referencing]
pub struct OwnedReceiverLockGuard {
/// Owns the shared memory
receiver: Receiver,
/// Borrows the shared memory and owns the file lock
#[borrows(receiver)]
#[covariant]
lock_guard: ReceiverLockGuard<'this>,
}

impl OwnedReceiverLockGuard {
pub fn lock(receiver: Receiver) -> io::Result<Self> {
Self::try_new(receiver, fspy_shared::ipc::channel::Receiver::lock)
}

pub async fn lock_async(receiver: Receiver) -> io::Result<Self> {
spawn_blocking(move || Self::lock(receiver)).await.expect("lock task panicked")
}

pub fn iter_path_accesses(&self) -> impl Iterator<Item = PathAccess<'_>> {
self.borrow_lock_guard()
.iter_frames()
.map(|frame| wincode::deserialize_exact(frame).unwrap())
}
}
44 changes: 34 additions & 10 deletions crates/fspy/src/unix/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ use std::{io, path::Path};
use fspy_seccomp_unotify::supervisor::supervise;
use fspy_shared::ipc::PathAccess;
#[cfg(not(target_env = "musl"))]
use fspy_shared::ipc::{NativeStr, channel::channel};
use fspy_shared::ipc::{
NativeStr,
channel::{ChannelFrames, channel},
};
#[cfg(target_os = "macos")]
use fspy_shared_unix::payload::Artifacts;
use fspy_shared_unix::{
Expand All @@ -25,7 +28,7 @@ use tokio::task::spawn_blocking;
use tokio_util::sync::CancellationToken;

#[cfg(not(target_env = "musl"))]
use crate::ipc::{OwnedReceiverLockGuard, SHM_CAPACITY};
use crate::ipc::SHM_CAPACITY;
use crate::{ChildTermination, Command, TrackedChild, arena::PathAccessArena, error::SpawnError};

#[derive(Debug)]
Expand All @@ -40,7 +43,7 @@ pub struct SpyImpl {
impl SpyImpl {
/// Initialize the fs access spy by writing the preload library on disk.
///
/// On musl targets, we don't build a preload library
/// On musl targets, we don't build a preload library;
/// only seccomp-based tracking is used.
pub fn init_in(#[cfg_attr(target_env = "musl", allow(unused))] dir: &Path) -> io::Result<Self> {
#[cfg(not(target_env = "musl"))]
Expand Down Expand Up @@ -158,15 +161,24 @@ impl SpyImpl {
);
let arenas = arenas.collect::<Vec<_>>();

// Lock the ipc channel after the child has exited.
// We are not interested in path accesses from descendants after the main child has exited.
// Close the ipc channel now that the child has exited. We are not
// interested in path accesses from descendants after the main child
// has exited, and we do not wait for them either: closing is a
// single atomic operation that also fences out later writers.
Comment thread
wan9chi marked this conversation as resolved.
// A close error means a traced process was still mid-write; its
// frames cannot be read safely, so the run is incomplete.
#[cfg(not(target_env = "musl"))]
let ipc_receiver_lock_guard =
OwnedReceiverLockGuard::lock_async(ipc_receiver).await?;
let (shm_frames, incomplete) = ipc_receiver
.close()
.map_or_else(|_| (None, true), |frames| (Some(frames), false));
let path_accesses = PathAccessIterable {
arenas,
#[cfg(not(target_env = "musl"))]
ipc_receiver_lock_guard,
shm_frames,
#[cfg(not(target_env = "musl"))]
incomplete,
#[cfg(target_env = "musl")]
incomplete: false,
};

io::Result::Ok(ChildTermination { status, path_accesses })
Expand All @@ -179,8 +191,12 @@ impl SpyImpl {

pub struct PathAccessIterable {
arenas: Vec<PathAccessArena>,
/// `None` when the channel could not be frozen for reading.
#[cfg(not(target_env = "musl"))]
ipc_receiver_lock_guard: OwnedReceiverLockGuard,
shm_frames: Option<ChannelFrames>,
/// A traced process was still writing when the channel closed, so the
/// recorded accesses are not a complete picture of the run.
incomplete: bool,
}

impl PathAccessIterable {
Expand All @@ -190,12 +206,20 @@ impl PathAccessIterable {

#[cfg(not(target_env = "musl"))]
{
let accesses_in_shm = self.ipc_receiver_lock_guard.iter_path_accesses();
let accesses_in_shm =
self.shm_frames.iter().flat_map(ChannelFrames::iter_path_accesses);
accesses_in_shm.chain(accesses_in_arena)
}
#[cfg(target_env = "musl")]
{
accesses_in_arena
}
}

/// Whether tracking was cut short, which makes the accesses above an
/// incomplete record of the run.
#[must_use]
pub const fn is_incomplete(&self) -> bool {
self.incomplete
}
}
38 changes: 27 additions & 11 deletions crates/fspy/src/windows/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ use std::{

use fspy_detours_sys::{DetourCopyPayloadToProcess, DetourUpdateProcessWithDll};
use fspy_shared::{
ipc::{PathAccess, channel::channel},
ipc::{
PathAccess,
channel::{ChannelFrames, channel},
},
windows::{PAYLOAD_ID, Payload},
};
use futures_util::FutureExt;
Expand All @@ -21,21 +24,29 @@ use winapi::{
use winsafe::co::{CP, WC};

use crate::{
ChildTermination, TrackedChild,
command::Command,
error::SpawnError,
ipc::{OwnedReceiverLockGuard, SHM_CAPACITY},
ChildTermination, TrackedChild, command::Command, error::SpawnError, ipc::SHM_CAPACITY,
};

const INTERPOSE_CDYLIB: Artifact = artifact!("fspy_preload");

pub struct PathAccessIterable {
ipc_receiver_lock_guard: OwnedReceiverLockGuard,
/// `None` when the channel could not be frozen for reading.
shm_frames: Option<ChannelFrames>,
/// A traced process was still writing when the channel closed, so the
/// recorded accesses are not a complete picture of the run.
incomplete: bool,
}

impl PathAccessIterable {
pub fn iter(&self) -> impl Iterator<Item = PathAccess<'_>> {
self.ipc_receiver_lock_guard.iter_path_accesses()
self.shm_frames.iter().flat_map(ChannelFrames::iter_path_accesses)
}

/// Whether tracking was cut short, which makes the accesses above an
/// incomplete record of the run.
#[must_use]
pub const fn is_incomplete(&self) -> bool {
self.incomplete
}
}

Expand Down Expand Up @@ -159,10 +170,15 @@ impl SpyImpl {
child.wait().await?
}
};
// Lock the ipc channel after the child has exited.
// We are not interested in path accesses from descendants after the main child has exited.
let ipc_receiver_lock_guard = OwnedReceiverLockGuard::lock_async(receiver).await?;
let path_accesses = PathAccessIterable { ipc_receiver_lock_guard };
// Close the ipc channel now that the child has exited. We are not
// interested in path accesses from descendants after the main child
// has exited, and we do not wait for them either: closing is a
// single atomic operation that also fences out later writers.
// A close error means a traced process was still mid-write; its
// frames cannot be read safely, so the run is incomplete.
let (shm_frames, incomplete) =
receiver.close().map_or_else(|_| (None, true), |frames| (Some(frames), false));
let path_accesses = PathAccessIterable { shm_frames, incomplete };

io::Result::Ok(ChildTermination { status, path_accesses })
})
Expand Down
4 changes: 3 additions & 1 deletion crates/fspy/tests/test_utils/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,11 @@ pub fn assert_contains(
assert_eq!(
expected_mode,
actual_mode,
"Expected to find access to path {} with mode {:?}, but it was not found in: {:?}",
"Expected to find access to path {} with mode {:?}, but it was not found in \
(tracking incomplete: {}): {:?}",
expected_path.display(),
expected_mode,
accesses.is_incomplete(),
accesses.iter().collect::<Vec<_>>()
);
}
Expand Down
66 changes: 60 additions & 6 deletions crates/fspy_preload_unix/src/client/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,23 @@ pub mod convert;
pub mod raw_exec;

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

use convert::{ToAbsolutePath, ToAccessMode};
use fspy_shared::ipc::{PathAccess, channel::Sender};
use fspy_shared::ipc::{
PathAccess,
channel::{ClaimError, Sender},
};
use fspy_shared_unix::{
exec::ExecResolveConfig,
payload::EncodedPayload,
Expand Down Expand Up @@ -78,9 +89,16 @@ impl Client {
let frame_size = NonZeroUsize::new(serialized_size)
.expect("fspy: encoded PathAccess should never be empty");

let mut frame = ipc_sender
.claim_frame(frame_size)
.expect("fspy: failed to claim frame in shared memory");
let _in_flight = SendInFlight::begin();
let mut frame = match ipc_sender.claim_frame(frame_size) {
Ok(frame) => frame,
// The channel was closed because the traced root process exited.
// Accesses from whatever is left behind are dropped by design.
Err(ClaimError::Closed) => return Ok(()),
Err(ClaimError::Capacity) => {
panic!("fspy: failed to claim frame in shared memory")
}
};
let mut writer: &mut [u8] = &mut frame;
PathAccess::serialize_into(&mut writer, &path_access)?;
assert_eq!(writer.len(), 0);
Expand Down Expand Up @@ -125,6 +143,42 @@ impl Client {

static CLIENT: OnceLock<Client> = OnceLock::new();

/// Sends that are between claiming a frame and finishing it, on any thread.
static IN_FLIGHT_SENDS: AtomicUsize = AtomicUsize::new(0);

struct SendInFlight;

impl SendInFlight {
fn begin() -> Self {
IN_FLIGHT_SENDS.fetch_add(1, Ordering::Relaxed);
Self
}
}

impl Drop for SendInFlight {
fn drop(&mut self) {
IN_FLIGHT_SENDS.fetch_sub(1, Ordering::Release);
}
}

/// Waits until no send is mid-frame on any thread of this process.
///
/// The exit interception calls this so a voluntary exit never abandons a
/// claimed frame, which would make the whole run's tracking incomplete. A send
/// lasts microseconds and never blocks, so this returns almost at once. The
/// deadline covers a thread that died mid-send without running its drop
/// (asynchronous `pthread_cancel` and the like): exit is delayed by at most
/// the deadline, and the runner treats the leaked count as an incomplete run.
pub fn drain_in_flight_sends() {
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(100);
while IN_FLIGHT_SENDS.load(Ordering::Acquire) != 0 {
if std::time::Instant::now() > deadline {
return;
}
std::thread::yield_now();
}
}

// Resolving and reporting a file access can call another interposed function.
// Suppress same-thread re-entry to prevent recursive access handling while
// still recording accesses from other threads.
Expand Down
25 changes: 25 additions & 0 deletions crates/fspy_preload_unix/src/interceptions/exit.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
//! Drains in-flight sends before a voluntary exit.
//!
//! A process may call `exit` while another of its threads is between claiming
//! a frame and finishing it. Dying there abandons the frame's gate guard, and
//! the runner then treats the whole run's tracking as incomplete. The drain
//! lasts microseconds; signals and crashes still skip it, and the runner
//! handles those by not caching the run.

use libc::c_int;

use crate::{client::drain_in_flight_sends, macros::intercept};

intercept!(exit: unsafe extern "C" fn(status: c_int) -> !);
unsafe extern "C" fn exit(status: c_int) -> ! {
drain_in_flight_sends();
// SAFETY: forwarding to the real libc exit with the caller's status
unsafe { exit::original()(status) }
}

intercept!(_exit: unsafe extern "C" fn(status: c_int) -> !);
unsafe extern "C" fn _exit(status: c_int) -> ! {
drain_in_flight_sends();
// SAFETY: forwarding to the real libc _exit with the caller's status
unsafe { _exit::original()(status) }
}
1 change: 1 addition & 0 deletions crates/fspy_preload_unix/src/interceptions/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
mod access;
mod dirent;
mod exit;
mod open;
mod spawn;
mod stat;
Expand Down
13 changes: 11 additions & 2 deletions crates/fspy_preload_windows/src/windows/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ use std::{cell::SyncUnsafeCell, ffi::CStr, mem::MaybeUninit};

use fspy_detours_sys::DetourCopyPayloadToProcess;
use fspy_shared::{
ipc::{PathAccess, channel::Sender},
ipc::{
PathAccess,
channel::{ClaimError, Sender, WriteEncodedError},
},
windows::{PAYLOAD_ID, Payload},
};
use winapi::{shared::minwindef::BOOL, um::winnt::HANDLE};
Expand Down Expand Up @@ -40,7 +43,13 @@ impl<'a> Client<'a> {
let Some(sender) = &self.ipc_sender else {
return;
};
sender.write_encoded(&access).expect("failed to send path access");
match sender.write_encoded(&access) {
Ok(())
// The channel was closed because the traced root process exited.
// Accesses from whatever is left behind are dropped by design.
| Err(WriteEncodedError::Claim(ClaimError::Closed)) => {}
Err(err) => panic!("failed to send path access: {err:?}"),
}
}

pub unsafe fn prepare_child_process(&self, child_handle: HANDLE) -> BOOL {
Expand Down
Loading
Loading