Skip to content

Commit 377cc3d

Browse files
authored
feat(cache): fingerprint tracked getEnv reads (#454)
## Motivation Runner-served `getEnv` values can influence tool output even when the env is not declared in the task config. If cached tasks do not remember those reads, a later run can replay stale output after the served env value changes. ## Scope Always record runner-aware `getEnv` reads in IPC reports, store SHA-256 hashes of their served values in the post-run fingerprint, validate those hashes during cache lookup, and render env-specific cache miss messages without exposing values. This follows the env hashing model from #455, bumps the cache schema for the new serialized fingerprint field, and raises a post-run fingerprint error instead of treating non-UTF-8 tracked env values as unset. This PR intentionally does not add the `tracked` option or `getEnvs`; those are split into later PRs in the stack. ## Verification - `cargo check -p vite_task` - `cargo test -p vite_task_server --test integration` - `cargo test -p vite_task_bin --test e2e_snapshots fetch_env_tracked_invalidates_on_change -- --ignored` - `cargo test -p vite_task_bin --test e2e_snapshots fetch_env_tracks_with_explicit_inputs -- --ignored`
1 parent d5e2684 commit 377cc3d

15 files changed

Lines changed: 377 additions & 41 deletions

File tree

CHANGELOG.md

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

33
- **Changed** Tracked environment values in task cache fingerprints are now stored only as SHA-256 digests, and env-related cache miss details report names without values.
4+
- **Fixed** Runner-aware `getEnv` reads now affect task cache fingerprints, so changing a tool-served env value invalidates cached output and names the env var in the miss message ([#454](https://github.com/voidzero-dev/vite-task/pull/454)).
45
- **Added** Runner-aware tools can now opt the current task run out of caching through the new IPC channel; Vite dev server integration uses this automatically ([#441](https://github.com/voidzero-dev/vite-task/pull/441))
56
- **Fixed** Prefix environment assignments like `PATH=... command` now affect executable lookup during task planning, so tools provided only by the prefixed `PATH` can be resolved correctly ([#440](https://github.com/voidzero-dev/vite-task/pull/440))
67
- **Changed** Cache misses caused by a tracked env var now name the env var inline, for example `cache miss: env 'NODE_ENV' changed`, instead of the generic `envs changed` message ([#438](https://github.com/voidzero-dev/vite-task/pull/438))

crates/vite_task/src/session/cache/display.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,9 @@ pub fn format_cache_status_inline(cache_status: &CacheStatus) -> Option<Str> {
186186
FingerprintMismatch::InputChanged { kind, path } => {
187187
format_input_change_str(*kind, path.as_str())
188188
}
189+
FingerprintMismatch::TrackedEnvChanged(mismatch) => {
190+
format_env_changed_inline(&[mismatch.name()])
191+
}
189192
};
190193
Some(vite_str::format!("○ cache miss: {reason}, executing"))
191194
}

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

Lines changed: 30 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,21 @@ impl EnvMismatch {
163163
Self::Added { name } | Self::Removed { name } | Self::Changed { name } => name,
164164
}
165165
}
166+
167+
/// Compare a stored env value against the current one, returning the
168+
/// mismatch if they differ. `None` on either side means the env is unset
169+
/// there; two unset or two equal values are not a mismatch.
170+
#[must_use]
171+
pub fn compare<T: Eq>(name: &Str, stored: Option<&T>, current: Option<&T>) -> Option<Self> {
172+
match (stored, current) {
173+
(None, Some(_)) => Some(Self::Added { name: name.clone() }),
174+
(Some(_), None) => Some(Self::Removed { name: name.clone() }),
175+
(Some(old_value), Some(new_value)) if old_value != new_value => {
176+
Some(Self::Changed { name: name.clone() })
177+
}
178+
_ => None,
179+
}
180+
}
166181
}
167182

168183
impl Display for EnvMismatch {
@@ -194,23 +209,16 @@ pub enum FingerprintMismatch {
194209
kind: InputChangeKind,
195210
path: RelativePathBuf,
196211
},
212+
/// A runner-aware tool-tracked env var changed between runs.
213+
TrackedEnvChanged(EnvMismatch),
197214
}
198215

199-
impl Display for FingerprintMismatch {
200-
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
201-
match self {
202-
Self::SpawnFingerprint { old, new } => {
203-
write!(f, "Spawn fingerprint changed: old={old:?}, new={new:?}")
204-
}
205-
Self::InputConfig => {
206-
write!(f, "input configuration changed")
207-
}
208-
Self::OutputConfig => {
209-
write!(f, "output configuration changed")
210-
}
211-
Self::InputChanged { kind, path } => {
212-
write!(f, "{}", display::format_input_change_str(*kind, path.as_str()))
213-
}
216+
impl From<crate::session::execute::fingerprint::PostRunMismatch> for FingerprintMismatch {
217+
fn from(mismatch: crate::session::execute::fingerprint::PostRunMismatch) -> Self {
218+
use crate::session::execute::fingerprint::PostRunMismatch;
219+
match mismatch {
220+
PostRunMismatch::InputChanged { kind, path } => Self::InputChanged { kind, path },
221+
PostRunMismatch::TrackedEnvChanged(mismatch) => Self::TrackedEnvChanged(mismatch),
214222
}
215223
}
216224
}
@@ -236,7 +244,7 @@ pub fn split_path(path: &str) -> (Option<&str>, &str) {
236244
/// its own cache warm across branch switches, and a cache from a different
237245
/// version is simply ignored (it lives in a directory this build never looks
238246
/// at) rather than aborting the run. Bumping the version starts a fresh cache.
239-
const CACHE_SCHEMA_VERSION: u32 = 14;
247+
const CACHE_SCHEMA_VERSION: u32 = 15;
240248

241249
/// Name of the per-version subdirectory (e.g. `v14`) under the task-cache
242250
/// directory that holds the database and output archives for the current
@@ -303,11 +311,12 @@ impl ExecutionCache {
303311
return Ok(Err(CacheMiss::FingerprintMismatch(mismatch)));
304312
}
305313

306-
// Validate post-run fingerprint (inferred inputs from fspy)
307-
if let Some((kind, path)) = cache_value.post_run_fingerprint.validate(workspace_root)? {
308-
return Ok(Err(CacheMiss::FingerprintMismatch(
309-
FingerprintMismatch::InputChanged { kind, path },
310-
)));
314+
// Validate post-run fingerprint (inferred inputs + tracked envs)
315+
if let Some(mismatch) = cache_value
316+
.post_run_fingerprint
317+
.validate(workspace_root, &cache_metadata.unfiltered_envs)?
318+
{
319+
return Ok(Err(CacheMiss::FingerprintMismatch(mismatch.into())));
311320
}
312321
// Associate the execution key to the cache entry key if not already,
313322
// so that next time we can find it and report what changed

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

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
//! Post-run cache update: decide whether a finished spawn may be cached and,
22
//! if so, store its fingerprint, captured output, and output archive.
33
4-
use std::{sync::Arc, time::Duration};
4+
use std::{collections::BTreeMap, sync::Arc, time::Duration};
55

66
use vite_path::{AbsolutePath, RelativePathBuf};
77
use vite_str::Str;
8-
use vite_task_plan::cache_metadata::CacheMetadata;
8+
use vite_task_plan::cache_metadata::{CacheMetadata, EnvValueHash};
99
use vite_task_server::Reports;
1010

1111
use super::{
@@ -97,13 +97,27 @@ pub(super) async fn update_cache(
9797
return (CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::FspyUnsupported), None);
9898
}
9999

100+
// Collect tool-reported tracked envs for the post-run fingerprint. Env
101+
// names that the user already declared are skipped because their values
102+
// are already part of the spawn fingerprint.
103+
let tracked_envs = match collect_tracked_reports(reports, metadata) {
104+
Ok(tracked_envs) => tracked_envs,
105+
Err(err) => {
106+
return (
107+
CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::CacheDisabled),
108+
Some(ExecutionError::PostRunFingerprint(err)),
109+
);
110+
}
111+
};
112+
100113
// Paths already in globbed_inputs are skipped: the overlap check above
101114
// guarantees no input modification, so the prerun hash is the correct
102115
// post-exec hash.
103116
let empty_path_reads = HashMap::default();
104117
let path_reads = fspy_outcome.as_ref().map_or(&empty_path_reads, |o| &o.path_reads);
105118
let post_run_fingerprint =
106-
match PostRunFingerprint::create(path_reads, workspace_root, &globbed_inputs) {
119+
match PostRunFingerprint::create(path_reads, workspace_root, &globbed_inputs, tracked_envs)
120+
{
107121
Ok(fingerprint) => fingerprint,
108122
Err(err) => {
109123
return (
@@ -166,6 +180,44 @@ fn observe_fspy(
166180
}
167181
}
168182

183+
fn collect_tracked_reports(
184+
reports: Option<&Reports>,
185+
metadata: &CacheMetadata,
186+
) -> anyhow::Result<BTreeMap<Str, Option<EnvValueHash>>> {
187+
reports.map(|r| collect_tracked_envs(r, metadata)).transpose().map(Option::unwrap_or_default)
188+
}
189+
190+
/// Select tool-reported env records to embed in the post-run fingerprint.
191+
/// Only `tracked: true` records are included, and names that the user already
192+
/// declared as fingerprinted are skipped.
193+
fn collect_tracked_envs(
194+
reports: &Reports,
195+
metadata: &CacheMetadata,
196+
) -> anyhow::Result<BTreeMap<Str, Option<EnvValueHash>>> {
197+
let fingerprinted = &metadata.spawn_fingerprint.env_fingerprints().fingerprinted_envs;
198+
let mut tracked_envs = BTreeMap::new();
199+
200+
for (name, value) in &reports.env_records {
201+
let name_str =
202+
name.to_str().ok_or_else(|| anyhow::anyhow!("tracked env name is not valid UTF-8"))?;
203+
if fingerprinted.contains_key(name_str) {
204+
continue;
205+
}
206+
let value = value
207+
.as_ref()
208+
.map(|value| {
209+
let value_str = value.to_str().ok_or_else(|| {
210+
anyhow::anyhow!("tracked env value for {name_str} is not valid UTF-8")
211+
})?;
212+
Ok::<_, anyhow::Error>(EnvValueHash::new(value_str))
213+
})
214+
.transpose()?;
215+
tracked_envs.insert(Str::from(name_str), value);
216+
}
217+
218+
Ok(tracked_envs)
219+
}
220+
169221
/// Collect output files matching the configured globs and create a tar.zst
170222
/// archive in the cache directory.
171223
///

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

Lines changed: 101 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,24 @@
55
66
use std::{
77
collections::BTreeMap,
8+
ffi::OsStr,
89
fs::File,
910
io::{self, BufRead},
1011
sync::Arc,
1112
};
1213

1314
use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
15+
use rustc_hash::FxHashMap;
1416
use serde::{Deserialize, Serialize};
1517
use vite_path::{AbsolutePath, RelativePathBuf};
1618
use vite_str::Str;
19+
use vite_task_plan::cache_metadata::EnvValueHash;
1720
use wincode::{SchemaRead, SchemaWrite};
1821

19-
use crate::{collections::HashMap, session::cache::InputChangeKind};
22+
use crate::{
23+
collections::HashMap,
24+
session::cache::{EnvMismatch, InputChangeKind},
25+
};
2026

2127
/// Path read access info
2228
#[derive(Debug, Clone, Copy)]
@@ -26,11 +32,26 @@ pub struct PathRead {
2632

2733
/// Post-run fingerprint capturing file state after execution.
2834
/// Used to validate whether cached outputs are still valid.
29-
#[derive(SchemaWrite, SchemaRead, Debug, Serialize)]
35+
#[derive(SchemaWrite, SchemaRead, Debug, Default, Serialize)]
3036
pub struct PostRunFingerprint {
3137
/// Paths inferred from fspy during execution with their content fingerprints.
3238
/// Only populated when `input_config.includes_auto` is true.
3339
pub inferred_inputs: HashMap<RelativePathBuf, PathFingerprint>,
40+
41+
/// Env vars observed via runner-aware IPC `getEnv` with `tracked: true`.
42+
/// Key is the env name; value is the env value hash at execution time, or
43+
/// `None` if unset. Validated at cache lookup against the same plan env
44+
/// context that served the original request.
45+
pub tracked_envs: BTreeMap<Str, Option<EnvValueHash>>,
46+
}
47+
48+
/// A mismatch between the stored post-run fingerprint and the current state.
49+
#[derive(Debug, Clone)]
50+
pub enum PostRunMismatch {
51+
/// An inferred input file or directory changed.
52+
InputChanged { kind: InputChangeKind, path: RelativePathBuf },
53+
/// A tool-tracked env var changed value, appeared, or disappeared.
54+
TrackedEnvChanged(EnvMismatch),
3455
}
3556

3657
/// Fingerprint for a single path (file or directory)
@@ -69,11 +90,13 @@ impl PostRunFingerprint {
6990
/// * `inferred_path_reads` - Map of paths that were read during execution (from fspy)
7091
/// * `base_dir` - Workspace root for resolving relative paths
7192
/// * `globbed_inputs` - Prerun glob fingerprint; paths here are skipped
93+
/// * `tracked_envs` - Tool-requested env vars (name -> value hash), validated on lookup
7294
#[tracing::instrument(level = "debug", skip_all, name = "create_post_run_fingerprint")]
7395
pub fn create(
7496
inferred_path_reads: &HashMap<RelativePathBuf, PathRead>,
7597
base_dir: &AbsolutePath,
7698
globbed_inputs: &BTreeMap<RelativePathBuf, u64>,
99+
tracked_envs: BTreeMap<Str, Option<EnvValueHash>>,
77100
) -> anyhow::Result<Self> {
78101
let inferred_inputs = inferred_path_reads
79102
.par_iter()
@@ -85,16 +108,24 @@ impl PostRunFingerprint {
85108
})
86109
.collect::<anyhow::Result<HashMap<_, _>>>()?;
87110

88-
Ok(Self { inferred_inputs })
111+
Ok(Self { inferred_inputs, tracked_envs })
89112
}
90113

91-
/// Validates the fingerprint against current filesystem state.
92-
/// Returns `Some((kind, path))` if an input changed, `None` if all valid.
114+
/// Validates the fingerprint against current filesystem state and the
115+
/// unfiltered env context used by runner-aware IPC. `unfiltered_envs` must
116+
/// be the same plan env context that served the original `getEnv` request,
117+
/// not the filtered env passed to the spawned process.
118+
///
119+
/// Returns `Some(mismatch)` if anything changed, `None` if all valid.
120+
/// Returns an error if a tracked env is currently present but cannot be
121+
/// represented as UTF-8; treating that value as unset would make cache
122+
/// validation unsound.
93123
#[tracing::instrument(level = "debug", skip_all, name = "validate_post_run_fingerprint")]
94124
pub fn validate(
95125
&self,
96126
base_dir: &AbsolutePath,
97-
) -> anyhow::Result<Option<(InputChangeKind, RelativePathBuf)>> {
127+
unfiltered_envs: &FxHashMap<Arc<OsStr>, Arc<OsStr>>,
128+
) -> anyhow::Result<Option<PostRunMismatch>> {
98129
let input_mismatch = self.inferred_inputs.par_iter().find_map_any(
99130
|(input_relative_path, path_fingerprint)| {
100131
let input_full_path = Arc::<AbsolutePath>::from(base_dir.join(input_relative_path));
@@ -120,11 +151,32 @@ impl PostRunFingerprint {
120151
} else {
121152
input_relative_path.clone()
122153
};
123-
Some(Ok((kind, path)))
154+
Some(Ok(PostRunMismatch::InputChanged { kind, path }))
124155
}
125156
},
126157
);
127-
input_mismatch.transpose()
158+
if let Some(result) = input_mismatch {
159+
return result.map(Some);
160+
}
161+
162+
for (name, stored_value) in &self.tracked_envs {
163+
let current_value = unfiltered_envs
164+
.get(OsStr::new(name.as_str()))
165+
.map(|value| {
166+
let value_str = value.to_str().ok_or_else(|| {
167+
anyhow::anyhow!("tracked env value for {name} is not valid UTF-8")
168+
})?;
169+
Ok::<_, anyhow::Error>(EnvValueHash::new(value_str))
170+
})
171+
.transpose()?;
172+
if let Some(mismatch) =
173+
EnvMismatch::compare(name, stored_value.as_ref(), current_value.as_ref())
174+
{
175+
return Ok(Some(PostRunMismatch::TrackedEnvChanged(mismatch)));
176+
}
177+
}
178+
179+
Ok(None)
128180
}
129181
}
130182

@@ -320,3 +372,44 @@ fn process_directory_unix(file: &File, path_read: PathRead) -> anyhow::Result<Pa
320372

321373
Ok(PathFingerprint::Folder(Some(entries)))
322374
}
375+
376+
#[cfg(test)]
377+
mod tests {
378+
use std::ffi::{OsStr, OsString};
379+
380+
use super::*;
381+
382+
#[cfg(unix)]
383+
fn non_utf8_os_string() -> OsString {
384+
use std::os::unix::ffi::OsStringExt;
385+
386+
OsString::from_vec(vec![0xFF])
387+
}
388+
389+
#[cfg(windows)]
390+
fn non_utf8_os_string() -> OsString {
391+
use std::os::windows::ffi::OsStringExt;
392+
393+
OsString::from_wide(&[0xD800])
394+
}
395+
396+
#[test]
397+
fn validate_errors_on_current_non_utf8_tracked_env_value() {
398+
let mut tracked_envs = BTreeMap::new();
399+
tracked_envs.insert(Str::from("PROBE_ENV"), None);
400+
let fingerprint = PostRunFingerprint { tracked_envs, ..PostRunFingerprint::default() };
401+
402+
let mut unfiltered_envs = FxHashMap::default();
403+
unfiltered_envs.insert(
404+
Arc::<OsStr>::from(OsStr::new("PROBE_ENV")),
405+
Arc::<OsStr>::from(non_utf8_os_string()),
406+
);
407+
408+
let workspace_root = vite_path::current_dir().expect("cwd");
409+
let err = fingerprint
410+
.validate(&workspace_root, &unfiltered_envs)
411+
.expect_err("non-UTF-8 tracked env values must error");
412+
413+
assert!(err.to_string().contains("tracked env value for PROBE_ENV is not valid UTF-8"));
414+
}
415+
}

0 commit comments

Comments
 (0)