Skip to content

Commit d1b8cda

Browse files
authored
feat(vite_task): gate fspy behind cfg for non-supported OSes (#352)
## Motivation `vite_task` was hard-wired to the `fspy` (file-access tracing) crate, which only builds on Windows, macOS, and Linux. That made the whole task-runner crate uncompilable on other targets (Android, FreeBSD, etc.) — a blocker for anyone embedding `vite_task` elsewhere, and for cross-compilation sanity checks. This PR lets `vite_task` compile on any target: fspy is present on the three supported OSes as before, and absent on others. On the latter, tasks still execute; only `input` auto-inference is unavailable, and the summary tells the user how to re-enable caching explicitly. ## Summary - New `cfg(fspy)` set by `vite_task`'s `build.rs` when `target_os` is windows/macos/linux, with a matching `[target.'cfg(any(...))'.dependencies]` block for the `fspy` dep (target cfgs and build-script cfgs must stay in sync — documented in `build.rs`). - On `cfg(not(fspy))`: `spawn(cmd, fspy: true, ...)` silently takes the tokio path; `ChildOutcome.path_accesses` and the whole `tracked_accesses` module are cfg-gated out. - `spawn()` now always builds `tokio::process::Command` directly; `fspy::Command` is only constructed inside `spawn_fspy`. `fspy::Command::into_tokio_command` became `pub(crate)`. - New `CacheNotUpdatedReason::FspyUnsupported` surfaced in the summary as: `→ Not cached: \`input\` auto-inference isn't supported on this OS. Configure \`input\` manually to enable caching.` Cache lookups and post-run fingerprint validation of existing entries still work on unsupported OSes — only cache *creation* requiring auto-inferred inputs is refused. - `PathRead` moves from the fspy-gated `tracked_accesses.rs` to `fingerprint.rs` (usable on both builds). ## Test plan - [x] On-path: `cargo test -p vite_task`, `cargo test -p vite_task_bin --test e2e_snapshots`, `cargo test -p vite_task_plan --test plan_snapshots` - [x] On-path cross-OS: `just lint-linux`, `just lint-windows` - [x] Off-path: `mise check-android` (cross-compiles `vite_task` to `aarch64-linux-android`, clippy-checks with `--all-targets --all-features -- -D warnings`) - [x] Off-path dep graph: `cargo ndk -t arm64-v8a tree -p vite_task | grep fspy` returns nothing - [x] `just lint`, `cargo fmt --check` 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent 6daa700 commit d1b8cda

12 files changed

Lines changed: 199 additions & 84 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# Changelog
22

3+
- **Added** Platform support for targets without `input` auto-inference (e.g. Android). Tasks still run; those relying on auto-inference run uncached, with the summary noting that `input` must be configured manually to enable caching ([#352](https://github.com/voidzero-dev/vite-task/pull/352))
34
- **Fixed** `vp run` no longer aborts with `failed to prepare the command for injection: Invalid argument` when the user environment already has `LD_PRELOAD` (Linux) or `DYLD_INSERT_LIBRARIES` (macOS) set. The tracer shim is now appended to any existing value and placed last, so user preloads keep their symbol-interposition precedence ([#340](https://github.com/voidzero-dev/vite-task/issues/340))
45
- **Changed** Arguments passed after a task name (e.g. `vp run test some-filter`) are now forwarded only to that task. Tasks pulled in via `dependsOn` no longer receive them ([#324](https://github.com/voidzero-dev/vite-task/issues/324))
56
- **Fixed** Windows file access tracking no longer panics when a task touches malformed paths that cannot be represented as workspace-relative inputs ([#330](https://github.com/voidzero-dev/vite-task/pull/330))

Cargo.lock

Lines changed: 12 additions & 24 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,12 +83,12 @@ fspy_shared_unix = { path = "crates/fspy_shared_unix" }
8383
futures = "0.3.31"
8484
futures-util = "0.3.31"
8585
jsonc-parser = { version = "0.29.0", features = ["serde"] }
86-
libc = "0.2.172"
86+
libc = "0.2.185"
8787
libtest-mimic = "0.8.2"
8888
memmap2 = "0.9.7"
8989
monostate = "1.0.2"
9090
native_str = { path = "crates/native_str" }
91-
nix = { version = "0.30.1", features = ["dir", "signal"] }
91+
nix = { version = "0.31.2", features = ["dir", "signal"] }
9292
ntapi = "0.4.1"
9393
nucleo-matcher = "0.3.1"
9494
once_cell = "1.19"

crates/fspy/src/command.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ impl Command {
228228

229229
/// Convert to a `tokio::process::Command` without tracking.
230230
#[must_use]
231-
pub fn into_tokio_command(self) -> TokioCommand {
231+
pub(crate) fn into_tokio_command(self) -> TokioCommand {
232232
let mut tokio_cmd = TokioCommand::new(self.program);
233233
if let Some(cwd) = &self.cwd {
234234
tokio_cmd.current_dir(cwd);

crates/vite_task/Cargo.toml

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
name = "vite_task"
33
version = "0.0.0"
44
edition.workspace = true
5-
include = ["/src"]
5+
include = ["/src", "/build.rs"]
66
license.workspace = true
77
publish = false
88
readme = "README.md"
@@ -17,8 +17,7 @@ async-trait = { workspace = true }
1717
wincode = { workspace = true, features = ["derive"] }
1818
clap = { workspace = true, features = ["derive"] }
1919
ctrlc = { workspace = true }
20-
derive_more = { workspace = true, features = ["from"] }
21-
fspy = { workspace = true }
20+
derive_more = { workspace = true, features = ["debug", "from"] }
2221
futures-util = { workspace = true }
2322
once_cell = { workspace = true }
2423
owo-colors = { workspace = true }
@@ -30,7 +29,14 @@ rustc-hash = { workspace = true }
3029
serde = { workspace = true, features = ["derive", "rc"] }
3130
serde_json = { workspace = true }
3231
thiserror = { workspace = true }
33-
tokio = { workspace = true, features = ["rt-multi-thread", "io-std", "io-util", "macros", "sync"] }
32+
tokio = { workspace = true, features = [
33+
"rt-multi-thread",
34+
"io-std",
35+
"io-util",
36+
"macros",
37+
"process",
38+
"sync",
39+
] }
3440
tokio-util = { workspace = true }
3541
tracing = { workspace = true }
3642
twox-hash = { workspace = true }
@@ -45,6 +51,9 @@ wax = { workspace = true }
4551
[dev-dependencies]
4652
tempfile = { workspace = true }
4753

54+
[target.'cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))'.dependencies]
55+
fspy = { workspace = true }
56+
4857
[target.'cfg(unix)'.dependencies]
4958
nix = { workspace = true }
5059

crates/vite_task/build.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// Why `cfg(fspy)` instead of matching on `target_os` directly at each use site:
2+
// "fspy is available" is a single semantic predicate, but the underlying reason
3+
// (the `fspy` crate builds on windows/macos/linux) is a three-OS list that
4+
// would otherwise have to be repeated — as `any(target_os = "windows", "macos",
5+
// "linux")` — everywhere `fspy::*` is touched. Naming it `fspy` keeps the
6+
// source self-documenting: code reads `#[cfg(fspy)]` instead of a disjunction
7+
// over OSes. The OS allowlist lives in two spots that must stay in sync: this
8+
// file (for the rustc cfg) and the target-scoped dep block in Cargo.toml
9+
// (which Cargo resolves before build.rs runs, so it can't reuse this cfg).
10+
fn main() {
11+
println!("cargo::rustc-check-cfg=cfg(fspy)");
12+
println!("cargo::rerun-if-changed=build.rs");
13+
14+
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap();
15+
if matches!(target_os.as_str(), "windows" | "macos" | "linux") {
16+
println!("cargo::rustc-cfg=fspy");
17+
}
18+
}

crates/vite_task/src/session/event.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@ pub enum CacheNotUpdatedReason {
7272
/// First path that was both read and written during execution.
7373
path: RelativePathBuf,
7474
},
75+
/// fspy isn't compiled in on this build and the task requires fspy
76+
/// (its `input` config includes auto-inference). Task ran but cannot
77+
/// be cached without tracked path accesses.
78+
FspyUnsupported,
7579
}
7680

7781
#[derive(Debug)]

crates/vite_task/src/session/execute/fingerprint.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,14 @@ use vite_path::{AbsolutePath, RelativePathBuf};
1616
use vite_str::Str;
1717
use wincode::{SchemaRead, SchemaWrite};
1818

19-
use super::tracked_accesses::PathRead;
2019
use crate::{collections::HashMap, session::cache::InputChangeKind};
2120

21+
/// Path read access info
22+
#[derive(Debug, Clone, Copy)]
23+
pub struct PathRead {
24+
pub read_dir_entries: bool,
25+
}
26+
2227
/// Post-run fingerprint capturing file state after execution.
2328
/// Used to validate whether cached outputs are still valid.
2429
#[derive(SchemaWrite, SchemaRead, Debug, Serialize)]

crates/vite_task/src/session/execute/mod.rs

Lines changed: 52 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ pub mod glob_inputs;
33
mod hash;
44
pub mod pipe;
55
pub mod spawn;
6+
#[cfg(fspy)]
67
pub mod tracked_accesses;
78
#[cfg(windows)]
89
mod win_job;
@@ -20,12 +21,13 @@ use vite_task_plan::{
2021
cache_metadata::CacheMetadata, execution_graph::ExecutionNodeIndex,
2122
};
2223

24+
#[cfg(fspy)]
25+
use self::tracked_accesses::TrackedPathAccesses;
2326
use self::{
24-
fingerprint::PostRunFingerprint,
27+
fingerprint::{PathRead, PostRunFingerprint},
2528
glob_inputs::compute_globbed_inputs,
2629
pipe::{PipeSinks, StdOutput, pipe_stdio},
2730
spawn::{SpawnStdio, spawn},
28-
tracked_accesses::TrackedPathAccesses,
2931
};
3032
use super::{
3133
cache::{CacheEntryValue, ExecutionCache},
@@ -291,6 +293,17 @@ struct CacheState<'a> {
291293
fspy_negatives: Option<Vec<wax::Glob<'static>>>,
292294
}
293295

296+
/// Post-execution summary of what fspy observed for a single task. Used in the
297+
/// cache-update step. Fields are cfg-agnostic so the downstream match logic
298+
/// doesn't need `cfg(fspy)` — the value is only ever `Some` when tracking
299+
/// happened (see the `let tracking = ...` fork in `execute_spawn`).
300+
struct TrackingOutcome {
301+
path_reads: HashMap<RelativePathBuf, PathRead>,
302+
/// First path that was both read and written during execution, if any.
303+
/// A non-empty value means caching this task is unsound.
304+
read_write_overlap: Option<RelativePathBuf>,
305+
}
306+
294307
/// Execute a spawned process with cache-aware lifecycle.
295308
///
296309
/// This is a free function (not tied to `ExecutionContext`) so it can be reused
@@ -539,44 +552,57 @@ pub async fn execute_spawn(
539552
let (cache_update_status, cache_error) = if let ExecutionMode::Cached { state, .. } = mode {
540553
let CacheState { metadata, globbed_inputs, std_outputs, fspy_negatives } = state;
541554

542-
// Normalize fspy accesses. `zip` gives `Some` iff fspy was enabled
543-
// (both outcome.path_accesses and fspy_negatives are Some together).
544-
let path_accesses = outcome
545-
.path_accesses
546-
.as_ref()
547-
.zip(fspy_negatives.as_deref())
548-
.map(|(raw, negs)| TrackedPathAccesses::from_raw(raw, cache_base_path, negs));
555+
// Post-execution summary of what fspy observed. `Some` iff tracking was
556+
// both requested (`fspy_negatives.is_some()`) and compiled in (`cfg(fspy)`).
557+
// On a `cfg(not(fspy))` build this is always `None`, and the match below
558+
// short-circuits to `FspyUnsupported` when tracking was needed.
559+
let tracking: Option<TrackingOutcome> = {
560+
#[cfg(fspy)]
561+
{
562+
outcome.path_accesses.as_ref().zip(fspy_negatives.as_deref()).map(|(raw, negs)| {
563+
let tracked = TrackedPathAccesses::from_raw(raw, cache_base_path, negs);
564+
let read_write_overlap = tracked
565+
.path_reads
566+
.keys()
567+
.find(|p| tracked.path_writes.contains(*p))
568+
.cloned();
569+
TrackingOutcome { path_reads: tracked.path_reads, read_write_overlap }
570+
})
571+
}
572+
#[cfg(not(fspy))]
573+
{
574+
None
575+
}
576+
};
549577

550578
let cancelled = fast_fail_token.is_cancelled() || interrupt_token.is_cancelled();
551579
if cancelled {
552580
// Cancelled (Ctrl-C or sibling failure) — result is untrustworthy
553581
(CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::Cancelled), None)
554582
} else if outcome.exit_status.success() {
555-
// Check for read-write overlap: if the task wrote to any file it also
556-
// read, the inputs were modified during execution — don't cache.
557-
// Note: this only checks fspy-inferred reads, not globbed_inputs keys.
558-
// A task that writes to a glob-matched file without reading it causes
559-
// perpetual cache misses (glob detects the hash change) but not a
560-
// correctness bug, so we don't handle that case here.
561-
if let Some(path) = path_accesses
562-
.as_ref()
563-
.and_then(|pa| pa.path_reads.keys().find(|p| pa.path_writes.contains(*p)))
564-
{
583+
// fspy-inferred read-write overlap: the task wrote to a file it also
584+
// read, so the prerun input hashes are stale and caching is unsound.
585+
// (We only check fspy-inferred reads, not globbed_inputs. A task that
586+
// writes to a glob-matched file without reading it produces perpetual
587+
// cache misses but not a correctness bug.)
588+
if let Some(TrackingOutcome { read_write_overlap: Some(path), .. }) = &tracking {
565589
(
566590
CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::InputModified {
567591
path: path.clone(),
568592
}),
569593
None,
570594
)
595+
} else if tracking.is_none() && fspy_negatives.is_some() {
596+
// Task requested fspy auto-inference but this binary was built
597+
// without `cfg(fspy)`. Task ran, but we can't compute a valid
598+
// cache entry without tracked path accesses.
599+
(CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::FspyUnsupported), None)
571600
} else {
572-
// path_reads is empty when inference is disabled (path_accesses is None)
601+
// Paths already in globbed_inputs are skipped: the overlap check
602+
// above guarantees no input modification, so the prerun hash is
603+
// the correct post-exec hash.
573604
let empty_path_reads = HashMap::default();
574-
let path_reads =
575-
path_accesses.as_ref().map_or(&empty_path_reads, |pa| &pa.path_reads);
576-
577-
// Execution succeeded — attempt to create fingerprint and update cache.
578-
// Paths already in globbed_inputs are skipped: Rule 1 (above) guarantees
579-
// no input modification, so the prerun hash is the correct post-exec hash.
605+
let path_reads = tracking.as_ref().map_or(&empty_path_reads, |t| &t.path_reads);
580606
match PostRunFingerprint::create(path_reads, cache_base_path, &globbed_inputs) {
581607
Ok(post_run_fingerprint) => {
582608
let new_cache_value = CacheEntryValue {

0 commit comments

Comments
 (0)