Skip to content
Open
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

- **Added** `replayLogs: false` for cached tasks that should restore outputs and report cache hits without replaying cached stdout/stderr.
- **Fixed** Missing env vars requested through `@voidzero-dev/vite-task-client` now return `undefined` instead of `null`, preserving Vite production `NODE_ENV` semantics when builds run through `vp run` ([#508](https://github.com/voidzero-dev/vite-task/pull/508)).
- **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)).
Expand Down
10 changes: 7 additions & 3 deletions crates/vite_task/src/session/cache/display.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,9 +149,13 @@ fn format_env_changed_inline(names: &[&Str]) -> Str {
/// Note: Returns plain text without styling. The reporter applies colors.
pub fn format_cache_status_inline(cache_status: &CacheStatus) -> Option<Str> {
match cache_status {
CacheStatus::Hit { .. } => {
// Show "cache hit" indicator when replaying from cache
Some(Str::from("◉ cache hit, replaying"))
CacheStatus::Hit { logs_replayed, .. } => {
if *logs_replayed {
// Show "cache hit" indicator when replaying from cache
Some(Str::from("◉ cache hit, replaying"))
} else {
Some(Str::from("◉ cache hit"))
}
}
CacheStatus::Miss(CacheMiss::NotFound) => {
// No inline message for "not found" case - just show command
Expand Down
2 changes: 1 addition & 1 deletion crates/vite_task/src/session/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ pub enum CacheUpdateStatus {
pub enum CacheStatus {
Disabled(CacheDisabledReason),
Miss(CacheMiss),
Hit { replayed_duration: Duration },
Hit { replayed_duration: Duration, logs_replayed: bool },
}

/// Convert `ExitStatus` to an i32 exit code.
Expand Down
25 changes: 16 additions & 9 deletions crates/vite_task/src/session/execute/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -385,14 +385,18 @@ async fn run(
// to execute the command — or carry the globbed inputs into the run.
let (stdio_config, globbed_inputs) = match lookup {
CacheLookup::Hit(cached) => {
let mut stdio_config =
reporter.start(CacheStatus::Hit { replayed_duration: cached.duration });
let replay_logs = cache_metadata.map_or(true, |metadata| metadata.replay_logs);
let mut stdio_config = reporter.start(CacheStatus::Hit {
replayed_duration: cached.duration,
logs_replayed: replay_logs,
});
return Ok(replay_cache_hit(
&mut stdio_config,
&cached,
workspace_root,
cache_dir,
program_name,
replay_logs,
));
}
CacheLookup::Miss { miss, globbed_inputs } => {
Expand Down Expand Up @@ -547,14 +551,17 @@ fn replay_cache_hit(
workspace_root: &Arc<AbsolutePath>,
cache_dir: &AbsolutePath,
program_name: &str,
replay_logs: bool,
) -> Report {
for output in cached.std_outputs.iter() {
let writer: &mut dyn std::io::Write = match output.kind {
pipe::OutputKind::StdOut => &mut stdio_config.writers.stdout_writer,
pipe::OutputKind::StdErr => &mut stdio_config.writers.stderr_writer,
};
let _ = writer.write_all(&output.content);
let _ = writer.flush();
if replay_logs {
for output in cached.std_outputs.iter() {
let writer: &mut dyn std::io::Write = match output.kind {
pipe::OutputKind::StdOut => &mut stdio_config.writers.stdout_writer,
pipe::OutputKind::StdErr => &mut stdio_config.writers.stderr_writer,
};
let _ = writer.write_all(&output.content);
let _ = writer.flush();
}
}

// Restore output files from the cached archive. Failure here means the
Expand Down
29 changes: 21 additions & 8 deletions crates/vite_task/src/session/reporter/summary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,12 @@ pub struct TaskSummary {
/// making invalid combinations unrepresentable.
#[derive(Serialize, Deserialize)]
pub enum TaskResult {
/// Cache hit — output was replayed from cache. Always successful.
CacheHit { saved_duration_ms: u64 },
/// Cache hit. Always successful.
CacheHit {
saved_duration_ms: u64,
#[serde(default = "default_logs_replayed")]
logs_replayed: bool,
},

/// In-process execution (built-in command like echo). Always successful.
InProcess,
Expand Down Expand Up @@ -92,6 +96,10 @@ pub enum SpawnedCacheStatus {
Disabled,
}

const fn default_logs_replayed() -> bool {
true
}

/// Outcome of a spawned process.
#[derive(Serialize, Deserialize)]
pub enum SpawnOutcome {
Expand Down Expand Up @@ -191,7 +199,7 @@ impl SummaryStats {

for task in tasks {
match &task.result {
TaskResult::CacheHit { saved_duration_ms } => {
TaskResult::CacheHit { saved_duration_ms, .. } => {
stats.cache_hits += 1;
stats.total_saved += Duration::from_millis(*saved_duration_ms);
}
Expand Down Expand Up @@ -333,9 +341,10 @@ impl TaskResult {
);

match cache_status {
CacheStatus::Hit { replayed_duration } => {
Self::CacheHit { saved_duration_ms: duration_to_ms(*replayed_duration) }
}
CacheStatus::Hit { replayed_duration, logs_replayed } => Self::CacheHit {
saved_duration_ms: duration_to_ms(*replayed_duration),
logs_replayed: *logs_replayed,
},
CacheStatus::Disabled(CacheDisabledReason::InProcessExecution) => Self::InProcess,
CacheStatus::Disabled(CacheDisabledReason::NoCacheMetadata) => Self::Spawned {
cache_status: SpawnedCacheStatus::Disabled,
Expand Down Expand Up @@ -553,10 +562,14 @@ impl TaskResult {
}

match self {
Self::CacheHit { saved_duration_ms } => {
Self::CacheHit { saved_duration_ms, logs_replayed } => {
let d = Duration::from_millis(*saved_duration_ms);
let formatted_duration = format_summary_duration(d);
vite_str::format!("→ Cache hit - output replayed - {formatted_duration} saved")
if *logs_replayed {
vite_str::format!("→ Cache hit - output replayed - {formatted_duration} saved")
} else {
vite_str::format!("→ Cache hit - logs skipped - {formatted_duration} saved")
}
}
Self::InProcess => Str::from("→ Cache disabled for built-in command"),
Self::Spawned { cache_status, .. } => match cache_status {
Expand Down
1 change: 1 addition & 0 deletions crates/vite_task_bin/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ impl vite_task::CommandHandler for CommandHandler {
program,
args: args.into_iter().filter(|a| a.as_str() != "--").collect(),
cache_config: UserCacheConfig::with_config(EnabledCacheConfig {
replay_logs: None,
env: None,
untracked_env: None,
input: None,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"name": "replay-logs-test"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
[[e2e]]
name = "cache_hit_skips_log_replay_but_restores_outputs"
comment = """
`replayLogs: false` should keep cache hits and output restoration, while suppressing cached stdout/stderr replay.
"""
steps = [
{ argv = [
"vt",
"run",
"build",
], comment = "first run — cache miss, prints logs and writes dist/output.txt" },
{ argv = [
"vtt",
"rm",
"-rf",
"dist",
], comment = "delete dist/ to prove cache-hit output restoration still happens" },
{ argv = [
"vt",
"run",
"build",
], comment = "second run — cache hit, skips cached log replay" },
{ argv = [
"vtt",
"print-file",
"dist/output.txt",
], comment = "output file restored from cache" },
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# cache_hit_skips_log_replay_but_restores_outputs

`replayLogs: false` should keep cache hits and output restoration, while suppressing cached stdout/stderr replay.

## `vt run build`

first run — cache miss, prints logs and writes dist/output.txt

```
$ vtt print fresh logs; vtt write-file dist/output.txt artifact
fresh logs
```

## `vtt rm -rf dist`

delete dist/ to prove cache-hit output restoration still happens

```
```

## `vt run build`

second run — cache hit, skips cached log replay

```
$ vtt print fresh logs; vtt write-file dist/output.txt artifact ◉ cache hit

---
vt run: cache hit.
```

## `vtt print-file dist/output.txt`

output file restored from cache

```
artifact
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"tasks": {
"build": {
"command": "vtt print fresh logs; vtt write-file dist/output.txt artifact",
"cache": true,
"replayLogs": false,
"input": [],
"output": ["dist/**"]
}
}
}
4 changes: 4 additions & 0 deletions crates/vite_task_graph/run-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ dependsOn?: Array<DependsOnEntry>, } & ({
* Whether to cache the task
*/
cache?: true,
/**
* Whether to replay cached stdout/stderr on cache hits.
*/
replayLogs?: boolean,
/**
* Environment variable names to be fingerprinted and passed to the task.
*/
Expand Down
8 changes: 8 additions & 0 deletions crates/vite_task_graph/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ impl ResolvedTaskOptions {
let cache_config = match user_options.cache_config {
UserCacheConfig::Disabled { cache: MustBe!(false) } => None,
UserCacheConfig::Enabled { cache: _, enabled_cache_config } => {
let replay_logs = enabled_cache_config.replay_logs.unwrap_or(true);
let mut untracked_env: FxHashSet<Str> =
enabled_cache_config.untracked_env.unwrap_or_default().into_iter().collect();
untracked_env.extend(DEFAULT_UNTRACKED_ENV.iter().copied().map(Str::from));
Expand All @@ -83,6 +84,7 @@ impl ResolvedTaskOptions {
)?;

Some(CacheConfig {
replay_logs,
env_config: EnvConfig {
fingerprinted_envs: enabled_cache_config
.env
Expand All @@ -105,11 +107,17 @@ impl ResolvedTaskOptions {
reason = "env_config, input_config, output_config are distinct config categories, not a naming smell"
)]
pub struct CacheConfig {
#[serde(skip_serializing_if = "is_true")]
pub replay_logs: bool,
pub env_config: EnvConfig,
pub input_config: ResolvedGlobConfig,
pub output_config: ResolvedGlobConfig,
}

const fn is_true(value: &bool) -> bool {
*value
}

/// Resolved input configuration for cache fingerprinting.
///
/// This is the normalized form after parsing user config.
Expand Down
26 changes: 26 additions & 0 deletions crates/vite_task_graph/src/config/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,9 @@ impl UserCacheConfig {
#[cfg_attr(all(test, not(clippy)), derive(TS), ts(optional_fields))]
#[serde(rename_all = "camelCase")]
pub struct EnabledCacheConfig {
/// Whether to replay cached stdout/stderr on cache hits.
pub replay_logs: Option<bool>,

/// Environment variable names to be fingerprinted and passed to the task.
pub env: Option<Box<[Str]>>,

Expand Down Expand Up @@ -260,6 +263,7 @@ impl Default for UserTaskOptions {
cache_config: UserCacheConfig::Enabled {
cache: None,
enabled_cache_config: EnabledCacheConfig {
replay_logs: None,
env: None,
untracked_env: None,
input: None,
Expand Down Expand Up @@ -702,6 +706,7 @@ mod tests {
UserCacheConfig::Enabled {
cache: Some(MustBe!(true)),
enabled_cache_config: EnabledCacheConfig {
replay_logs: None,
env: Some(std::iter::once("NODE_ENV".into()).collect()),
untracked_env: Some(std::iter::once("FOO".into()).collect()),
input: None,
Expand All @@ -711,6 +716,27 @@ mod tests {
);
}

#[test]
fn test_replay_logs_can_be_disabled() {
let user_config_json = json!({
"cache": true,
"replayLogs": false,
});
assert_eq!(
serde_json::from_value::<UserCacheConfig>(user_config_json).unwrap(),
UserCacheConfig::Enabled {
cache: Some(MustBe!(true)),
enabled_cache_config: EnabledCacheConfig {
replay_logs: Some(false),
env: None,
untracked_env: None,
input: None,
output: None,
}
},
);
}

#[test]
fn test_input_empty_array() {
let user_config_json = json!({
Expand Down
8 changes: 8 additions & 0 deletions crates/vite_task_plan/src/cache_metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ pub struct CacheMetadata {
/// Used at execution time to determine what output files to archive.
pub output_config: ResolvedGlobConfig,

/// Whether cached stdout/stderr should be replayed on cache hits.
#[serde(skip_serializing_if = "is_true")]
pub replay_logs: bool,

/// The unfiltered env context for runner-aware APIs. This is the planning
/// context's envs before spawn-env filtering, including command prefix envs
/// from this command and enclosing nested `vp run` expansions.
Expand All @@ -66,6 +70,10 @@ pub struct CacheMetadata {
pub unfiltered_envs: Arc<FxHashMap<Arc<OsStr>, Arc<OsStr>>>,
}

const fn is_true(value: &bool) -> bool {
*value
}

/// Fingerprint for spawn execution that affects caching.
///
/// # Environment Variable Impact on Cache
Expand Down
Loading