Skip to content

Commit a7a0595

Browse files
branchseerclaude
andcommitted
refactor: simplify spawn_with_tracking cancellation flow
Remove the background tokio::spawn for the non-fspy path. Instead, handle cancellation directly in the pipe read loop alongside pipe reads. This eliminates: - The WaitState enum and background task indirection - The cancellation_for_pipes token clone - The need for Send on the Job Object handle The pipe read loop now has a unified cancellation arm (all platforms) that kills the direct child and terminates the Job Object on Windows. The exit condition is checked at the top of the loop to avoid the tokio::select! else-arm issue with the always-pending cancelled() future. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 2d88cfa commit a7a0595

File tree

1 file changed

+90
-107
lines changed
  • crates/vite_task/src/session/execute

1 file changed

+90
-107
lines changed

crates/vite_task/src/session/execute/spawn.rs

Lines changed: 90 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)