diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c112e278..1cea55058 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Changelog +- **Fixed** `vp run` no longer fails Node `spawnSync` calls to statically linked Linux tools such as `tsgolint`; when fspy cannot fully trace that child process, the task now runs successfully and skips writing an unsafe cache entry ([#499](https://github.com/voidzero-dev/vite-task/issues/499)). - **Fixed** Windows builds no longer hang on CI when a `node_modules/.bin` `.cmd` shim is routed through PowerShell: the npm/pnpm/yarn `.ps1` wrappers read stdin and block forever on a non-TTY pipe, so the PowerShell rewrite is now skipped when stdin is not an interactive terminal, falling back to the `.cmd` (which never reads stdin) ([#491](https://github.com/voidzero-dev/vite-task/pull/491)). - **Added** First-party support for caching `vite build` with zero cache config, giving Vite projects correct cache hits out of the box ([vitejs/vite#22453](https://github.com/vitejs/vite/pull/22453)). - **Added** Support for specifying tasks from dependency packages in `dependsOn`, such as `dependsOn: [{ "task": "build", "from": "dependencies" }]` ([#479](https://github.com/voidzero-dev/vite-task/pull/479)). diff --git a/crates/fspy/tests/static_executable.rs b/crates/fspy/tests/static_executable.rs index ae3c27169..c8312cfec 100644 --- a/crates/fspy/tests/static_executable.rs +++ b/crates/fspy/tests/static_executable.rs @@ -115,6 +115,57 @@ async fn stat() { assert_contains(&accesses, Path::new("/hello"), fspy::AccessMode::READ); } +#[test(tokio::test)] +async fn posix_spawn() { + let test_bin = test_bin_path().to_str().unwrap().to_owned(); + let accesses = track_fn!(test_bin, |test_bin: String| { + use std::ffi::CString; + + unsafe extern "C" { + static environ: *mut *mut libc::c_char; + } + + let program = CString::new(test_bin).unwrap(); + let action = CString::new("stat").unwrap(); + let path = CString::new("/hello").unwrap(); + let argv = [ + program.as_ptr().cast_mut(), + action.as_ptr().cast_mut(), + path.as_ptr().cast_mut(), + std::ptr::null_mut(), + ]; + + let mut pid = 0; + // SAFETY: all pointers refer to live C strings or null terminators for + // the duration of the posix_spawn call; envp forwards this process env. + let errno = unsafe { + libc::posix_spawn( + &raw mut pid, + program.as_ptr(), + std::ptr::null(), + std::ptr::null(), + argv.as_ptr(), + environ, + ) + }; + assert_eq!(errno, 0, "posix_spawn failed: {}", std::io::Error::from_raw_os_error(errno)); + + let mut status = 0; + // SAFETY: pid was returned by a successful posix_spawn call. + let wait_result = unsafe { libc::waitpid(pid, &raw mut status, 0) }; + assert_eq!(wait_result, pid, "waitpid failed: {}", std::io::Error::last_os_error()); + assert!(libc::WIFEXITED(status), "child exited abnormally with status {status}"); + assert_eq!(libc::WEXITSTATUS(status), 0, "child exited with status {status}"); + }) + .await + .unwrap(); + + assert!( + accesses.iter().any(|access| access.mode.contains(fspy::AccessMode::UNTRACKED_EXEC)), + "expected fspy to report incomplete tracking for the static posix_spawn child" + ); +} + #[test(tokio::test)] async fn execve() { let accesses = track_test_bin(&["execve", "/hello"], None).await; diff --git a/crates/fspy_preload_unix/src/client/mod.rs b/crates/fspy_preload_unix/src/client/mod.rs index aad7b46be..8e7d8e47d 100644 --- a/crates/fspy_preload_unix/src/client/mod.rs +++ b/crates/fspy_preload_unix/src/client/mod.rs @@ -88,6 +88,10 @@ impl Client { Ok(()) } + pub fn record_untracked_exec(&self, path: &Path) { + let _ = self.send(fspy_shared::ipc::AccessMode::UNTRACKED_EXEC, path); + } + pub unsafe fn handle_exec( &self, config: ExecResolveConfig, diff --git a/crates/fspy_preload_unix/src/interceptions/spawn/posix_spawn.rs b/crates/fspy_preload_unix/src/interceptions/spawn/posix_spawn.rs index 9496b1153..e9753b6ae 100644 --- a/crates/fspy_preload_unix/src/interceptions/spawn/posix_spawn.rs +++ b/crates/fspy_preload_unix/src/interceptions/spawn/posix_spawn.rs @@ -1,4 +1,4 @@ -use std::thread; +use std::{ffi::CStr, os::unix::ffi::OsStrExt, path::Path}; use fspy_shared_unix::exec::ExecResolveConfig; use libc::{c_char, c_int}; @@ -31,14 +31,6 @@ unsafe fn handle_posix_spawn( argv: *const *mut c_char, envp: *const *mut c_char, ) -> c_int { - struct AssertSend(T); - #[expect( - clippy::non_send_fields_in_send_ty, - reason = "the closure captures raw pointers that are valid for the duration of the thread::scope call, so sending them to the scoped thread is safe" - )] - // SAFETY: the raw pointers captured inside T are valid for the duration of the thread::scope call, so sending them to the scoped thread is safe - unsafe impl Send for AssertSend {} - let client = global_client() .expect("posix_spawn(p) unexpectedly called before client initialized in ctor"); @@ -58,21 +50,21 @@ unsafe fn handle_posix_spawn( raw_command.envp.cast(), ) }; - if let Some(pre_exec) = pre_exec { - thread::scope(move |s| { - let call_original = AssertSend(call_original); - s.spawn(move || { - let call_original = call_original; - pre_exec.run()?; - - nix::Result::Ok((call_original.0)()) - }) - .join() - .unwrap() - }) - } else { - Ok(call_original()) + if pre_exec.is_some() { + // Static Linux executables cannot be instrumented through + // LD_PRELOAD. Installing a seccomp-unotify listener here + // can make libc's posix_spawn fail before the child exists + // (reported by Node as spawnSync EINVAL). Let the spawn + // proceed, but tell the parent the trace is incomplete so + // task caching is skipped. + // SAFETY: raw_command.prog is a valid C string produced + // from the resolved Exec just above. + let program = CStr::from_ptr(raw_command.prog); + client.record_untracked_exec(Path::new(std::ffi::OsStr::from_bytes( + program.to_bytes(), + ))); } + Ok(call_original()) }, ) }; diff --git a/crates/fspy_shared/src/ipc/mod.rs b/crates/fspy_shared/src/ipc/mod.rs index c7236e5d6..0adf48220 100644 --- a/crates/fspy_shared/src/ipc/mod.rs +++ b/crates/fspy_shared/src/ipc/mod.rs @@ -16,6 +16,7 @@ bitflags! { const READ = 1; const WRITE = 1 << 1; const READ_DIR = 1 << 2; + const UNTRACKED_EXEC = 1 << 3; } } diff --git a/crates/vite_task/src/session/event.rs b/crates/vite_task/src/session/event.rs index a04af70ab..0a1737f84 100644 --- a/crates/vite_task/src/session/event.rs +++ b/crates/vite_task/src/session/event.rs @@ -79,9 +79,8 @@ pub enum CacheNotUpdatedReason { /// First path that was both read and written during execution. path: RelativePathBuf, }, - /// fspy isn't compiled in on this build and the task requires fspy - /// (its `input` config includes auto-inference). Task ran but cannot - /// be cached without tracked path accesses. + /// fspy could not provide complete file tracking for this task. Task ran + /// but cannot be cached without complete path accesses. FspyUnsupported, /// The runner's IPC server failed during execution, so the collected /// reports may be incomplete. Caching such a run would risk stale diff --git a/crates/vite_task/src/session/execute/cache_update.rs b/crates/vite_task/src/session/execute/cache_update.rs index 0cf4df1c5..8d70aa586 100644 --- a/crates/vite_task/src/session/execute/cache_update.rs +++ b/crates/vite_task/src/session/execute/cache_update.rs @@ -27,6 +27,9 @@ use crate::{ /// cfg-agnostic so the decision logic below doesn't need `cfg(fspy)` — the /// value is only ever `Some` when tracking happened (see [`observe_fspy`]). struct TrackingOutcome { + /// fspy reported that a descendant process could not be tracked fully. + incomplete: bool, + path_reads: HashMap, /// Auto-output writes after output exclusions are applied. Empty when /// `output_config.includes_auto` is false. @@ -98,6 +101,12 @@ pub(super) async fn update_cache( workspace_root, ); + if let Some(TrackingOutcome { incomplete: true, .. }) = &fspy_outcome { + // Task ran successfully, but fspy could not fully observe a descendant + // process. A cache entry would risk stale hits, so skip updating. + return (CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::FspyUnsupported), None); + } + if let Some(TrackingOutcome { read_write_overlap: Some(path), .. }) = &fspy_outcome { // fspy-inferred read-write overlap: the task wrote to a file it also // read, so the prerun input hashes are stale and caching is unsound. @@ -246,6 +255,7 @@ fn observe_fspy( let read_write_overlap = filtered_path_reads.keys().find(|p| filtered_path_writes.contains(*p)).cloned(); TrackingOutcome { + incomplete: tracked.incomplete, path_reads: filtered_path_reads, path_writes: filtered_path_writes, read_write_overlap, diff --git a/crates/vite_task/src/session/execute/tracked_accesses.rs b/crates/vite_task/src/session/execute/tracked_accesses.rs index 6e3596512..7f1298d48 100644 --- a/crates/vite_task/src/session/execute/tracked_accesses.rs +++ b/crates/vite_task/src/session/execute/tracked_accesses.rs @@ -17,6 +17,9 @@ use crate::collections::HashMap; /// Tracked file accesses from fspy, normalized to workspace-relative paths. #[derive(Default, Debug)] pub struct TrackedPathAccesses { + /// fspy observed a descendant exec that could not be tracked completely. + pub incomplete: bool, + /// Tracked path reads pub path_reads: HashMap, @@ -31,6 +34,11 @@ impl TrackedPathAccesses { pub fn from_raw(raw: &PathAccessIterable, workspace_root: &AbsolutePath) -> Self { let mut accesses = Self::default(); for access in raw.iter() { + if access.mode.contains(AccessMode::UNTRACKED_EXEC) { + accesses.incomplete = true; + continue; + } + // Strip workspace root and clean `..` components in one pass. // fspy may report paths like `packages/sub-pkg/../shared/dist/output.js`. let relative_path = access.path.strip_path_prefix(workspace_root, |strip_result| { diff --git a/crates/vite_task/src/session/reporter/summary.rs b/crates/vite_task/src/session/reporter/summary.rs index 83bd2a937..f3a04bc3b 100644 --- a/crates/vite_task/src/session/reporter/summary.rs +++ b/crates/vite_task/src/session/reporter/summary.rs @@ -542,13 +542,13 @@ impl TaskResult { { return vite_str::format!("→ Not cached: read and wrote '{path}'"); } - // fspy-unsupported-on-this-OS message — same overrides precedence as above + // fspy-incomplete message — same overrides precedence as above if let Self::Spawned { outcome: SpawnOutcome::Success { fspy_unsupported: true, .. }, .. } = self { return Str::from( - "→ Not cached: `input` auto-inference isn't supported on this OS. Configure `input` manually to enable caching.", + "→ Not cached: `input` auto-inference could not fully track this task. Configure `input` manually to enable caching.", ); }