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 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)).
Expand Down
51 changes: 51 additions & 0 deletions crates/fspy/tests/static_executable.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions crates/fspy_preload_unix/src/client/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<R>(
&self,
config: ExecResolveConfig,
Expand Down
38 changes: 15 additions & 23 deletions crates/fspy_preload_unix/src/interceptions/spawn/posix_spawn.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -31,14 +31,6 @@ unsafe fn handle_posix_spawn(
argv: *const *mut c_char,
envp: *const *mut c_char,
) -> c_int {
struct AssertSend<T>(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<T> Send for AssertSend<T> {}

let client = global_client()
.expect("posix_spawn(p) unexpectedly called before client initialized in ctor");

Expand All @@ -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())
},
)
};
Expand Down
1 change: 1 addition & 0 deletions crates/fspy_shared/src/ipc/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ bitflags! {
const READ = 1;
const WRITE = 1 << 1;
const READ_DIR = 1 << 2;
const UNTRACKED_EXEC = 1 << 3;
}
}

Expand Down
5 changes: 2 additions & 3 deletions crates/vite_task/src/session/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions crates/vite_task/src/session/execute/cache_update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<RelativePathBuf, PathRead>,
/// Auto-output writes after output exclusions are applied. Empty when
/// `output_config.includes_auto` is false.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions crates/vite_task/src/session/execute/tracked_accesses.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<RelativePathBuf, PathRead>,

Expand All @@ -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| {
Expand Down
4 changes: 2 additions & 2 deletions crates/vite_task/src/session/reporter/summary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
);
}

Expand Down
Loading