@@ -4,22 +4,25 @@ pub mod spawn;
44use std:: { process:: Stdio , sync:: Arc } ;
55
66use futures_util:: FutureExt ;
7+ use tokio:: io:: AsyncWriteExt as _;
78use vite_path:: AbsolutePath ;
8- use vite_task_plan:: { ExecutionGraph , ExecutionItemKind , LeafExecutionKind , SpawnExecution } ;
9+ use vite_task_plan:: {
10+ ExecutionGraph , ExecutionItemKind , LeafExecutionKind , SpawnCommand , SpawnExecution ,
11+ } ;
912
1013use self :: {
1114 fingerprint:: PostRunFingerprint ,
12- spawn:: { OutputKind as SpawnOutputKind , spawn_with_tracking} ,
15+ spawn:: { SpawnResult , spawn_with_tracking} ,
1316} ;
1417use super :: {
1518 cache:: { CommandCacheValue , ExecutionCache } ,
1619 event:: {
1720 CacheDisabledReason , CacheErrorKind , CacheNotUpdatedReason , CacheStatus , CacheUpdateStatus ,
18- ExecutionError , OutputKind ,
21+ ExecutionError ,
1922 } ,
2023 reporter:: {
2124 ExitStatus , GraphExecutionReporter , GraphExecutionReporterBuilder , LeafExecutionPath ,
22- LeafExecutionReporter , StdinSuggestion ,
25+ LeafExecutionReporter , StdioSuggestion ,
2326 } ,
2427} ;
2528use crate :: { Session , session:: execute:: spawn:: SpawnTrackResult } ;
@@ -116,10 +119,13 @@ impl ExecutionContext<'_> {
116119 match leaf_execution_kind {
117120 LeafExecutionKind :: InProcess ( in_process_execution) => {
118121 // In-process (built-in) commands: caching is disabled, execute synchronously
119- leaf_reporter. start ( CacheStatus :: Disabled ( CacheDisabledReason :: InProcessExecution ) ) ;
122+ let mut stdio_config = leaf_reporter
123+ . start ( CacheStatus :: Disabled ( CacheDisabledReason :: InProcessExecution ) ) ;
120124
121125 let execution_output = in_process_execution. execute ( ) ;
122- leaf_reporter. output ( OutputKind :: Stdout , execution_output. stdout . into ( ) ) ;
126+ // Write output to the stdout writer from StdioConfig
127+ let _ = stdio_config. stdout_writer . write_all ( & execution_output. stdout ) . await ;
128+ let _ = stdio_config. stdout_writer . flush ( ) . await ;
123129
124130 leaf_reporter. finish (
125131 None ,
@@ -147,9 +153,10 @@ impl ExecutionContext<'_> {
147153///
148154/// The full lifecycle is:
149155/// 1. Cache lookup (determines cache status)
150- /// 2. `leaf_reporter.start(cache_status)`
151- /// 3. If cache hit: replay cached outputs → finish
152- /// 4. If cache miss/disabled: spawn process → stream output → update cache → finish
156+ /// 2. `leaf_reporter.start(cache_status)` → `StdioConfig`
157+ /// 3. If cache hit: replay cached outputs via `StdioConfig` writers → finish
158+ /// 4. If `Inherited` suggestion AND caching disabled: `spawn_inherited()` → finish
159+ /// 5. Else (piped): `spawn_with_tracking()` with writers → cache update → finish
153160///
154161/// Errors (cache lookup failure, spawn failure, cache update failure) are reported
155162/// through `leaf_reporter.finish()` and do not abort the caller.
@@ -197,20 +204,20 @@ pub async fn execute_spawn(
197204 ( CacheStatus :: Disabled ( CacheDisabledReason :: NoCacheMetadata ) , None )
198205 } ;
199206
200- // 2. Report execution start with the determined cache status
201- leaf_reporter. start ( cache_status) ;
207+ // 2. Report execution start with the determined cache status.
208+ // Returns StdioConfig with the reporter's suggestion and async writers.
209+ let mut stdio_config = leaf_reporter. start ( cache_status) ;
202210
203- // 3. If cache hit, replay outputs and finish early.
211+ // 3. If cache hit, replay outputs via the StdioConfig writers and finish early.
204212 // No need to actually execute the command — just replay what was cached.
205213 if let Some ( cached) = cached_value {
206214 for output in cached. std_outputs . iter ( ) {
207- leaf_reporter. output (
208- match output. kind {
209- SpawnOutputKind :: StdOut => OutputKind :: Stdout ,
210- SpawnOutputKind :: StdErr => OutputKind :: Stderr ,
211- } ,
212- output. content . clone ( ) . into ( ) ,
213- ) ;
215+ let writer: & mut ( dyn tokio:: io:: AsyncWrite + Unpin ) = match output. kind {
216+ spawn:: OutputKind :: StdOut => & mut stdio_config. stdout_writer ,
217+ spawn:: OutputKind :: StdErr => & mut stdio_config. stderr_writer ,
218+ } ;
219+ let _ = writer. write_all ( & output. content ) . await ;
220+ let _ = writer. flush ( ) . await ;
214221 }
215222 leaf_reporter. finish (
216223 None ,
@@ -220,41 +227,53 @@ pub async fn execute_spawn(
220227 return SpawnOutcome :: CacheHit ;
221228 }
222229
223- // 4. Execute spawn (cache miss or disabled).
224- // Track file system access if caching is enabled (for future cache updates).
230+ // 4. Determine actual stdio mode based on the suggestion AND cache state.
231+ // Inherited stdio is only used when the reporter suggests it AND caching is
232+ // completely disabled (no cache_metadata). If caching is enabled but missed,
233+ // we still need piped mode to capture output for the cache update.
234+ let use_inherited =
235+ stdio_config. suggestion == StdioSuggestion :: Inherited && cache_metadata. is_none ( ) ;
236+
237+ if use_inherited {
238+ // Inherited mode: all three stdio FDs (stdin, stdout, stderr) are inherited
239+ // from the parent process. No fspy tracking, no output capture.
240+ // Drop the StdioConfig writers before spawning to avoid holding tokio::io::Stdout
241+ // while the child also writes to the same FD.
242+ drop ( stdio_config) ;
243+
244+ match spawn_inherited ( & spawn_execution. spawn_command ) . await {
245+ Ok ( result) => {
246+ leaf_reporter. finish (
247+ Some ( result. exit_status ) ,
248+ CacheUpdateStatus :: NotUpdated ( CacheNotUpdatedReason :: CacheDisabled ) ,
249+ None ,
250+ ) ;
251+ return SpawnOutcome :: Spawned ( result. exit_status ) ;
252+ }
253+ Err ( err) => {
254+ leaf_reporter. finish (
255+ None ,
256+ CacheUpdateStatus :: NotUpdated ( CacheNotUpdatedReason :: CacheDisabled ) ,
257+ Some ( ExecutionError :: Spawn ( err) ) ,
258+ ) ;
259+ return SpawnOutcome :: Failed ;
260+ }
261+ }
262+ }
263+
264+ // 5. Piped mode: execute spawn with tracking, streaming output to writers.
225265 let mut track_result_with_cache_metadata =
226266 cache_metadata. map ( |cache_metadata| ( SpawnTrackResult :: default ( ) , cache_metadata) ) ;
227267
228- // Determine the child process's stdin mode based on:
229- // - The reporter's suggestion (inherited only when appropriate, e.g., single task)
230- // - Whether caching is disabled (inherited stdin would make output non-deterministic,
231- // breaking cache semantics)
232- let stdin = if leaf_reporter. stdin_suggestion ( ) == StdinSuggestion :: Inherited
233- && cache_metadata. is_none ( )
234- {
235- Stdio :: inherit ( )
236- } else {
237- Stdio :: null ( )
238- } ;
239-
240- // Execute command with tracking, streaming output in real-time via the reporter
241268 #[ expect(
242269 clippy:: large_futures,
243270 reason = "spawn_with_tracking manages process I/O and creates a large future"
244271 ) ]
245272 let result = match spawn_with_tracking (
246273 & spawn_execution. spawn_command ,
247274 cache_base_path,
248- stdin,
249- |kind, content| {
250- leaf_reporter. output (
251- match kind {
252- SpawnOutputKind :: StdOut => OutputKind :: Stdout ,
253- SpawnOutputKind :: StdErr => OutputKind :: Stderr ,
254- } ,
255- content,
256- ) ;
257- } ,
275+ & mut stdio_config. stdout_writer ,
276+ & mut stdio_config. stderr_writer ,
258277 track_result_with_cache_metadata. as_mut ( ) . map ( |( track_result, _) | track_result) ,
259278 )
260279 . await
@@ -270,7 +289,7 @@ pub async fn execute_spawn(
270289 }
271290 } ;
272291
273- // 5 . Update cache if successful and determine cache update status.
292+ // 6 . Update cache if successful and determine cache update status.
274293 // Errors during cache update are terminal (reported through finish).
275294 let ( cache_update_status, cache_error) = if let Some ( ( track_result, cache_metadata) ) =
276295 track_result_with_cache_metadata
@@ -315,14 +334,37 @@ pub async fn execute_spawn(
315334 ( CacheUpdateStatus :: NotUpdated ( CacheNotUpdatedReason :: CacheDisabled ) , None )
316335 } ;
317336
318- // 6 . Finish the leaf execution with the result and optional cache error.
337+ // 7 . Finish the leaf execution with the result and optional cache error.
319338 // Cache update/fingerprint failures are reported but do not affect the outcome —
320339 // the process ran, so we return its actual exit status.
321340 leaf_reporter. finish ( Some ( result. exit_status ) , cache_update_status, cache_error) ;
322341
323342 SpawnOutcome :: Spawned ( result. exit_status )
324343}
325344
345+ /// Spawn a command with all three stdio file descriptors inherited from the parent.
346+ ///
347+ /// Used when the reporter suggests inherited stdio AND caching is disabled.
348+ /// All three FDs (stdin, stdout, stderr) are inherited, allowing interactive input
349+ /// and direct terminal output. No fspy tracking is performed since there's no
350+ /// cache to update.
351+ ///
352+ /// The child process will see `is_terminal() == true` for stdout/stderr when the
353+ /// parent is running in a terminal. This is expected behavior.
354+ async fn spawn_inherited ( spawn_command : & SpawnCommand ) -> anyhow:: Result < SpawnResult > {
355+ let mut cmd = fspy:: Command :: new ( spawn_command. program_path . as_path ( ) ) ;
356+ cmd. args ( spawn_command. args . iter ( ) . map ( vite_str:: Str :: as_str) ) ;
357+ cmd. envs ( spawn_command. all_envs . iter ( ) ) ;
358+ cmd. current_dir ( & * spawn_command. cwd ) ;
359+ cmd. stdin ( Stdio :: inherit ( ) ) . stdout ( Stdio :: inherit ( ) ) . stderr ( Stdio :: inherit ( ) ) ;
360+
361+ let start = std:: time:: Instant :: now ( ) ;
362+ let mut child = cmd. into_tokio_command ( ) . spawn ( ) ?;
363+ let exit_status = child. wait ( ) . await ?;
364+
365+ Ok ( SpawnResult { exit_status, duration : start. elapsed ( ) } )
366+ }
367+
326368impl Session < ' _ > {
327369 /// Execute an execution graph, reporting events through the provided reporter builder.
328370 ///
0 commit comments