@@ -57,6 +57,15 @@ pub struct TrackedPathAccesses {
5757 pub path_writes : FxHashSet < RelativePathBuf > ,
5858}
5959
60+ /// How the child process is awaited after stdout/stderr are drained.
61+ enum ChildWait {
62+ /// fspy tracking enabled — fspy manages cancellation internally.
63+ Fspy ( fspy:: TrackedChild ) ,
64+
65+ /// Plain tokio process — cancellation is handled in the pipe read loop.
66+ Tokio ( tokio:: process:: Child ) ,
67+ }
68+
6069/// Spawn a command with optional file system tracking via fspy, using piped stdio.
6170///
6271/// Returns the execution result including exit status and duration.
@@ -85,19 +94,6 @@ pub async fn spawn_with_tracking(
8594 resolved_negatives : & [ wax:: Glob < ' static > ] ,
8695 cancellation_token : CancellationToken ,
8796) -> anyhow:: Result < SpawnResult > {
88- /// How the child process is awaited after stdout/stderr are drained.
89- ///
90- /// Both variants run a background task that monitors the cancellation token
91- /// and kills the child when cancelled. The read loop needs no cancellation
92- /// branch — killing the child closes its pipes, which makes reads return EOF.
93- enum WaitState {
94- /// fspy tracking enabled — background task managed by fspy.
95- FspyEnabled ( fspy:: TrackedChild ) ,
96-
97- /// Plain tokio process — we spawn our own cancellation-aware background task.
98- TokioChild ( tokio:: task:: JoinHandle < std:: io:: Result < ExitStatus > > ) ,
99- }
100-
10197 let mut cmd = fspy:: Command :: new ( spawn_command. program_path . as_path ( ) ) ;
10298 cmd. args ( spawn_command. args . iter ( ) . map ( vite_str:: Str :: as_str) ) ;
10399 cmd. envs ( spawn_command. all_envs . iter ( ) ) ;
@@ -106,18 +102,13 @@ pub async fn spawn_with_tracking(
106102
107103 // On Windows, assign the child to a Job Object so that killing the child also
108104 // kills all descendant processes (e.g., node.exe spawned by a .cmd shim).
109- // Declared before the branch so it outlives both the fspy and non-fspy paths.
110105 #[ cfg( windows) ]
111106 let job;
112107
113- // Clone the token before it's moved into the spawn branches. The clone is used
114- // in the pipe read loop on Windows to terminate the job (killing grandchild
115- // processes that hold pipes open).
116- let cancellation_for_pipes = cancellation_token. clone ( ) ;
117-
118- let ( mut child_stdout, mut child_stderr, wait_state) = if path_accesses. is_some ( ) {
119- // fspy tracking enabled — fspy's background task handles cancellation
120- let mut tracked_child = cmd. spawn ( cancellation_token) . await ?;
108+ let ( mut child_stdout, mut child_stderr, mut child_wait) = if path_accesses. is_some ( ) {
109+ // fspy tracking enabled — fspy manages cancellation internally via a clone
110+ // of the token. We keep the original for the pipe read loop.
111+ let mut tracked_child = cmd. spawn ( cancellation_token. clone ( ) ) . await ?;
121112 let stdout = tracked_child. stdout . take ( ) . unwrap ( ) ;
122113 let stderr = tracked_child. stderr . take ( ) . unwrap ( ) ;
123114 #[ cfg( windows) ]
@@ -127,9 +118,8 @@ pub async fn spawn_with_tracking(
127118 tracked_child. process_handle . as_raw_handle ( ) ,
128119 ) ?;
129120 }
130- ( stdout, stderr, WaitState :: FspyEnabled ( tracked_child) )
121+ ( stdout, stderr, ChildWait :: Fspy ( tracked_child) )
131122 } else {
132- // No fspy — spawn a background task that waits for exit or cancellation
133123 let mut child = cmd. into_tokio_command ( ) . spawn ( ) ?;
134124 let stdout = child. stdout . take ( ) . unwrap ( ) ;
135125 let stderr = child. stderr . take ( ) . unwrap ( ) ;
@@ -141,16 +131,7 @@ pub async fn spawn_with_tracking(
141131 let owned = borrowed. try_clone_to_owned ( ) ?;
142132 job = super :: win_job:: assign_to_kill_on_close_job ( owned. as_raw_handle ( ) ) ?;
143133 }
144- let wait_handle = tokio:: spawn ( async move {
145- tokio:: select! {
146- status = child. wait( ) => status,
147- ( ) = cancellation_token. cancelled( ) => {
148- child. start_kill( ) ?;
149- child. wait( ) . await
150- }
151- }
152- } ) ;
153- ( stdout, stderr, WaitState :: TokioChild ( wait_handle) )
134+ ( stdout, stderr, ChildWait :: Tokio ( child) )
154135 } ;
155136
156137 // Output capturing is independent of fspy tracking
@@ -163,13 +144,8 @@ pub async fn spawn_with_tracking(
163144 let start = Instant :: now ( ) ;
164145
165146 // Read from both stdout and stderr concurrently using select!
166- // On cancellation, the background task kills the direct child, but on Windows
167- // grandchild processes may keep pipes open. The cancellation branch terminates
168- // the entire job to close all pipe writers.
169- //
170- // The exit condition is checked at the top of the loop (not via `else =>`),
171- // because the `cancelled()` arm stays pending even when pipes are done,
172- // which would prevent `else` from ever firing.
147+ // Cancellation is handled directly in the loop: kill the child process (and
148+ // on Windows, terminate the Job Object to kill grandchildren holding pipes).
173149 loop {
174150 if stdout_done && stderr_done {
175151 break ;
@@ -217,93 +193,100 @@ pub async fn spawn_with_tracking(
217193 }
218194 }
219195 }
220- // On Windows, kill the entire process tree so that grandchild processes
221- // release their pipe handles, allowing the reads above to reach EOF.
222- ( ) = cancellation_for_pipes. cancelled( ) , if cfg!( windows) => {
196+ ( ) = cancellation_token. cancelled( ) => {
197+ // Kill the direct child (no-op for fspy which handles it internally).
198+ if let ChildWait :: Tokio ( ref mut child) = child_wait {
199+ let _ = child. start_kill( ) ;
200+ }
201+ // On Windows, terminate the entire process tree so grandchild
202+ // processes release their pipe handles.
223203 #[ cfg( windows) ]
224204 job. terminate( ) ;
225205 break ;
226206 }
227207 }
228208 }
229209
230- // Wait for process termination. Both variants' background tasks handle
231- // cancellation internally, so these awaits need no additional select.
232- let ( termination, path_accesses) = match wait_state {
233- WaitState :: FspyEnabled ( tracked_child) => {
210+ // Wait for process termination and collect results.
211+ match child_wait {
212+ ChildWait :: Fspy ( tracked_child) => {
234213 let termination = tracked_child. wait_handle . await ?;
214+ let duration = start. elapsed ( ) ;
215+
235216 // path_accesses must be Some when fspy is enabled (they're set together)
236217 let path_accesses = path_accesses. ok_or_else ( || {
237218 anyhow:: anyhow!( "internal error: fspy enabled but path_accesses is None" )
238219 } ) ?;
239- ( termination, path_accesses)
240- }
241- WaitState :: TokioChild ( wait_handle) => {
242- let exit_status = wait_handle. await . map_err ( |err| anyhow:: anyhow!( err) ) ??;
243- return Ok ( SpawnResult { exit_status, duration : start. elapsed ( ) } ) ;
244- }
245- } ;
246- let duration = start. elapsed ( ) ;
247- let path_reads = & mut path_accesses. path_reads ;
248- let path_writes = & mut path_accesses. path_writes ;
220+ let path_reads = & mut path_accesses. path_reads ;
221+ let path_writes = & mut path_accesses. path_writes ;
249222
250- for access in termination. path_accesses . iter ( ) {
251- // Strip workspace root, clean `..` components, and filter in one pass.
252- // fspy may report paths like `packages/sub-pkg/../shared/dist/output.js`.
253- let relative_path = access. path . strip_path_prefix ( workspace_root, |strip_result| {
254- let Ok ( stripped_path) = strip_result else {
255- return None ;
256- } ;
257- // On Windows, paths are possible to be still absolute after stripping the workspace root.
258- // For example: c:\workspace\subdir\c:\workspace\subdir
259- // Just ignore those accesses.
260- let relative = RelativePathBuf :: new ( stripped_path) . ok ( ) ?;
223+ for access in termination. path_accesses . iter ( ) {
224+ // Strip workspace root, clean `..` components, and filter in one pass.
225+ // fspy may report paths like `packages/sub-pkg/../shared/dist/output.js`.
226+ let relative_path = access. path . strip_path_prefix ( workspace_root, |strip_result| {
227+ let Ok ( stripped_path) = strip_result else {
228+ return None ;
229+ } ;
230+ // On Windows, paths are possible to be still absolute after stripping the workspace root.
231+ // For example: c:\workspace\subdir\c:\workspace\subdir
232+ // Just ignore those accesses.
233+ let relative = RelativePathBuf :: new ( stripped_path) . ok ( ) ?;
261234
262- // Clean `..` components — fspy may report paths like
263- // `packages/sub-pkg/../shared/dist/output.js`. Normalize them for
264- // consistent behavior across platforms and clean user-facing messages.
265- let relative = relative. clean ( ) ;
235+ // Clean `..` components — fspy may report paths like
236+ // `packages/sub-pkg/../shared/dist/output.js`. Normalize them for
237+ // consistent behavior across platforms and clean user-facing messages.
238+ let relative = relative. clean ( ) ;
266239
267- // Skip .git directory accesses (workaround for tools like oxlint)
268- if relative. as_path ( ) . strip_prefix ( ".git" ) . is_ok ( ) {
269- return None ;
270- }
240+ // Skip .git directory accesses (workaround for tools like oxlint)
241+ if relative. as_path ( ) . strip_prefix ( ".git" ) . is_ok ( ) {
242+ return None ;
243+ }
271244
272- if !resolved_negatives. is_empty ( )
273- && resolved_negatives. iter ( ) . any ( |neg| neg. is_match ( relative. as_str ( ) ) )
274- {
275- return None ;
276- }
245+ if !resolved_negatives. is_empty ( )
246+ && resolved_negatives. iter ( ) . any ( |neg| neg. is_match ( relative. as_str ( ) ) )
247+ {
248+ return None ;
249+ }
277250
278- Some ( relative)
279- } ) ;
251+ Some ( relative)
252+ } ) ;
280253
281- let Some ( relative_path) = relative_path else {
282- continue ;
283- } ;
254+ let Some ( relative_path) = relative_path else {
255+ continue ;
256+ } ;
284257
285- if access. mode . contains ( AccessMode :: READ ) {
286- path_reads. entry ( relative_path. clone ( ) ) . or_insert ( PathRead { read_dir_entries : false } ) ;
287- }
288- if access. mode . contains ( AccessMode :: WRITE ) {
289- path_writes. insert ( relative_path. clone ( ) ) ;
290- }
291- if access. mode . contains ( AccessMode :: READ_DIR ) {
292- match path_reads. entry ( relative_path) {
293- Entry :: Occupied ( mut occupied) => occupied. get_mut ( ) . read_dir_entries = true ,
294- Entry :: Vacant ( vacant) => {
295- vacant. insert ( PathRead { read_dir_entries : true } ) ;
258+ if access. mode . contains ( AccessMode :: READ ) {
259+ path_reads
260+ . entry ( relative_path. clone ( ) )
261+ . or_insert ( PathRead { read_dir_entries : false } ) ;
262+ }
263+ if access. mode . contains ( AccessMode :: WRITE ) {
264+ path_writes. insert ( relative_path. clone ( ) ) ;
265+ }
266+ if access. mode . contains ( AccessMode :: READ_DIR ) {
267+ match path_reads. entry ( relative_path) {
268+ Entry :: Occupied ( mut occupied) => {
269+ occupied. get_mut ( ) . read_dir_entries = true ;
270+ }
271+ Entry :: Vacant ( vacant) => {
272+ vacant. insert ( PathRead { read_dir_entries : true } ) ;
273+ }
274+ }
296275 }
297276 }
298- }
299- }
300277
301- tracing:: debug!(
302- "spawn finished, path_reads: {}, path_writes: {}, exit_status: {}" ,
303- path_reads. len( ) ,
304- path_writes. len( ) ,
305- termination. status,
306- ) ;
278+ tracing:: debug!(
279+ "spawn finished, path_reads: {}, path_writes: {}, exit_status: {}" ,
280+ path_reads. len( ) ,
281+ path_writes. len( ) ,
282+ termination. status,
283+ ) ;
307284
308- Ok ( SpawnResult { exit_status : termination. status , duration } )
285+ Ok ( SpawnResult { exit_status : termination. status , duration } )
286+ }
287+ ChildWait :: Tokio ( mut child) => {
288+ let exit_status = child. wait ( ) . await ?;
289+ Ok ( SpawnResult { exit_status, duration : start. elapsed ( ) } )
290+ }
291+ }
309292}
0 commit comments