diff --git a/CHANGELOG.md b/CHANGELOG.md index 380ce3ee..b28d58db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Changelog +- **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)) - **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)) - **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)) - **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)) diff --git a/Cargo.lock b/Cargo.lock index 1003a961..3b897f53 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -737,7 +737,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" dependencies = [ "dispatch2", - "nix 0.31.1", + "nix 0.31.2", "windows-sys 0.61.2", ] @@ -1198,7 +1198,7 @@ dependencies = [ "libc", "materialized_artifact", "materialized_artifact_build", - "nix 0.30.1", + "nix 0.31.2", "ouroboros", "rustc-hash", "sha2", @@ -1247,7 +1247,7 @@ dependencies = [ "fspy_shared", "fspy_shared_unix", "libc", - "nix 0.30.1", + "nix 0.31.2", "wincode", ] @@ -1274,7 +1274,7 @@ dependencies = [ "assertables", "futures-util", "libc", - "nix 0.30.1", + "nix 0.31.2", "passfd", "seccompiler", "syscalls", @@ -1318,7 +1318,7 @@ dependencies = [ "fspy_seccomp_unotify", "fspy_shared", "memmap2", - "nix 0.30.1", + "nix 0.31.2", "phf", "stackalloc", "wincode", @@ -1328,7 +1328,7 @@ dependencies = [ name = "fspy_test_bin" version = "0.0.0" dependencies = [ - "nix 0.30.1", + "nix 0.31.2", ] [[package]] @@ -1722,9 +1722,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.180" +version = "0.2.185" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" [[package]] name = "libloading" @@ -1986,9 +1986,9 @@ dependencies = [ [[package]] name = "nix" -version = "0.30.1" +version = "0.31.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" dependencies = [ "bitflags 2.10.0", "cfg-if", @@ -1997,18 +1997,6 @@ dependencies = [ "memoffset 0.9.1", ] -[[package]] -name = "nix" -version = "0.31.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "225e7cfe711e0ba79a68baeddb2982723e4235247aefce1482f2f16c27865b66" -dependencies = [ - "bitflags 2.10.0", - "cfg-if", - "cfg_aliases 0.2.1", - "libc", -] - [[package]] name = "nom" version = "7.1.3" @@ -2620,7 +2608,7 @@ dependencies = [ "anyhow", "ctor", "ctrlc", - "nix 0.30.1", + "nix 0.31.2", "ntest", "portable-pty", "signal-hook", @@ -3946,7 +3934,7 @@ dependencies = [ "derive_more", "fspy", "futures-util", - "nix 0.30.1", + "nix 0.31.2", "once_cell", "owo-colors", "petgraph", diff --git a/Cargo.toml b/Cargo.toml index 835614d0..56300f3e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -83,12 +83,12 @@ fspy_shared_unix = { path = "crates/fspy_shared_unix" } futures = "0.3.31" futures-util = "0.3.31" jsonc-parser = { version = "0.29.0", features = ["serde"] } -libc = "0.2.172" +libc = "0.2.185" libtest-mimic = "0.8.2" memmap2 = "0.9.7" monostate = "1.0.2" native_str = { path = "crates/native_str" } -nix = { version = "0.30.1", features = ["dir", "signal"] } +nix = { version = "0.31.2", features = ["dir", "signal"] } ntapi = "0.4.1" nucleo-matcher = "0.3.1" once_cell = "1.19" diff --git a/crates/fspy/src/command.rs b/crates/fspy/src/command.rs index 2a72c67d..fb150b26 100644 --- a/crates/fspy/src/command.rs +++ b/crates/fspy/src/command.rs @@ -228,7 +228,7 @@ impl Command { /// Convert to a `tokio::process::Command` without tracking. #[must_use] - pub fn into_tokio_command(self) -> TokioCommand { + pub(crate) fn into_tokio_command(self) -> TokioCommand { let mut tokio_cmd = TokioCommand::new(self.program); if let Some(cwd) = &self.cwd { tokio_cmd.current_dir(cwd); diff --git a/crates/vite_task/Cargo.toml b/crates/vite_task/Cargo.toml index 7ba8f2ff..2286b9dc 100644 --- a/crates/vite_task/Cargo.toml +++ b/crates/vite_task/Cargo.toml @@ -2,7 +2,7 @@ name = "vite_task" version = "0.0.0" edition.workspace = true -include = ["/src"] +include = ["/src", "/build.rs"] license.workspace = true publish = false readme = "README.md" @@ -17,8 +17,7 @@ async-trait = { workspace = true } wincode = { workspace = true, features = ["derive"] } clap = { workspace = true, features = ["derive"] } ctrlc = { workspace = true } -derive_more = { workspace = true, features = ["from"] } -fspy = { workspace = true } +derive_more = { workspace = true, features = ["debug", "from"] } futures-util = { workspace = true } once_cell = { workspace = true } owo-colors = { workspace = true } @@ -30,7 +29,14 @@ rustc-hash = { workspace = true } serde = { workspace = true, features = ["derive", "rc"] } serde_json = { workspace = true } thiserror = { workspace = true } -tokio = { workspace = true, features = ["rt-multi-thread", "io-std", "io-util", "macros", "sync"] } +tokio = { workspace = true, features = [ + "rt-multi-thread", + "io-std", + "io-util", + "macros", + "process", + "sync", +] } tokio-util = { workspace = true } tracing = { workspace = true } twox-hash = { workspace = true } @@ -45,6 +51,9 @@ wax = { workspace = true } [dev-dependencies] tempfile = { workspace = true } +[target.'cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))'.dependencies] +fspy = { workspace = true } + [target.'cfg(unix)'.dependencies] nix = { workspace = true } diff --git a/crates/vite_task/build.rs b/crates/vite_task/build.rs new file mode 100644 index 00000000..6acc864e --- /dev/null +++ b/crates/vite_task/build.rs @@ -0,0 +1,18 @@ +// Why `cfg(fspy)` instead of matching on `target_os` directly at each use site: +// "fspy is available" is a single semantic predicate, but the underlying reason +// (the `fspy` crate builds on windows/macos/linux) is a three-OS list that +// would otherwise have to be repeated — as `any(target_os = "windows", "macos", +// "linux")` — everywhere `fspy::*` is touched. Naming it `fspy` keeps the +// source self-documenting: code reads `#[cfg(fspy)]` instead of a disjunction +// over OSes. The OS allowlist lives in two spots that must stay in sync: this +// file (for the rustc cfg) and the target-scoped dep block in Cargo.toml +// (which Cargo resolves before build.rs runs, so it can't reuse this cfg). +fn main() { + println!("cargo::rustc-check-cfg=cfg(fspy)"); + println!("cargo::rerun-if-changed=build.rs"); + + let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap(); + if matches!(target_os.as_str(), "windows" | "macos" | "linux") { + println!("cargo::rustc-cfg=fspy"); + } +} diff --git a/crates/vite_task/src/session/event.rs b/crates/vite_task/src/session/event.rs index 57aec779..a0ca481c 100644 --- a/crates/vite_task/src/session/event.rs +++ b/crates/vite_task/src/session/event.rs @@ -72,6 +72,10 @@ 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. + FspyUnsupported, } #[derive(Debug)] diff --git a/crates/vite_task/src/session/execute/fingerprint.rs b/crates/vite_task/src/session/execute/fingerprint.rs index d73a7f61..7b7103d7 100644 --- a/crates/vite_task/src/session/execute/fingerprint.rs +++ b/crates/vite_task/src/session/execute/fingerprint.rs @@ -16,9 +16,14 @@ use vite_path::{AbsolutePath, RelativePathBuf}; use vite_str::Str; use wincode::{SchemaRead, SchemaWrite}; -use super::tracked_accesses::PathRead; use crate::{collections::HashMap, session::cache::InputChangeKind}; +/// Path read access info +#[derive(Debug, Clone, Copy)] +pub struct PathRead { + pub read_dir_entries: bool, +} + /// Post-run fingerprint capturing file state after execution. /// Used to validate whether cached outputs are still valid. #[derive(SchemaWrite, SchemaRead, Debug, Serialize)] diff --git a/crates/vite_task/src/session/execute/mod.rs b/crates/vite_task/src/session/execute/mod.rs index 8af09888..812de5f6 100644 --- a/crates/vite_task/src/session/execute/mod.rs +++ b/crates/vite_task/src/session/execute/mod.rs @@ -3,6 +3,7 @@ pub mod glob_inputs; mod hash; pub mod pipe; pub mod spawn; +#[cfg(fspy)] pub mod tracked_accesses; #[cfg(windows)] mod win_job; @@ -20,12 +21,13 @@ use vite_task_plan::{ cache_metadata::CacheMetadata, execution_graph::ExecutionNodeIndex, }; +#[cfg(fspy)] +use self::tracked_accesses::TrackedPathAccesses; use self::{ - fingerprint::PostRunFingerprint, + fingerprint::{PathRead, PostRunFingerprint}, glob_inputs::compute_globbed_inputs, pipe::{PipeSinks, StdOutput, pipe_stdio}, spawn::{SpawnStdio, spawn}, - tracked_accesses::TrackedPathAccesses, }; use super::{ cache::{CacheEntryValue, ExecutionCache}, @@ -291,6 +293,17 @@ struct CacheState<'a> { fspy_negatives: Option>>, } +/// Post-execution summary of what fspy observed for a single task. Used in the +/// cache-update step. Fields are cfg-agnostic so the downstream match logic +/// doesn't need `cfg(fspy)` — the value is only ever `Some` when tracking +/// happened (see the `let tracking = ...` fork in `execute_spawn`). +struct TrackingOutcome { + path_reads: HashMap, + /// First path that was both read and written during execution, if any. + /// A non-empty value means caching this task is unsound. + read_write_overlap: Option, +} + /// Execute a spawned process with cache-aware lifecycle. /// /// This is a free function (not tied to `ExecutionContext`) so it can be reused @@ -539,44 +552,57 @@ pub async fn execute_spawn( let (cache_update_status, cache_error) = if let ExecutionMode::Cached { state, .. } = mode { let CacheState { metadata, globbed_inputs, std_outputs, fspy_negatives } = state; - // Normalize fspy accesses. `zip` gives `Some` iff fspy was enabled - // (both outcome.path_accesses and fspy_negatives are Some together). - let path_accesses = outcome - .path_accesses - .as_ref() - .zip(fspy_negatives.as_deref()) - .map(|(raw, negs)| TrackedPathAccesses::from_raw(raw, cache_base_path, negs)); + // Post-execution summary of what fspy observed. `Some` iff tracking was + // both requested (`fspy_negatives.is_some()`) and compiled in (`cfg(fspy)`). + // On a `cfg(not(fspy))` build this is always `None`, and the match below + // short-circuits to `FspyUnsupported` when tracking was needed. + let tracking: Option = { + #[cfg(fspy)] + { + outcome.path_accesses.as_ref().zip(fspy_negatives.as_deref()).map(|(raw, negs)| { + let tracked = TrackedPathAccesses::from_raw(raw, cache_base_path, negs); + let read_write_overlap = tracked + .path_reads + .keys() + .find(|p| tracked.path_writes.contains(*p)) + .cloned(); + TrackingOutcome { path_reads: tracked.path_reads, read_write_overlap } + }) + } + #[cfg(not(fspy))] + { + None + } + }; let cancelled = fast_fail_token.is_cancelled() || interrupt_token.is_cancelled(); if cancelled { // Cancelled (Ctrl-C or sibling failure) — result is untrustworthy (CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::Cancelled), None) } else if outcome.exit_status.success() { - // Check for read-write overlap: if the task wrote to any file it also - // read, the inputs were modified during execution — don't cache. - // Note: this only checks fspy-inferred reads, not globbed_inputs keys. - // A task that writes to a glob-matched file without reading it causes - // perpetual cache misses (glob detects the hash change) but not a - // correctness bug, so we don't handle that case here. - if let Some(path) = path_accesses - .as_ref() - .and_then(|pa| pa.path_reads.keys().find(|p| pa.path_writes.contains(*p))) - { + // 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. + // (We only check fspy-inferred reads, not globbed_inputs. A task that + // writes to a glob-matched file without reading it produces perpetual + // cache misses but not a correctness bug.) + if let Some(TrackingOutcome { read_write_overlap: Some(path), .. }) = &tracking { ( CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::InputModified { path: path.clone(), }), None, ) + } else if tracking.is_none() && fspy_negatives.is_some() { + // Task requested fspy auto-inference but this binary was built + // without `cfg(fspy)`. Task ran, but we can't compute a valid + // cache entry without tracked path accesses. + (CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::FspyUnsupported), None) } else { - // path_reads is empty when inference is disabled (path_accesses is None) + // Paths already in globbed_inputs are skipped: the overlap check + // above guarantees no input modification, so the prerun hash is + // the correct post-exec hash. let empty_path_reads = HashMap::default(); - let path_reads = - path_accesses.as_ref().map_or(&empty_path_reads, |pa| &pa.path_reads); - - // Execution succeeded — attempt to create fingerprint and update cache. - // Paths already in globbed_inputs are skipped: Rule 1 (above) guarantees - // no input modification, so the prerun hash is the correct post-exec hash. + let path_reads = tracking.as_ref().map_or(&empty_path_reads, |t| &t.path_reads); match PostRunFingerprint::create(path_reads, cache_base_path, &globbed_inputs) { Ok(post_run_fingerprint) => { let new_cache_value = CacheEntryValue { diff --git a/crates/vite_task/src/session/execute/spawn.rs b/crates/vite_task/src/session/execute/spawn.rs index 0f29926b..2ed64fc8 100644 --- a/crates/vite_task/src/session/execute/spawn.rs +++ b/crates/vite_task/src/session/execute/spawn.rs @@ -2,10 +2,12 @@ //! //! [`spawn`] does one thing: hand back the child's stdio pipes plus a //! cancellation-aware `wait` future. Draining the pipes is [`super::pipe`]'s -//! job; normalizing fspy path accesses is [`super::tracked_accesses`]'s. +//! job; normalizing fspy path accesses is [`super::tracked_accesses`]'s (only +//! compiled when `cfg(fspy)` is on). use std::{io, process::Stdio}; +#[cfg(fspy)] use fspy::PathAccessIterable; use futures_util::{FutureExt, future::LocalBoxFuture}; use tokio::process::{ChildStderr, ChildStdout}; @@ -40,6 +42,7 @@ pub struct ChildHandle { pub struct ChildOutcome { pub exit_status: std::process::ExitStatus, /// Raw fspy accesses. `Some` iff `fspy` was `true` at spawn time. + #[cfg(fspy)] pub path_accesses: Option, } @@ -47,12 +50,37 @@ pub struct ChildOutcome { /// /// Cancellation is unified: whether fspy is enabled or not, the returned `wait` /// future observes `cancellation_token` and kills the child before resolving. +/// +/// On builds without `cfg(fspy)`, the `fspy` argument is ignored and the tokio +/// path is always taken. #[tracing::instrument(level = "debug", skip_all)] pub async fn spawn( cmd: &SpawnCommand, fspy: bool, stdio: SpawnStdio, cancellation_token: CancellationToken, +) -> anyhow::Result { + #[cfg(fspy)] + if fspy { + return spawn_fspy(cmd, stdio, cancellation_token).await; + } + #[cfg(not(fspy))] + let _ = fspy; + + let mut tokio_cmd = tokio::process::Command::new(cmd.program_path.as_path()); + tokio_cmd.args(cmd.args.iter().map(vite_str::Str::as_str)); + tokio_cmd.env_clear(); + tokio_cmd.envs(cmd.all_envs.iter()); + tokio_cmd.current_dir(&*cmd.cwd); + apply_stdio(&mut tokio_cmd, stdio); + spawn_tokio(tokio_cmd, cancellation_token) +} + +#[cfg(fspy)] +async fn spawn_fspy( + cmd: &SpawnCommand, + stdio: SpawnStdio, + cancellation_token: CancellationToken, ) -> anyhow::Result { let mut fspy_cmd = fspy::Command::new(cmd.program_path.as_path()); fspy_cmd.args(cmd.args.iter().map(vite_str::Str::as_str)); @@ -77,18 +105,7 @@ pub async fn spawn( } } - if fspy { - spawn_fspy(fspy_cmd, cancellation_token).await - } else { - spawn_tokio(fspy_cmd, cancellation_token) - } -} - -async fn spawn_fspy( - cmd: fspy::Command, - cancellation_token: CancellationToken, -) -> anyhow::Result { - let mut tracked = cmd.spawn(cancellation_token).await?; + let mut tracked = fspy_cmd.spawn(cancellation_token).await?; // On Windows, assign the child to a Job Object so that killing the child // also kills all descendant processes (e.g., node.exe via a .cmd shim). @@ -120,10 +137,10 @@ async fn spawn_fspy( } fn spawn_tokio( - cmd: fspy::Command, + mut cmd: tokio::process::Command, cancellation_token: CancellationToken, ) -> anyhow::Result { - let mut child = cmd.into_tokio_command().spawn()?; + let mut child = cmd.spawn()?; #[cfg(windows)] let job = { @@ -152,13 +169,37 @@ fn spawn_tokio( // `job` drops here on Windows, terminating any stragglers. #[cfg(windows)] drop(job); - Ok(ChildOutcome { exit_status, path_accesses: None }) + Ok(ChildOutcome { + exit_status, + #[cfg(fspy)] + path_accesses: None, + }) } .boxed_local(); Ok(ChildHandle { stdout, stderr, wait }) } +fn apply_stdio(cmd: &mut tokio::process::Command, stdio: SpawnStdio) { + match stdio { + SpawnStdio::Inherited => { + cmd.stdin(Stdio::inherit()).stdout(Stdio::inherit()).stderr(Stdio::inherit()); + // libuv (used by Node.js) marks stdin/stdout/stderr as close-on-exec; + // without this fix the child reopens fds 0-2 as /dev/null after exec. + // See: https://github.com/libuv/libuv/issues/2062 + // SAFETY: the pre_exec closure only performs fcntl operations on + // stdio fds, which is safe in a post-fork context. + #[cfg(unix)] + unsafe { + cmd.pre_exec(clear_stdio_cloexec); + } + } + SpawnStdio::Piped => { + cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()); + } + } +} + #[cfg(unix)] #[expect( clippy::unnecessary_wraps, diff --git a/crates/vite_task/src/session/execute/tracked_accesses.rs b/crates/vite_task/src/session/execute/tracked_accesses.rs index 79c77f10..83596cc7 100644 --- a/crates/vite_task/src/session/execute/tracked_accesses.rs +++ b/crates/vite_task/src/session/execute/tracked_accesses.rs @@ -1,4 +1,5 @@ //! Normalize raw fspy path accesses into workspace-relative, filtered form. +#![cfg(fspy)] use std::collections::hash_map::Entry; @@ -6,14 +7,9 @@ use fspy::{AccessMode, PathAccessIterable}; use rustc_hash::FxHashSet; use vite_path::{AbsolutePath, RelativePathBuf}; +use super::fingerprint::PathRead; use crate::collections::HashMap; -/// Path read access info -#[derive(Debug, Clone, Copy)] -pub struct PathRead { - pub read_dir_entries: bool, -} - /// Tracked file accesses from fspy, normalized to workspace-relative paths. #[derive(Default, Debug)] pub struct TrackedPathAccesses { diff --git a/crates/vite_task/src/session/reporter/summary.rs b/crates/vite_task/src/session/reporter/summary.rs index 81c314b1..4eeceb68 100644 --- a/crates/vite_task/src/session/reporter/summary.rs +++ b/crates/vite_task/src/session/reporter/summary.rs @@ -102,6 +102,11 @@ pub enum SpawnOutcome { /// First path that was both read and written, causing cache to be skipped. /// Only set when fspy detected a read-write overlap. input_modified_path: Option, + /// `true` when the task required fspy auto-inference but the binary was + /// built without `cfg(fspy)` (e.g., cross-compiled to an unsupported OS). + /// Task ran successfully but cache was not updated. + #[serde(default)] + fspy_unsupported: bool, }, /// Process exited with non-zero status. @@ -282,6 +287,10 @@ impl TaskResult { } _ => None, }; + let fspy_unsupported = matches!( + cache_update_status, + CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::FspyUnsupported) + ); match cache_status { CacheStatus::Hit { replayed_duration } => { @@ -294,6 +303,7 @@ impl TaskResult { exit_status, saved_error, input_modified_path, + fspy_unsupported, ), }, CacheStatus::Miss(cache_miss) => Self::Spawned { @@ -304,6 +314,7 @@ impl TaskResult { exit_status, saved_error, input_modified_path, + fspy_unsupported, ), }, } @@ -315,14 +326,17 @@ fn spawn_outcome_from_execution( exit_status: Option, saved_error: Option<&SavedExecutionError>, input_modified_path: Option, + fspy_unsupported: bool, ) -> SpawnOutcome { match (exit_status, saved_error) { // Spawn error — process never ran (None, Some(err)) => SpawnOutcome::SpawnError(err.clone()), // Process exited successfully, possible infra error - (Some(status), _) if status.success() => { - SpawnOutcome::Success { infra_error: saved_error.cloned(), input_modified_path } - } + (Some(status), _) if status.success() => SpawnOutcome::Success { + infra_error: saved_error.cloned(), + input_modified_path, + fspy_unsupported, + }, // Process exited with non-zero code (Some(status), _) => { let code = crate::session::event::exit_status_to_code(status); @@ -336,7 +350,11 @@ fn spawn_outcome_from_execution( // No exit status, no error — this is the cache hit / in-process path, // handled by TaskResult::CacheHit / InProcess before reaching here. // If we somehow get here, treat as success. - (None, None) => SpawnOutcome::Success { infra_error: None, input_modified_path: None }, + (None, None) => SpawnOutcome::Success { + infra_error: None, + input_modified_path: None, + fspy_unsupported: false, + }, } } @@ -455,6 +473,15 @@ impl TaskResult { { return vite_str::format!("→ Not cached: read and wrote '{path}'"); } + // fspy-unsupported-on-this-OS 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.", + ); + } match self { Self::CacheHit { saved_duration_ms } => {