From 1109308f43372fb5500ff27679e910af1b4a2a60 Mon Sep 17 00:00:00 2001 From: branchseer Date: Fri, 7 Nov 2025 00:02:49 +0800 Subject: [PATCH 1/5] refactor: make `TrackedChild` easier to use --- crates/fspy/src/lib.rs | 30 ++++++++++++++++++++----- crates/fspy/src/unix/mod.rs | 45 +++++++++++++++++++++---------------- 2 files changed, 50 insertions(+), 25 deletions(-) diff --git a/crates/fspy/src/lib.rs b/crates/fspy/src/lib.rs index 96aee7dd..39b8fc76 100644 --- a/crates/fspy/src/lib.rs +++ b/crates/fspy/src/lib.rs @@ -20,20 +20,38 @@ mod os_impl; mod arena; mod command; -use std::{env::temp_dir, ffi::OsStr, fs::create_dir, io, sync::OnceLock}; +use std::{env::temp_dir, ffi::OsStr, fs::create_dir, io, process::ExitStatus, sync::OnceLock}; pub use command::Command; pub use fspy_shared::ipc::{AccessMode, PathAccess}; use futures_util::future::BoxFuture; pub use os_impl::PathAccessIterable; use os_impl::SpyInner; -use tokio::process::Child; +use tokio::process::{ChildStderr, ChildStdin, ChildStdout}; + +/// The result of a tracked child process upon its termination. +pub struct ChildTermination { + /// The exit status of the child process. + pub status: ExitStatus, + /// The path accesses captured from the child process. + pub path_accesses: PathAccessIterable, +} pub struct TrackedChild { - pub tokio_child: Child, - /// This future lazily locks the IPC channel when it's polled. - /// Do not `await` it until the child process has exited. - pub accesses_future: BoxFuture<'static, io::Result>, + /// The handle for writing to the child's standard input (stdin), if it has + /// been captured. + pub stdin: Option, + + /// The handle for reading from the child's standard output (stdout), if it + /// has been captured. + pub stdout: Option, + + /// The handle for reading from the child's standard error (stderr), if it + /// has been captured. + pub stderr: Option, + + /// The future that resolves to exit status and path accesses when the process exits. + pub wait_handle: BoxFuture<'static, io::Result>, } pub struct Spy(SpyInner); diff --git a/crates/fspy/src/unix/mod.rs b/crates/fspy/src/unix/mod.rs index d4c9a223..55b1db19 100644 --- a/crates/fspy/src/unix/mod.rs +++ b/crates/fspy/src/unix/mod.rs @@ -22,7 +22,7 @@ use syscall_handler::SyscallHandler; use tokio::task::spawn_blocking; use crate::{ - Command, TrackedChild, + ChildTermination, Command, TrackedChild, arena::PathAccessArena, error::SpawnError, ipc::{OwnedReceiverLockGuard, SHM_CAPACITY}, @@ -131,26 +131,33 @@ pub(crate) async fn spawn_impl(mut command: Command) -> Result>()) - }; - - let accesses_future = async move { - let arenas = arenas_future.await?; - // `receiver.lock()` blocks. Run it inside `spawn_blocking` to avoid blocking the tokio runtime. - let ipc_receiver_lock_guard = OwnedReceiverLockGuard::lock_async(ipc_receiver).await?; - Ok(PathAccessIterable { arenas, ipc_receiver_lock_guard }) - } - .boxed(); - - Ok(TrackedChild { tokio_child: child, accesses_future }) + Ok(TrackedChild { + stdin: child.stdin.take(), + stdout: child.stdout.take(), + stderr: child.stderr.take(), + wait_handle: tokio::spawn(async move { + let status = child.wait().await?; + + let arenas = std::iter::once(exec_resolve_accesses); + // Stop the supervisor and collect path accesses from it. + #[cfg(target_os = "linux")] + let arenas = arenas + .chain(supervisor.stop().await?.into_iter().map(|handler| handler.into_arena())); + let arenas = arenas.collect::>(); + + // Lock the ipc channel after the child has exited. + // We are not interested in path accesses from decendants after the main child has exited. + let ipc_receiver_lock_guard = OwnedReceiverLockGuard::lock_async(ipc_receiver).await?; + let path_accesses = PathAccessIterable { arenas, ipc_receiver_lock_guard }; + + io::Result::Ok(ChildTermination { status, path_accesses }) + }) + .map(|f| io::Result::Ok(f??)) // flatten JoinError and io::Result + .boxed(), + }) } From 09144b140e3695e6bceabc8ad9bda8e660fe6491 Mon Sep 17 00:00:00 2001 From: branchseer Date: Fri, 7 Nov 2025 10:15:12 +0800 Subject: [PATCH 2/5] update usages --- crates/fspy/examples/cli.rs | 13 ++-- crates/fspy/src/unix/mod.rs | 2 + crates/fspy/tests/node_fs.rs | 11 ++-- crates/fspy/tests/test_utils.rs | 11 ++-- crates/fspy_e2e/src/main.rs | 22 ++++--- crates/vite_task/src/execute.rs | 112 ++++++++++++++++---------------- 6 files changed, 86 insertions(+), 85 deletions(-) diff --git a/crates/fspy/examples/cli.rs b/crates/fspy/examples/cli.rs index ebd5a085..0aade07b 100644 --- a/crates/fspy/examples/cli.rs +++ b/crates/fspy/examples/cli.rs @@ -1,6 +1,6 @@ use std::{env::args_os, ffi::OsStr, path::PathBuf, pin::Pin}; -use fspy::{AccessMode, TrackedChild}; +use fspy::AccessMode; use tokio::{ fs::File, io::{AsyncWrite, stdout}, @@ -21,11 +21,8 @@ async fn main() -> anyhow::Result<()> { let mut command = spy.new_command(program); command.envs(std::env::vars_os()).args(args); - let TrackedChild { mut tokio_child, accesses_future } = command.spawn().await?; - - let output = tokio_child.wait().await?; - - let accesses = accesses_future.await?; + let child = command.spawn().await?; + let termination = child.wait_handle.await?; let mut path_count = 0usize; let out_file: Pin> = @@ -33,7 +30,7 @@ async fn main() -> anyhow::Result<()> { let mut csv_writer = csv_async::AsyncWriter::from_writer(out_file); - for acc in accesses.iter() { + for acc in termination.path_accesses.iter() { path_count += 1; csv_writer .write_record(&[ @@ -49,6 +46,6 @@ async fn main() -> anyhow::Result<()> { } csv_writer.flush().await?; - eprintln!("\nfspy: {path_count} paths accessed. {output}"); + eprintln!("\nfspy: {path_count} paths accessed. status: {}", termination.status); Ok(()) } diff --git a/crates/fspy/src/unix/mod.rs b/crates/fspy/src/unix/mod.rs index 55b1db19..7566a12d 100644 --- a/crates/fspy/src/unix/mod.rs +++ b/crates/fspy/src/unix/mod.rs @@ -140,6 +140,8 @@ pub(crate) async fn spawn_impl(mut command: Command) -> Result anyhow::Result { @@ -11,11 +11,10 @@ async fn track_node_script(script: &str) -> anyhow::Result { .arg("-e") .envs(vars_os()) // https://github.com/jdx/mise/discussions/5968 .arg(script); - let TrackedChild { mut tokio_child, accesses_future } = command.spawn().await?; - let status = tokio_child.wait().await?; - let accesses = accesses_future.await?; - assert!(status.success()); - Ok(accesses) + let child = command.spawn().await?; + let termination = child.wait_handle.await?; + assert!(termination.status.success()); + Ok(termination.path_accesses) } #[tokio::test] diff --git a/crates/fspy/tests/test_utils.rs b/crates/fspy/tests/test_utils.rs index f4448b71..e5ef61ee 100644 --- a/crates/fspy/tests/test_utils.rs +++ b/crates/fspy/tests/test_utils.rs @@ -1,6 +1,6 @@ use std::path::{Path, PathBuf, StripPrefixError}; -use fspy::{AccessMode, PathAccessIterable, TrackedChild}; +use fspy::{AccessMode, PathAccessIterable}; #[track_caller] pub fn assert_contains( @@ -55,10 +55,7 @@ macro_rules! track_child { pub async fn _spawn_with_id(id: &str) -> anyhow::Result { let mut command = fspy::Spy::global()?.new_command(::std::env::current_exe()?); command.arg(id); - let TrackedChild { mut tokio_child, accesses_future } = command.spawn().await?; - - let status = tokio_child.wait().await?; - let accesses = accesses_future.await?; - assert!(status.success()); - Ok(accesses) + let termination = command.spawn().await?.wait_handle.await?; + assert!(termination.status.success()); + Ok(termination.path_accesses) } diff --git a/crates/fspy_e2e/src/main.rs b/crates/fspy_e2e/src/main.rs index 0d702fc3..b220f9b5 100644 --- a/crates/fspy_e2e/src/main.rs +++ b/crates/fspy_e2e/src/main.rs @@ -9,6 +9,7 @@ use std::{ use fspy::{AccessMode, PathAccess}; use serde::{Deserialize, Serialize}; +use tokio::io::AsyncReadExt; #[derive(Serialize, Deserialize)] struct Config { @@ -86,25 +87,30 @@ async fn main() { .stderr(Stdio::piped()) .current_dir(&dir); - let tracked_child = cmd.spawn().await.unwrap(); + let mut tracked_child = cmd.spawn().await.unwrap(); - let output = tracked_child.tokio_child.wait_with_output().await.unwrap(); - let accesses = tracked_child.accesses_future.await.unwrap(); + let mut stdout_bytes = Vec::::new(); + tracked_child.stdout.take().unwrap().read_to_end(&mut stdout_bytes).await.unwrap(); - if !output.status.success() { + let mut stderr_bytes = Vec::::new(); + tracked_child.stderr.take().unwrap().read_to_end(&mut stderr_bytes).await.unwrap(); + + let termination = tracked_child.wait_handle.await.unwrap(); + + if !termination.status.success() { eprintln!("----- stdout begin -----"); - stderr().write_all(&output.stdout).unwrap(); + stderr().write_all(&stdout_bytes).unwrap(); eprintln!("----- stdout end -----"); eprintln!("----- stderr begin-----"); - stderr().write_all(&output.stderr).unwrap(); + stderr().write_all(&stderr_bytes).unwrap(); eprintln!("----- stderr end -----"); - eprintln!("Case `{}` failed with status: {}", name, output.status); + eprintln!("Case `{}` failed with status: {}", name, termination.status); process::exit(1); } let mut collector = AccessCollector::new(dir); - for access in accesses.iter() { + for access in termination.path_accesses.iter() { collector.add(access); } let snap_file = File::create(manifest_dir.join(format!("snaps/{name}.txt"))).unwrap(); diff --git a/crates/vite_task/src/execute.rs b/crates/vite_task/src/execute.rs index 2b0cfb95..895ee805 100644 --- a/crates/vite_task/src/execute.rs +++ b/crates/vite_task/src/execute.rs @@ -9,7 +9,7 @@ use std::{ }; use bincode::{Decode, Encode}; -use fspy::{AccessMode, Spy, TrackedChild}; +use fspy::{AccessMode, Spy}; use futures_util::future::try_join3; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -402,74 +402,68 @@ pub async fn execute_task( cmd.current_dir(base_dir.join(&resolved_command.fingerprint.cwd)) .stdout(Stdio::piped()) .stderr(Stdio::piped()); - let TrackedChild { tokio_child: mut child, accesses_future } = cmd.spawn().await?; + let mut child = cmd.spawn().await?; let child_stdout = child.stdout.take().unwrap(); let child_stderr = child.stderr.take().unwrap(); let outputs = Mutex::new(Vec::::new()); - let path_accesses_fut = async move { - let path_accesses = accesses_future.await?; - let mut path_reads = HashMap::::new(); - let mut path_writes = HashMap::::new(); - for access in path_accesses.iter() { - let relative_path = access - .path - .strip_path_prefix(base_dir, |strip_result| { - let Ok(stripped_path) = strip_result else { - return None; - }; - Some(RelativePathBuf::new(stripped_path).map_err(|err| { - Error::InvalidRelativePath { path: stripped_path.into(), reason: err } - })) - }) - .transpose()?; - - let Some(relative_path) = relative_path else { - // ignore accesses outside the workspace - continue; - }; - if relative_path.as_path().strip_prefix(".git").is_ok() { - // temp workaround for oxlint reading inside .git - continue; - } - match access.mode { - AccessMode::Read => { - path_reads.entry(relative_path).or_insert(PathRead { read_dir_entries: false }); - } - AccessMode::Write => { - path_writes.insert(relative_path, PathWrite); - } - AccessMode::ReadWrite => { - path_reads - .entry(relative_path.clone()) - .or_insert(PathRead { read_dir_entries: false }); - path_writes.insert(relative_path, PathWrite); - } - AccessMode::ReadDir => match path_reads.entry(relative_path) { - Entry::Occupied(mut occupied) => occupied.get_mut().read_dir_entries = true, - Entry::Vacant(vacant) => { - vacant.insert(PathRead { read_dir_entries: true }); - } - }, - } - } - Ok::<_, Error>((path_reads, path_writes)) - }; - - let ((), (), (exit_status, duration)) = try_join3( + let ((), (), (termination, duration)) = try_join3( collect_std_outputs(&outputs, child_stdout, OutputKind::StdOut), collect_std_outputs(&outputs, child_stderr, OutputKind::StdErr), async move { let start = Instant::now(); - let exit_status = child.wait().await?; + let exit_status = child.wait_handle.await?; Ok((exit_status, start.elapsed())) }, ) .await?; - let (path_reads, path_writes) = path_accesses_fut.await?; + let mut path_reads = HashMap::::new(); + let mut path_writes = HashMap::::new(); + for access in termination.path_accesses.iter() { + let relative_path = access + .path + .strip_path_prefix(base_dir, |strip_result| { + let Ok(stripped_path) = strip_result else { + return None; + }; + Some(RelativePathBuf::new(stripped_path).map_err(|err| { + Error::InvalidRelativePath { path: stripped_path.into(), reason: err } + })) + }) + .transpose()?; + + let Some(relative_path) = relative_path else { + // ignore accesses outside the workspace + continue; + }; + if relative_path.as_path().strip_prefix(".git").is_ok() { + // temp workaround for oxlint reading inside .git + continue; + } + match access.mode { + AccessMode::Read => { + path_reads.entry(relative_path).or_insert(PathRead { read_dir_entries: false }); + } + AccessMode::Write => { + path_writes.insert(relative_path, PathWrite); + } + AccessMode::ReadWrite => { + path_reads + .entry(relative_path.clone()) + .or_insert(PathRead { read_dir_entries: false }); + path_writes.insert(relative_path, PathWrite); + } + AccessMode::ReadDir => match path_reads.entry(relative_path) { + Entry::Occupied(mut occupied) => occupied.get_mut().read_dir_entries = true, + Entry::Vacant(vacant) => { + vacant.insert(PathRead { read_dir_entries: true }); + } + }, + } + } let outputs = outputs.into_inner().unwrap(); tracing::debug!( @@ -477,12 +471,18 @@ pub async fn execute_task( path_reads.len(), path_writes.len(), outputs.len(), - exit_status + termination.status, ); // let input_paths = gather_inputs(task, base_dir)?; - Ok(ExecutedTask { std_outputs: outputs.into(), exit_status, path_reads, path_writes, duration }) + Ok(ExecutedTask { + std_outputs: outputs.into(), + exit_status: termination.status, + path_reads, + path_writes, + duration, + }) } #[expect(dead_code)] From 4c741c01e71f53502c266b9ad7a64374ef8282f8 Mon Sep 17 00:00:00 2001 From: branchseer Date: Fri, 7 Nov 2025 10:19:00 +0800 Subject: [PATCH 3/5] update TrackedChild on Windows --- crates/fspy/src/windows/mod.rs | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/crates/fspy/src/windows/mod.rs b/crates/fspy/src/windows/mod.rs index a39fdaa3..78963eca 100644 --- a/crates/fspy/src/windows/mod.rs +++ b/crates/fspy/src/windows/mod.rs @@ -21,7 +21,7 @@ use winsafe::co::{CP, WC}; use xxhash_rust::const_xxh3::xxh3_128; use crate::{ - TrackedChild, + ChildTermination, TrackedChild, command::Command, error::SpawnError, fixture::Fixture, @@ -81,17 +81,9 @@ pub(crate) async fn spawn_impl(command: Command) -> Result Result Date: Fri, 7 Nov 2025 10:24:30 +0800 Subject: [PATCH 4/5] fix typo --- crates/fspy/src/unix/mod.rs | 2 +- crates/fspy/src/windows/mod.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/fspy/src/unix/mod.rs b/crates/fspy/src/unix/mod.rs index 7566a12d..e55e6a50 100644 --- a/crates/fspy/src/unix/mod.rs +++ b/crates/fspy/src/unix/mod.rs @@ -153,7 +153,7 @@ pub(crate) async fn spawn_impl(mut command: Command) -> Result>(); // Lock the ipc channel after the child has exited. - // We are not interested in path accesses from decendants after the main 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(ipc_receiver).await?; let path_accesses = PathAccessIterable { arenas, ipc_receiver_lock_guard }; diff --git a/crates/fspy/src/windows/mod.rs b/crates/fspy/src/windows/mod.rs index 78963eca..47ff43c8 100644 --- a/crates/fspy/src/windows/mod.rs +++ b/crates/fspy/src/windows/mod.rs @@ -139,7 +139,7 @@ pub(crate) async fn spawn_impl(command: Command) -> Result Date: Fri, 7 Nov 2025 10:44:42 +0800 Subject: [PATCH 5/5] update usages in static_executable --- crates/fspy/tests/static_executable.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/fspy/tests/static_executable.rs b/crates/fspy/tests/static_executable.rs index 4d3709c4..705a4173 100644 --- a/crates/fspy/tests/static_executable.rs +++ b/crates/fspy/tests/static_executable.rs @@ -41,12 +41,12 @@ async fn track_test_bin(args: &[&str], cwd: Option<&str>) -> PathAccessIterable cmd.current_dir(cwd); }; cmd.args(args); - let mut tracked_child = cmd.spawn().await.unwrap(); + let tracked_child = cmd.spawn().await.unwrap(); - let output = tracked_child.tokio_child.wait().await.unwrap(); - assert!(output.success()); + let termination = tracked_child.wait_handle.await.unwrap(); + assert!(termination.status.success()); - tracked_child.accesses_future.await.unwrap() + termination.path_accesses } #[tokio::test]