From 27d35ee8c4fd2022af52c212d4c158ef035876b9 Mon Sep 17 00:00:00 2001 From: branchseer Date: Wed, 14 Jan 2026 00:58:04 +0800 Subject: [PATCH 1/3] feat(session): add CacheUpdateStatus to ExecutionEventKind::Finish Add CacheUpdateStatus enum to track whether cache was updated after task execution and why. This provides visibility into cache behavior in the Finish event. - Add CacheNotUpdatedReason enum with CacheHit, CacheDisabled, NonZeroExitStatus - Add CacheUpdateStatus enum with Updated and NotUpdated variants - Include cache_update_status in ExecutionEventKind::Finish - Update all Finish event emissions to include appropriate status Co-Authored-By: Claude Opus 4.5 From de0f2ca5d5d755bb5602416116bdf87c8b9374aa Mon Sep 17 00:00:00 2001 From: branchseer Date: Wed, 14 Jan 2026 01:23:28 +0800 Subject: [PATCH 2/3] feat(cache): disable cache if there was user input # Conflicts: # crates/vite_task_bin/tests/e2e_snapshots/fixtures/exit-codes/snapshots/multiple task failures returns exit code 1.snap # crates/vite_task_bin/tests/e2e_snapshots/fixtures/exit-codes/snapshots/single task failure returns task exit code.snap --- crates/vite_task/src/session/cache/display.rs | 30 +++++++++++++- crates/vite_task/src/session/cache/mod.rs | 4 +- crates/vite_task/src/session/event.rs | 2 + crates/vite_task/src/session/execute/mod.rs | 4 +- crates/vite_task/src/session/reporter.rs | 32 ++++++++++++--- .../fixtures/cache-non-zero-exit/package.json | 6 +++ .../cache-non-zero-exit/snapshots.toml | 6 +++ .../failed task does not update cache.snap | 40 +++++++++++++++++++ ...ple task failures returns exit code 1.snap | 2 + ...e task failure returns task exit code.snap | 1 + 10 files changed, 117 insertions(+), 10 deletions(-) create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/cache-non-zero-exit/package.json create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/cache-non-zero-exit/snapshots.toml create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/cache-non-zero-exit/snapshots/failed task does not update cache.snap diff --git a/crates/vite_task/src/session/cache/display.rs b/crates/vite_task/src/session/cache/display.rs index c194f934..4eea7d5f 100644 --- a/crates/vite_task/src/session/cache/display.rs +++ b/crates/vite_task/src/session/cache/display.rs @@ -8,7 +8,9 @@ use std::collections::HashSet; use vite_task_plan::cache_metadata::SpawnFingerprint; use super::{CacheMiss, FingerprintMismatch}; -use crate::session::event::{CacheDisabledReason, CacheStatus}; +use crate::session::event::{ + CacheDisabledReason, CacheNotUpdatedReason, CacheStatus, CacheUpdateStatus, +}; /// Describes a single atomic change between two spawn fingerprints enum SpawnFingerprintChange { @@ -283,3 +285,29 @@ pub fn format_cache_status_summary(cache_status: &CacheStatus) -> String { } } } + +/// Format cache update status for summary display (post-execution). +/// +/// Returns Some(formatted_string) only when the reason is not already clear from CacheStatus. +/// - Updated: No message needed (success is implied) +/// - CacheHit: No message needed (already shown in CacheStatus::Hit) +/// - CacheDisabled: No message needed (already shown in CacheStatus::Disabled) +/// - BuiltInCommand: No message needed (already shown in CacheStatus::Disabled(InProcessExecution)) +/// - NonZeroExitStatus: Shows message that cache wasn't updated due to failure +/// +/// Note: Returns plain text without styling. The reporter applies colors. +pub fn format_cache_update_status(status: &CacheUpdateStatus) -> Option { + match status { + CacheUpdateStatus::Updated => None, + CacheUpdateStatus::NotUpdated(reason) => match reason { + // These are already clear from CacheStatus in the Start event + CacheNotUpdatedReason::CacheHit => None, + CacheNotUpdatedReason::CacheDisabled => None, + CacheNotUpdatedReason::BuiltInCommand => None, + // This needs to be shown - task failed so cache wasn't updated + CacheNotUpdatedReason::NonZeroExitStatus => { + Some("→ Cache not updated: task failed".to_string()) + } + }, + } +} diff --git a/crates/vite_task/src/session/cache/mod.rs b/crates/vite_task/src/session/cache/mod.rs index d43a01db..ed9d3915 100644 --- a/crates/vite_task/src/session/cache/mod.rs +++ b/crates/vite_task/src/session/cache/mod.rs @@ -6,7 +6,9 @@ use std::{fmt::Display, fs::File, io::Write, sync::Arc, time::Duration}; use bincode::{Decode, Encode, decode_from_slice, encode_to_vec}; // Re-export display functions for convenience -pub use display::{format_cache_status_inline, format_cache_status_summary}; +pub use display::{ + format_cache_status_inline, format_cache_status_summary, format_cache_update_status, +}; use rusqlite::{Connection, OptionalExtension as _, config::DbConfig}; use serde::{Deserialize, Serialize}; use tokio::sync::Mutex; diff --git a/crates/vite_task/src/session/event.rs b/crates/vite_task/src/session/event.rs index 85d07584..f8644da4 100644 --- a/crates/vite_task/src/session/event.rs +++ b/crates/vite_task/src/session/event.rs @@ -27,6 +27,8 @@ pub enum CacheNotUpdatedReason { CacheDisabled, /// Execution exited with non-zero status NonZeroExitStatus, + /// Built-in command doesn't support caching + BuiltInCommand, } #[derive(Debug)] diff --git a/crates/vite_task/src/session/execute/mod.rs b/crates/vite_task/src/session/execute/mod.rs index b62c01e4..56611d8b 100644 --- a/crates/vite_task/src/session/execute/mod.rs +++ b/crates/vite_task/src/session/execute/mod.rs @@ -153,13 +153,13 @@ impl ExecutionContext<'_> { }, }); - // Emit Finish with CacheDisabled status (in-process executions don't cache) + // Emit Finish with BuiltInCommand status (built-in commands don't cache) self.event_handler.handle_event(ExecutionEvent { execution_id, kind: ExecutionEventKind::Finish { status: Some(0), cache_update_status: CacheUpdateStatus::NotUpdated( - CacheNotUpdatedReason::CacheDisabled, + CacheNotUpdatedReason::BuiltInCommand, ), }, }); diff --git a/crates/vite_task/src/session/reporter.rs b/crates/vite_task/src/session/reporter.rs index ef8be7d7..408c311f 100644 --- a/crates/vite_task/src/session/reporter.rs +++ b/crates/vite_task/src/session/reporter.rs @@ -11,8 +11,11 @@ use owo_colors::{Style, Styled}; use vite_path::AbsolutePath; use super::{ - cache::{format_cache_status_inline, format_cache_status_summary}, - event::{CacheStatus, ExecutionEvent, ExecutionEventKind, ExecutionId, ExecutionItemDisplay}, + cache::{format_cache_status_inline, format_cache_status_summary, format_cache_update_status}, + event::{ + CacheStatus, CacheUpdateStatus, ExecutionEvent, ExecutionEventKind, ExecutionId, + ExecutionItemDisplay, + }, }; /// Wrap of `OwoColorize` that ignores style if `NO_COLOR` is set. @@ -55,6 +58,7 @@ const CACHE_MISS_STYLE: Style = Style::new().purple(); struct ExecutionInfo { display: Option, cache_status: CacheStatus, // Non-optional, determined at Start + cache_update_status: Option, // Set at Finish exit_status: Option, error_message: Option, } @@ -157,6 +161,7 @@ impl LabeledReporter { self.executions.push(ExecutionInfo { display: None, cache_status, + cache_update_status: None, exit_status: None, error_message: None, }); @@ -199,6 +204,7 @@ impl LabeledReporter { self.executions.push(ExecutionInfo { display: Some(display), cache_status, + cache_update_status: None, exit_status: None, error_message: None, }); @@ -226,7 +232,12 @@ impl LabeledReporter { self.stats.failed += 1; } - fn handle_finish(&mut self, execution_id: ExecutionId, status: Option) { + fn handle_finish( + &mut self, + execution_id: ExecutionId, + status: Option, + cache_update_status: CacheUpdateStatus, + ) { // Update failure statistics if let Some(s) = status { if s != 0 { @@ -234,9 +245,10 @@ impl LabeledReporter { } } - // Update execution info exit status + // Update execution info if let Some(exec) = self.executions.last_mut() { exec.exit_status = status; + exec.cache_update_status = Some(cache_update_status); } // For direct synthetic execution with cache hit, print message at the bottom @@ -430,6 +442,14 @@ impl LabeledReporter { }; let _ = writeln!(self.writer, " {}", styled_summary); + // Cache update status (only shown for NonZeroExitStatus) + if let Some(ref cache_update_status) = exec.cache_update_status { + if let Some(update_msg) = format_cache_update_status(cache_update_status) { + let _ = + writeln!(self.writer, " {}", update_msg.style(Style::new().yellow())); + } + } + // Error message if present if let Some(ref error_msg) = exec.error_message { let _ = writeln!( @@ -486,8 +506,8 @@ impl Reporter for LabeledReporter { ExecutionEventKind::Error { message } => { self.handle_error(event.execution_id, message); } - ExecutionEventKind::Finish { status, cache_update_status: _ } => { - self.handle_finish(event.execution_id, status); + ExecutionEventKind::Finish { status, cache_update_status } => { + self.handle_finish(event.execution_id, status, cache_update_status); } } } diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/cache-non-zero-exit/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/cache-non-zero-exit/package.json new file mode 100644 index 00000000..8bfea287 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/cache-non-zero-exit/package.json @@ -0,0 +1,6 @@ +{ + "name": "cache-non-zero-exit-test", + "scripts": { + "test": "node -e \"process.exit(1)\"" + } +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/cache-non-zero-exit/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/cache-non-zero-exit/snapshots.toml new file mode 100644 index 00000000..844b5823 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/cache-non-zero-exit/snapshots.toml @@ -0,0 +1,6 @@ +[[e2e]] +name = "failed task does not update cache" +steps = [ + "vite run test", + "vite run test", +] diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/cache-non-zero-exit/snapshots/failed task does not update cache.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/cache-non-zero-exit/snapshots/failed task does not update cache.snap new file mode 100644 index 00000000..4243e3cc --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/cache-non-zero-exit/snapshots/failed task does not update cache.snap @@ -0,0 +1,40 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +input_file: crates/vite_task_bin/tests/e2e_snapshots/fixtures/cache-non-zero-exit +--- +[1]> vite run test +$ node -e "process.exit(1)" + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Vite+ Task Runner • Execution Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Statistics: 1 tasks • 0 cache hits • 1 cache misses • 1 failed +Performance: 0% cache hit rate + +Task Details: +──────────────────────────────────────────────── + [1] cache-non-zero-exit-test#test: $ node -e "process.exit(1)" ✗ (exit code: 1) + → Cache miss: no previous cache entry found + → Cache not updated: task failed +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +[1]> vite run test +$ node -e "process.exit(1)" + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Vite+ Task Runner • Execution Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Statistics: 1 tasks • 0 cache hits • 1 cache misses • 1 failed +Performance: 0% cache hit rate + +Task Details: +──────────────────────────────────────────────── + [1] cache-non-zero-exit-test#test: $ node -e "process.exit(1)" ✗ (exit code: 1) + → Cache miss: no previous cache entry found + → Cache not updated: task failed +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/exit-codes/snapshots/multiple task failures returns exit code 1.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/exit-codes/snapshots/multiple task failures returns exit code 1.snap index 27126371..d111a2c6 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/exit-codes/snapshots/multiple task failures returns exit code 1.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/exit-codes/snapshots/multiple task failures returns exit code 1.snap @@ -21,7 +21,9 @@ Task Details: ──────────────────────────────────────────────── [1] pkg-b#fail: ~/packages/pkg-b$ node -e "process.exit(7)" ✗ (exit code: 7) → Cache miss: no previous cache entry found + → Cache not updated: task failed ······················································· [2] pkg-a#fail: ~/packages/pkg-a$ node -e "process.exit(42)" ✗ (exit code: 42) → Cache miss: no previous cache entry found + → Cache not updated: task failed ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/exit-codes/snapshots/single task failure returns task exit code.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/exit-codes/snapshots/single task failure returns task exit code.snap index 97dde56a..cfd5239e 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/exit-codes/snapshots/single task failure returns task exit code.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/exit-codes/snapshots/single task failure returns task exit code.snap @@ -19,4 +19,5 @@ Task Details: ──────────────────────────────────────────────── [1] pkg-a#fail: ~/packages/pkg-a$ node -e "process.exit(42)" ✗ (exit code: 42) → Cache miss: no previous cache entry found + → Cache not updated: task failed ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ From 26d24bf968c830bab7bef431e8db01a9adb0afe9 Mon Sep 17 00:00:00 2001 From: branchseer Date: Thu, 15 Jan 2026 10:06:33 +0800 Subject: [PATCH 3/3] feat(cache): detect stdin data and disable cache update - Forward stdin from parent to child process concurrently with output draining - Track whether stdin had data during execution - Disable cache update when stdin had data (output may depend on input) - Add CacheNotUpdatedReason::StdinDataExists variant - Add e2e test for stdin passthrough functionality This ensures tasks that read from stdin don't get incorrectly cached, since their output may depend on the input data which we don't fingerprint. Co-Authored-By: Claude Sonnet 4.5 --- crates/vite_task/src/session/cache/display.rs | 5 +- crates/vite_task/src/session/event.rs | 2 + crates/vite_task/src/session/execute/mod.rs | 14 +- crates/vite_task/src/session/execute/spawn.rs | 297 +++++++++++++----- .../stdin passthrough to single task.snap | 1 + 5 files changed, 236 insertions(+), 83 deletions(-) diff --git a/crates/vite_task/src/session/cache/display.rs b/crates/vite_task/src/session/cache/display.rs index 4eea7d5f..4e6a910d 100644 --- a/crates/vite_task/src/session/cache/display.rs +++ b/crates/vite_task/src/session/cache/display.rs @@ -304,10 +304,13 @@ pub fn format_cache_update_status(status: &CacheUpdateStatus) -> Option CacheNotUpdatedReason::CacheHit => None, CacheNotUpdatedReason::CacheDisabled => None, CacheNotUpdatedReason::BuiltInCommand => None, - // This needs to be shown - task failed so cache wasn't updated + // These need to be shown - they explain why cache wasn't updated CacheNotUpdatedReason::NonZeroExitStatus => { Some("→ Cache not updated: task failed".to_string()) } + CacheNotUpdatedReason::StdinDataExists => { + Some("→ Cache not updated: stdin had data".to_string()) + } }, } } diff --git a/crates/vite_task/src/session/event.rs b/crates/vite_task/src/session/event.rs index f8644da4..0f8b581e 100644 --- a/crates/vite_task/src/session/event.rs +++ b/crates/vite_task/src/session/event.rs @@ -29,6 +29,8 @@ pub enum CacheNotUpdatedReason { NonZeroExitStatus, /// Built-in command doesn't support caching BuiltInCommand, + /// Stdin had data - output may depend on input, unsafe to cache + StdinDataExists, } #[derive(Debug)] diff --git a/crates/vite_task/src/session/execute/mod.rs b/crates/vite_task/src/session/execute/mod.rs index 56611d8b..354f1541 100644 --- a/crates/vite_task/src/session/execute/mod.rs +++ b/crates/vite_task/src/session/execute/mod.rs @@ -287,11 +287,18 @@ impl ExecutionContext<'_> { }; // 5. Update cache if successful and determine cache update status + // Priority: NonZeroExitStatus > StdinDataExists > Success let cache_update_status = if let Some((track_result, cache_metadata)) = track_result_with_cache_metadata { - if result.exit_status.success() { - // Execution succeeded, attempt cache update + if !result.exit_status.success() { + // Priority 1: Non-zero exit status - don't update cache + CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::NonZeroExitStatus) + } else if result.stdin_had_data { + // Priority 2: Stdin had data - output may depend on input, unsafe to cache + CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::StdinDataExists) + } else { + // Success: attempt cache update let fingerprint_ignores = cache_metadata.spawn_fingerprint.fingerprint_ignores().map(|v| v.as_slice()); match PostRunFingerprint::create( @@ -326,9 +333,6 @@ impl ExecutionContext<'_> { return Err(ExecutionAborted); } } - } else { - // Execution failed with non-zero exit status, don't update cache - CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::NonZeroExitStatus) } } else { // Caching was disabled for this task diff --git a/crates/vite_task/src/session/execute/spawn.rs b/crates/vite_task/src/session/execute/spawn.rs index 6cf24019..61876d19 100644 --- a/crates/vite_task/src/session/execute/spawn.rs +++ b/crates/vite_task/src/session/execute/spawn.rs @@ -10,7 +10,7 @@ use bincode::{Decode, Encode}; use bstr::BString; use fspy::AccessMode; use serde::Serialize; -use tokio::io::AsyncReadExt as _; +use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; use vite_path::{AbsolutePath, RelativePathBuf}; use vite_task_plan::SpawnCommand; @@ -45,6 +45,8 @@ pub struct StdOutput { pub struct SpawnResult { pub exit_status: ExitStatus, pub duration: Duration, + /// Whether stdin had data forwarded to the child + pub stdin_had_data: bool, } /// Tracking result from a spawned process for caching @@ -65,8 +67,35 @@ pub struct SpawnTrackResult { /// Returns the execution result including captured outputs, exit status, /// and tracked file accesses. /// -/// - `on_output` is called in real-time as stdout/stderr data arrives. -/// - `track_result` if provided, will be populated with captured outputs and path accesses for caching. If `None`, tracking is disabled. +/// # Arguments +/// - `spawn_command`: The command to spawn with its arguments, environment, and working directory. +/// - `workspace_root`: Base path for converting absolute paths to relative paths in tracking. +/// - `on_output`: Callback invoked in real-time as stdout/stderr data arrives. +/// - `track_result`: If provided, will be populated with captured outputs and path accesses +/// for caching. If `None`, tracking is disabled and the command runs without fspy overhead. +/// +/// # Concurrent I/O Architecture +/// +/// This function manages three concurrent I/O operations: +/// 1. **drain_outputs**: Reads stdout and stderr until both reach EOF +/// 2. **forward_stdin**: Forwards parent's stdin to child until EOF +/// 3. **wait_for_exit**: Waits for child process to terminate +/// +/// Each operation is a separate future with its own internal loop. This design avoids +/// a single large `select!` loop with many condition flags, making the code easier to +/// understand and maintain. +/// +/// # Deadlock Avoidance +/// +/// All three operations run concurrently. This is critical for commands that depend on +/// stdin, like `node -e "process.stdin.pipe(process.stdout)"`. If we waited for stdout +/// to close before forwarding stdin, we would deadlock because stdout won't close until +/// stdin closes. +/// +/// # Cancellation +/// +/// When the child exits, `forward_stdin` is implicitly cancelled by breaking out of +/// the coordination loop. This is safe because stdin data after child exit is meaningless. pub async fn spawn_with_tracking( spawn_command: &SpawnCommand, workspace_root: &AbsolutePath, @@ -80,126 +109,240 @@ where cmd.args(spawn_command.args.iter().map(|arg| arg.as_str())); cmd.envs(spawn_command.all_envs.iter()); cmd.current_dir(&*spawn_command.cwd); - cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); + cmd.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()); - /// The tracking state of the spawned process - enum TrackingState<'a> { - /// Tacking is enabled, with the tracked child and result reference - Enabled(fspy::TrackedChild, &'a mut SpawnTrackResult), - - /// Tracking is disabled, with the tokio child process - Disabled(tokio::process::Child), + // Spawn with or without tracking based on track_result. + // + // WaitHandle is separated from track_result to avoid borrow checker issues: + // - track_result needs to be borrowed mutably after the spawn + // - wait_handle needs to be moved into a future + // By keeping them separate, we can move wait_handle while retaining track_result. + enum WaitHandle { + /// Tracked spawn via fspy - returns termination info with file access data + Tracked(futures_util::future::BoxFuture<'static, std::io::Result>), + /// Untracked spawn via tokio - just returns exit status + Untracked(tokio::process::Child), } - let mut tracking_state = if let Some(track_result) = track_result { - // track_result is Some. Spawn with tracking enabled - TrackingState::Enabled(cmd.spawn().await?, track_result) + let ( + mut child_stdout, + mut child_stderr, + child_stdin, + mut wait_handle, + track_result, + track_enabled, + ) = if let Some(track_result) = track_result { + let mut tracked = cmd.spawn().await?; + let stdout = tracked.stdout.take().unwrap(); + let stderr = tracked.stderr.take().unwrap(); + let stdin = tracked.stdin.take(); + (stdout, stderr, stdin, WaitHandle::Tracked(tracked.wait_handle), Some(track_result), true) } else { - // Spawn without tracking - TrackingState::Disabled(cmd.into_tokio_command().spawn()?) + let mut child = cmd.into_tokio_command().spawn()?; + let stdout = child.stdout.take().unwrap(); + let stderr = child.stderr.take().unwrap(); + let stdin = child.stdin.take(); + (stdout, stderr, stdin, WaitHandle::Untracked(child), None, false) }; - let mut child_stdout = match &mut tracking_state { - TrackingState::Enabled(tracked_child, _) => tracked_child.stdout.take().unwrap(), - TrackingState::Disabled(tokio_child) => tokio_child.stdout.take().unwrap(), - }; - let mut child_stderr = match &mut tracking_state { - TrackingState::Enabled(tracked_child, _) => tracked_child.stderr.take().unwrap(), - TrackingState::Disabled(tokio_child) => tokio_child.stderr.take().unwrap(), - }; + // Local buffer for captured outputs. This is separate from track_result to allow + // the drain_outputs future to own a mutable reference without conflicting with + // track_result's lifetime. + let mut std_outputs: Vec = Vec::new(); + let start = Instant::now(); + + // Future that drains both stdout and stderr until EOF. + // + // Uses an internal select! loop to read from whichever stream has data available. + // Consecutive outputs of the same kind are merged into a single StdOutput entry + // to reduce storage overhead when caching. + let drain_outputs = async { + let mut stdout_buf = [0u8; 8192]; + let mut stderr_buf = [0u8; 8192]; + let mut stdout_done = false; + let mut stderr_done = false; - let mut outputs = match &mut tracking_state { - TrackingState::Enabled(_, track_result) => Some(&mut track_result.std_outputs), - TrackingState::Disabled(_) => None, + while !stdout_done || !stderr_done { + tokio::select! { + result = child_stdout.read(&mut stdout_buf), if !stdout_done => { + match result? { + 0 => stdout_done = true, + n => { + let content = stdout_buf[..n].to_vec(); + on_output(OutputKind::StdOut, content.clone().into()); + if track_enabled { + if let Some(last) = std_outputs.last_mut() + && last.kind == OutputKind::StdOut + { + last.content.extend(&content); + } else { + std_outputs.push(StdOutput { kind: OutputKind::StdOut, content }); + } + } + } + } + } + result = child_stderr.read(&mut stderr_buf), if !stderr_done => { + match result? { + 0 => stderr_done = true, + n => { + let content = stderr_buf[..n].to_vec(); + on_output(OutputKind::StdErr, content.clone().into()); + if track_enabled { + if let Some(last) = std_outputs.last_mut() + && last.kind == OutputKind::StdErr + { + last.content.extend(&content); + } else { + std_outputs.push(StdOutput { kind: OutputKind::StdErr, content }); + } + } + } + } + } + } + } + Ok::<_, std::io::Error>(()) }; - let mut stdout_buf = [0u8; 8192]; - let mut stderr_buf = [0u8; 8192]; - let mut stdout_done = false; - let mut stderr_done = false; - let start = Instant::now(); + // Future that forwards stdin from parent to child until EOF. + // + // This runs concurrently with drain_outputs to avoid deadlock: some commands + // (like `cat` or `node -e "process.stdin.pipe(process.stdout)"`) won't produce + // output until they receive input, so we must forward stdin while draining outputs. + // + // When parent stdin reaches EOF, we drop the child stdin handle to signal EOF + // to the child process. + // + // Returns whether any data was forwarded - this is used to disable caching since + // the output may depend on the input data which we don't fingerprint. + let forward_stdin = async { + let mut buf = [0u8; 8192]; + let mut stdin_had_data = false; + let mut parent_stdin = tokio::io::stdin(); + let mut child_stdin = child_stdin; - // Helper closure to process output chunks - let mut process_output = |kind: OutputKind, content: Vec| { - // Emit event immediately - on_output(kind, content.clone().into()); - - // Store outputs for caching - if let Some(outputs) = &mut outputs { - // Merge consecutive outputs of the same kind for caching - if let Some(last) = outputs.last_mut() - && last.kind == kind - { - last.content.extend(&content); - } else { - outputs.push(StdOutput { kind, content }); + loop { + match parent_stdin.read(&mut buf).await? { + 0 => { + // EOF on parent stdin - close child stdin to signal EOF + drop(child_stdin.take()); + break Ok::<_, std::io::Error>(stdin_had_data); + } + n => { + stdin_had_data = true; + if let Some(ref mut stdin) = child_stdin { + stdin.write_all(&buf[..n]).await?; + } + } } } }; - // Read from both stdout and stderr concurrently using select! + // Future that waits for child to exit. + // Returns Some(ChildTermination) for tracked spawns, None for untracked. + let wait_for_exit = async { + match &mut wait_handle { + WaitHandle::Tracked(wait) => wait.await.map(Some), + WaitHandle::Untracked(child) => child.wait().await.map(|_| None), + } + }; + + // Pin all futures for use in the coordination loop. + // We can't use join!/select! macros directly because: + // 1. drain_outputs must complete before wait_for_exit (child won't exit until we drain pipes) + // 2. forward_stdin should be cancelled when child exits (not waited on) + // 3. We need to track intermediate state (stdin_result) + futures_util::pin_mut!(drain_outputs, forward_stdin, wait_for_exit); + + // State flags for the coordination loop + let mut drain_done = false; + let mut stdin_result: Option> = None; + let termination: Option; + + // Coordination loop: orchestrates the three concurrent operations. + // + // The select! conditions ensure correct ordering: + // - drain_outputs runs unconditionally until done + // - forward_stdin runs until it completes or child exits (whichever first) + // - wait_for_exit only runs after drain_outputs is done (we must drain pipes first) + // + // When wait_for_exit completes, we break out of the loop. This implicitly cancels + // any pending stdin read, which is safe and intentional. loop { tokio::select! { - result = child_stdout.read(&mut stdout_buf), if !stdout_done => { - match result? { - 0 => stdout_done = true, - n => process_output(OutputKind::StdOut, stdout_buf[..n].to_vec()), - } + // Drain stdout/stderr - must complete before we can wait for exit + result = &mut drain_outputs, if !drain_done => { + result?; + drain_done = true; } - result = child_stderr.read(&mut stderr_buf), if !stderr_done => { - match result? { - 0 => stderr_done = true, - n => process_output(OutputKind::StdErr, stderr_buf[..n].to_vec()), - } + // Forward stdin - record result but don't block child exit + result = &mut forward_stdin, if stdin_result.is_none() => { + stdin_result = Some(result); + } + // Wait for child exit - only after drain is done + result = &mut wait_for_exit, if drain_done => { + termination = result?; + break; } - else => break, } } - let (termination, track_result) = match tracking_state { - TrackingState::Enabled(tracked_child, track_result) => { - (tracked_child.wait_handle.await?, track_result) - } - TrackingState::Disabled(mut tokio_child) => { - return Ok(SpawnResult { - exit_status: tokio_child.wait().await?, - duration: start.elapsed(), - }); - } - }; let duration = start.elapsed(); - // Process path accesses + // Extract stdin_had_data from the result. If stdin read was cancelled (None) or + // errored, we conservatively assume no data was forwarded. + let stdin_had_data = stdin_result.map(|r| r.unwrap_or(false)).unwrap_or(false); + + // Get exit status from termination info + let exit_status = match &termination { + Some(term) => term.status, + None => ExitStatus::default(), // Untracked path - status already consumed + }; + + // If tracking was disabled, return early without processing file accesses + let Some(track_result) = track_result else { + return Ok(SpawnResult { exit_status, duration, stdin_had_data }); + }; + + // Copy captured outputs from local buffer to track_result for caching + track_result.std_outputs = std_outputs; + + // Process path accesses from fspy tracking. + // These are used to build the post-run fingerprint for cache invalidation. + let termination = termination.expect("termination should be Some when tracking is enabled"); let path_reads = &mut track_result.path_reads; let path_writes = &mut track_result.path_writes; for access in termination.path_accesses.iter() { + // Convert absolute paths to workspace-relative paths. + // Paths outside the workspace are ignored (e.g., system libraries). let relative_path = access.path.strip_path_prefix(workspace_root, |strip_result| { let Ok(stripped_path) = strip_result else { return None; }; - // On Windows, paths are possible to be still absolute after stripping the workspace root. - // For example: c:\workspace\subdir\c:\workspace\subdir - // Just ignore those accesses. RelativePathBuf::new(stripped_path).ok() }); let Some(relative_path) = relative_path else { - // Ignore accesses outside the workspace continue; }; - // Skip .git directory accesses (workaround for tools like oxlint) + // Skip .git directory - these are internal git operations that shouldn't + // affect cache fingerprinting (e.g., reading HEAD, refs). if relative_path.as_path().strip_prefix(".git").is_ok() { continue; } + // Track read accesses for fingerprinting input files if access.mode.contains(AccessMode::READ) { path_reads.entry(relative_path.clone()).or_insert(PathRead { read_dir_entries: false }); } + // Track write accesses (for future use - output fingerprinting) if access.mode.contains(AccessMode::WRITE) { path_writes.insert(relative_path.clone(), PathWrite); } + // Track directory reads (e.g., readdir) which may affect cache validity if access.mode.contains(AccessMode::READ_DIR) { match path_reads.entry(relative_path) { Entry::Occupied(mut occupied) => occupied.get_mut().read_dir_entries = true, @@ -214,8 +357,8 @@ where "spawn finished, path_reads: {}, path_writes: {}, exit_status: {}", path_reads.len(), path_writes.len(), - termination.status, + exit_status, ); - Ok(SpawnResult { exit_status: termination.status, duration }) + Ok(SpawnResult { exit_status, duration, stdin_had_data }) } diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdin-passthrough/snapshots/stdin passthrough to single task.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdin-passthrough/snapshots/stdin passthrough to single task.snap index e7355cdb..628ab1c2 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdin-passthrough/snapshots/stdin passthrough to single task.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdin-passthrough/snapshots/stdin passthrough to single task.snap @@ -19,4 +19,5 @@ Task Details: ──────────────────────────────────────────────── [1] stdin-passthrough#echo-stdin: $ node -e "process.stdin.pipe(process.stdout)" ✓ → Cache miss: no previous cache entry found + → Cache not updated: stdin had data ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━