Skip to content
Merged
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
17 changes: 9 additions & 8 deletions crates/vite_task/src/session/execute/cache_update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ pub(super) async fn update_cache(
cancelled: bool,
) -> (CacheUpdateStatus, Option<ExecutionError>) {
let CacheState { metadata, globbed_inputs, std_outputs, tracking } = state;
let fspy_negatives = tracking.as_ref().map(|t| t.input_negative_globs.as_slice());
let fspy = tracking.fspy.as_ref();
let input_negative_globs = fspy.map(|t| t.input_negative_globs.as_slice());

if let Some(reports) = reports
&& reports.cache_disabled
Expand All @@ -73,7 +74,7 @@ pub(super) async fn update_cache(
return (CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::NonZeroExitStatus), None);
}

let fspy_outcome = observe_fspy(outcome, fspy_negatives, workspace_root);
let fspy_outcome = observe_fspy(outcome, input_negative_globs, workspace_root);

if let Some(TrackingOutcome { read_write_overlap: Some(path), .. }) = &fspy_outcome {
// fspy-inferred read-write overlap: the task wrote to a file it also
Expand All @@ -89,7 +90,7 @@ pub(super) async fn update_cache(
);
}

if fspy_outcome.is_none() && fspy_negatives.is_some() {
if fspy_outcome.is_none() && fspy.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.
Expand Down Expand Up @@ -139,28 +140,28 @@ pub(super) async fn update_cache(
}

/// Summarize the run's fspy observations. `Some` iff tracking was both
/// requested (`fspy_negatives.is_some()`) and compiled in (`cfg(fspy)`). On a
/// requested (`input_negative_globs.is_some()`) and compiled in (`cfg(fspy)`). On a
/// `cfg(not(fspy))` build this is always `None`, and [`update_cache`]
/// short-circuits to `FspyUnsupported` when tracking was needed.
fn observe_fspy(
outcome: &ChildOutcome,
fspy_negatives: Option<&[wax::Glob<'static>]>,
input_negative_globs: Option<&[wax::Glob<'static>]>,
workspace_root: &AbsolutePath,
) -> Option<TrackingOutcome> {
#[cfg(fspy)]
{
use super::tracked_accesses::TrackedPathAccesses;

outcome.path_accesses.as_ref().zip(fspy_negatives).map(|(raw, negs)| {
let tracked = TrackedPathAccesses::from_raw(raw, workspace_root, negs);
outcome.path_accesses.as_ref().zip(input_negative_globs).map(|(raw, negatives)| {
let tracked = TrackedPathAccesses::from_raw(raw, workspace_root, negatives);
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))]
{
let _ = (outcome, fspy_negatives, workspace_root);
let _ = (outcome, input_negative_globs, workspace_root);
None
}
}
Expand Down
60 changes: 32 additions & 28 deletions crates/vite_task/src/session/execute/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,21 +88,27 @@ struct CacheState<'a> {
/// Captured stdout/stderr for cache replay. Written in place during drain;
/// always present (possibly empty) once we reach the cache-update phase.
std_outputs: Vec<StdOutput>,
/// `Some` iff auto-input tracking is on (`input.includes_auto` + successful
/// IPC bind). Bundles fspy's input negative globs with the per-task IPC
/// server that runner-aware tools talk to. Parts are borrowed in place
/// during the wait/join; the struct is never moved out.
tracking: Option<Tracking>,
/// Runner-aware tracking for cached tasks: an IPC server is always
/// available, and fspy path tracing is attached only when auto input
/// inference needs it. Parts are borrowed in place during the wait/join;
/// the struct is never moved out.
tracking: Tracking,
}

/// The IPC server's driver future: resolves with the recorded reports after
/// [`StopAccepting::signal`] fires and all in-flight clients drain.
type IpcDriver = LocalBoxFuture<'static, Result<Recorder, vite_task_server::Error>>;

/// Per-task tracking: fspy input-negative globs + IPC server handle.
/// fspy path-tracking state, present only when a cached task needs automatic
/// input inference.
struct FspyTracking {
input_negative_globs: Vec<wax::Glob<'static>>,
}

/// Per-task runner-aware tracking: IPC server handle plus optional fspy state.
/// Lifetime-tied to a single `execute_spawn` call.
struct Tracking {
input_negative_globs: Vec<wax::Glob<'static>>,
fspy: Option<FspyTracking>,
ipc_envs: Vec<(&'static OsStr, OsString)>,
ipc_server_fut: IpcDriver,
stop_accepting: StopAccepting,
Expand All @@ -127,7 +133,7 @@ struct RunHandles<'m> {
/// Pipe writers + capture slot. `None` only in the inherited-uncached
/// case, where there are no pipes to drain.
sinks: Option<PipeSinks<'m>>,
/// The IPC server's handles. `None` iff tracking is off.
/// The IPC server's handles. `None` iff execution is uncached.
ipc: Option<IpcHandles<'m>>,
}

Expand Down Expand Up @@ -158,7 +164,7 @@ impl<'a> ExecutionMode<'a> {
});
};

let tracking = if metadata.input_config.includes_auto {
let fspy = if metadata.input_config.includes_auto {
// Resolve input negative globs for fspy path filtering (already
// workspace-root-relative).
let negatives = metadata
Expand All @@ -168,33 +174,31 @@ impl<'a> ExecutionMode<'a> {
.map(|p| Ok(wax::Glob::new(p.as_str())?.into_owned()))
.collect::<anyhow::Result<Vec<_>>>()
.map_err(ExecutionError::PostRunFingerprint)?;
// fspy + IPC are bundled. If binding the IPC server fails we abort
// the execution — tools that rely on IPC would otherwise silently
// diverge from the cache.
let (envs, ServerHandle { driver, stop_accepting }) =
serve(Recorder::new()).map_err(ExecutionError::IpcServerBind)?;
Some(Tracking {
input_negative_globs: negatives,
ipc_envs: envs.collect(),
ipc_server_fut: driver,
stop_accepting,
})
Some(FspyTracking { input_negative_globs: negatives })
} else {
None
};

// Bind runner IPC for every cached task. The merged cache-control API
// (`disableCache`) must work even when a task uses explicit inputs and
// therefore does not need fspy auto-input inference.
let (ipc_envs, ServerHandle { driver, stop_accepting }) =
serve(Recorder::new()).map_err(ExecutionError::IpcServerBind)?;
Comment on lines +185 to +186

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Accept pending IPC clients before stopping

Binding IPC for explicit-input cached tasks is not sufficient because disableCache is fire-and-forget: if a short-lived child connects, writes the request, and exits before the driver has accepted that connection, run_child signals stop_accepting, and the server can take the cancellation branch and drop the queued client without processing the frame (the server integration test comment in vite_task_server/tests/integration.rs:41-44 calls out this exact race). In that scenario the new explicit-input path still stores a cache entry and the next run can incorrectly hit despite the tool opting out, so the runner needs to drain/accept pending IPC clients or add an acknowledgement before allowing the cache update.

Useful? React with 👍 / 👎.

let tracking =
Tracking { fspy, ipc_envs: ipc_envs.collect(), ipc_server_fut: driver, stop_accepting };

Ok(Self::Cached {
pipe_writers: stdio_config.writers,
state: CacheState { metadata, globbed_inputs, std_outputs: Vec::new(), tracking },
})
}

/// The extra envs to inject into the child: IPC connection info + the
/// napi addon path runner-aware tools `require()`. Empty when tracking
/// is off.
/// napi addon path runner-aware tools `require()`. Empty when execution
/// is uncached.
fn injected_envs(&self) -> Vec<(&OsStr, &OsStr)> {
match self {
Self::Cached { state: CacheState { tracking: Some(t), .. }, .. } => {
Self::Cached { state: CacheState { tracking: t, .. }, .. } => {
let mut envs: Vec<(&OsStr, &OsStr)> =
t.ipc_envs.iter().map(|(k, v)| (*k, v.as_os_str())).collect();
envs.push((
Expand All @@ -203,15 +207,15 @@ impl<'a> ExecutionMode<'a> {
));
envs
}
_ => Vec::new(),
Self::Uncached { .. } => Vec::new(),
}
}

/// The arguments `spawn()` derives from the mode: stdio handling and
/// whether fspy tracking is on.
const fn spawn_config(&self) -> (SpawnStdio, bool) {
match self {
Self::Cached { state, .. } => (SpawnStdio::Piped, state.tracking.is_some()),
Self::Cached { state, .. } => (SpawnStdio::Piped, state.tracking.fspy.is_some()),
Self::Uncached { pipe_writers: Some(_) } => (SpawnStdio::Piped, false),
Self::Uncached { pipe_writers: None } => (SpawnStdio::Inherited, false),
}
Expand All @@ -228,9 +232,9 @@ impl<'a> ExecutionMode<'a> {
stderr_writer: &mut pipe_writers.stderr_writer,
capture: Some(&mut state.std_outputs),
});
let ipc = state.tracking.as_mut().map(|t| IpcHandles {
stop_accepting: &t.stop_accepting,
driver: &mut t.ipc_server_fut,
let ipc = Some(IpcHandles {
stop_accepting: &state.tracking.stop_accepting,
driver: &mut state.tracking.ipc_server_fut,
});
RunHandles { sinks, ipc }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,27 @@ steps = [
"--last-details",
], comment = "summary names the opt-out as the not-cached reason" },
]

[[e2e]]
name = "disable_cache_works_with_explicit_inputs"
comment = """
Exercises `disableCache` on a cached task with explicit inputs. The runner must still inject IPC even when fspy auto-input inference is disabled, or the tool's cache opt-out becomes a no-op and the second run incorrectly hits.
"""
ignore = true
steps = [
{ argv = [
"vt",
"run",
"disable-cache-explicit-input",
], comment = "first run uses input: [] and asks the runner not to cache" },
{ argv = [
"vt",
"run",
"disable-cache-explicit-input",
], comment = "re-executes because the first run was not cached" },
{ argv = [
"vt",
"run",
"--last-details",
], comment = "summary names the opt-out as the not-cached reason" },
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# disable_cache_works_with_explicit_inputs

Exercises `disableCache` on a cached task with explicit inputs. The runner must still inject IPC even when fspy auto-input inference is disabled, or the tool's cache opt-out becomes a no-op and the second run incorrectly hits.

## `vt run disable-cache-explicit-input`

first run uses input: [] and asks the runner not to cache

```
$ node scripts/disable_cache.mjs
```

## `vt run disable-cache-explicit-input`

re-executes because the first run was not cached

```
$ node scripts/disable_cache.mjs
```

## `vt run --last-details`

summary names the opt-out as the not-cached reason

```

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Vite+ Task Runner • Execution Summary
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Statistics: 1 tasks • 0 cache hits • 1 cache misses
Performance: 0% cache hit rate

Task Details:
────────────────────────────────────────────────
[1] ipc-client-test#disable-cache-explicit-input: $ node scripts/disable_cache.mjs ✓
→ Not cached: the task opted out of caching
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
```
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
"disable-cache": {
"command": "node scripts/disable_cache.mjs",
"cache": true
},
"disable-cache-explicit-input": {
"command": "node scripts/disable_cache.mjs",
"input": [],
"cache": true
}
}
}
Loading