Repository navigation
fix(host-core): terminate background task process tree on Windows - #1863
Conversation
vastsa
left a comment
There was a problem hiding this comment.
The issue is real and the process-tree fallback is aligned with the reported race. I can't merge this yet because the new fallback can silently fail while the stop path still reports success; that leaves the exact reported symptom possible. Please propagate unexpected snapshot/open/terminate failures (continuing cleanup where possible) and exercise a multi-level descendant tree before landing. Thanks for taking this on.
| #[cfg(windows)] | ||
| fn terminate_windows_process_tree(root_pid: u32) { | ||
| let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }; | ||
| if snapshot == INVALID_HANDLE_VALUE { |
There was a problem hiding this comment.
If snapshot creation fails, this returns without reporting it. ProcessOwnership::terminate then returns only job_result, so when TerminateJobObject succeeds but escaped descendants remain, the caller can still report the task as stopped. Please return an error from this helper and combine it with the Job result so failure to enumerate escaped children remains observable.
| if err == ERROR_INVALID_PARAMETER || err == ERROR_ACCESS_DENIED { | ||
| continue; | ||
| } | ||
| continue; |
There was a problem hiding this comment.
All unrecognized OpenProcess errors are also silently treated as success here, and the subsequent TerminateProcess result is discarded. A transient or permission failure can leave a descendant alive while stop succeeds. Please ignore only the expected already-exited race, attempt cleanup for the remaining descendants, and return an observable failure if any could not be terminated.
| let pid_file_str = pid_file.to_str().unwrap().replace('\\', "/"); | ||
| // Spawn a child process (cmd.exe running ping / timeout) that writes its PID to a file. | ||
| let command = format!( | ||
| "$f = '{pid_file_str}'; $p = Start-Process powershell -ArgumentList '-NoProfile','-Command','Start-Sleep -Seconds 60' -PassThru; [System.IO.File]::WriteAllText($f, $p.Id); Start-Sleep -Seconds 60" |
There was a problem hiding this comment.
The reported reproduction included several generations of descendants, but this regression only starts and checks one child. Please make the fixture spawn at least one grandchild and assert that the full tree is gone; that verifies the BFS/post-order logic this fix relies on.
e2fa02a to
dd014e8
Compare
|
Updated and rebased onto latest \upstream/main:
|
|
Re-reviewed |
When stopping a background task runner on Windows, child processes spawned by the launcher shell could escape the Job Object if they were spawned before or concurrently with AssignProcessToJobObject. This left descendant commands (such as dev servers or long-running watchers) running in the background after the shell exited. ProcessOwnership::terminate now traverses the descendant process tree using a ToolHelp32 snapshot and explicitly terminates all descendant processes under the runner root PID in reverse hierarchy order, ensuring no orphan processes remain when a background task is stopped. Unexpected snapshot, open, or terminate errors are propagated while continuing cleanup. fixes vastsa#1805
dd014e8 to
ef5291b
Compare
|
Updated commit ef5291b to address the remaining review blockers:
Validated locally with cargo test -p host-core background (9/9 passed, including multi-level tree and access denied regression tests), cargo fmt, and cargo clippy. |
vastsa
left a comment
There was a problem hiding this comment.
Thanks for addressing the previous error-handling finding. I still can’t approve this as a production fix because the new regression exercises a different process path from the shipped background-task path, and the PID-only fallback introduces a process-safety risk.
In production, spawn_tool_runner starts the host executable in hidden-runner mode, assigns that process to the Job Object, and only then writes the configuration frame. The hidden runner starts the requested shell after reading that frame. The #[cfg(test)] path instead spawns the shell directly, so its assign-after-spawn race does not establish that the reported leak happens in production. Please reproduce the failure through the real hidden-runner flow and keep the fix scoped to that demonstrated failure.
Separately, this helper terminates bare PIDs from a system-wide snapshot and always includes root_pid, even if that process is already absent. Since TerminateJobObject runs first and the task process may have exited, a PID can be reused before OpenProcess; this code could then terminate an unrelated process. Please preserve and verify process identity/ownership before terminating snapshot entries, or avoid the PID sweep if the production reproduction does not require it.
| } | ||
|
|
||
| #[cfg(windows)] | ||
| fn terminate_windows_process_tree(root_pid: u32) -> Result<(), String> { |
There was a problem hiding this comment.
The shipped path assigns the hidden runner to the Job Object before sending its shell configuration; the new test bypasses that gate with the #[cfg(test)] direct-shell path. Also, after TerminateJobObject, a bare reused root_pid can identify an unrelated process. Please validate the defect through the production runner and retain stable process identity before terminating this snapshot.
|
CI 的失败点与评审无关,是架构检查: |
Two blockers from review kept this from landing: the PID-only sweep could hit a recycled PID, and the regression spawned the shell directly instead of the runner path that actually ships. Resolve both in a dedicated module. `terminate_tree` opens each snapshot entry with PROCESS_TERMINATE | PROCESS_QUERY_LIMITED_INFORMATION, skips it when the handle's exit code is no longer STILL_ACTIVE, and compares the handle's creation time against a watermark taken right after the snapshot so a process created later — the only kind that can own a recycled PID — is never terminated. Access denied on a live member is reported rather than counted as stopped, and cleanup still continues across the rest of the tree. The regression now drives the production chain through the real background task entry point and asserts the launched command and its own child are both gone. It lives in a sibling module so `background_tasks.rs` stays inside the Rust LOC limit that failed CI.
|
两个阻塞点已在 c579338 处理,并附带 CI 的架构检查修复。 1. PID-only 风险:终止前先证明身份。每个快照条目都用 2. 测试路径:回归改为走真实生产链 3. CI 失败点:架构检查报 本地: |
vastsa
left a comment
There was a problem hiding this comment.
两个阻塞点都已验证修复:终止前用句柄退出码与创建时间 watermark 证明身份(复用 PID 不可能被误杀),活着的成员被拒绝访问按失败上报;回归改走真实 hidden runner → shell → 命令 → 子进程链,断言两层后代都被终止。CI 全绿(含 Windows clippy/构建)。合入。
|
Heads-up: the Windows host-core job fails on Run 40694217868 on
That points at process handling rather than at the visualization fixture: if terminating the background task tree on Windows now takes a lock, walks the process table, or waits on a descendant set that a foreground Bash execution also touches, a short-budget Not claiming it is definitely this change — Windows runners are timing-sensitive — but "passed on the previous head, failed on this one, only this file changed" is the cheapest place to look first. |
|
Following up on my regression note: it did not reproduce, so treat it as a flake rather than a defect from this change.
That is one failure and one pass on Windows, so the evidence no longer supports "this merge broke foreground Bash". The timeout was most likely the Windows runner being slow to complete the Bash execution within the fixture budget — the same class as the I would still keep the question I raised (whether descendant identification runs on every spawn and whether it holds anything a concurrent tool execution needs) as something to confirm by reading, but it should not block this PR or be treated as a confirmed regression. |
Summary
When stopping a background task runner on Windows, child processes spawned by the launcher shell could escape the Job Object if they were spawned before or concurrently with \AssignProcessToJobObject. This caused descendant commands (such as dev servers or long-running watchers) to remain running in the background after the shell exited (issue #1805).
\ProcessOwnership::terminate\ now takes a ToolHelp32 snapshot of the process list and explicitly terminates all descendant processes under the runner root PID in reverse hierarchy order.
Testing
Fixes #1805