Skip to content

fix(cli): a Windows browser close ends the whole Chrome tree while its root is alive - #6789

Closed
miguel-heygen wants to merge 2 commits into
mainfrom
fix/windows-browser-teardown
Closed

miguel-heygen wants to merge 2 commits into
mainfrom
fix/windows-browser-teardown

Conversation

@miguel-heygen

Copy link
Copy Markdown
Collaborator

What

On Windows, when the CLI has to force a browser closed (check, snapshot, render), it now ends Chrome's whole process tree while the root process is still alive, instead of only killing the root.

Why

After a force close, child.kill("SIGKILL") on Windows ends only the root. Chrome's renderer, GPU and utility processes are not ended with it and Windows does not reparent them, so taskkill /T can no longer find them once the root is gone. They kept the browser executable open, and a later run that touched the install failed with EPERM ... chrome-headless-shell.exe. The only other cleanup is a detached PowerShell sweep that is not awaited.

Related work

#6771 and #6776 cover deleting a leftover browser folder that Windows still holds; they do not stop the processes. This removes the cause.

How

closedWithoutKill calls terminateWindowsProcessTree(pid) (existing taskkill /T /F, output ignored) before child.kill, only on Windows and only when the graceful close did not finish. A failed or already-gone taskkill is ignored, and the root kill still follows.

Test plan

  • Unit tests added/updated
  • Manual testing performed
  • Documentation updated (if applicable)
  • Comments follow CONTRIBUTING.md "Comments"

fails without the fix: with the old child.kill("SIGKILL") restored, on Windows ends the browser's whole tree while its root still lives, since children outlive a killed root fails with expected "vi.fn()" to be called with arguments.

What I measured

launch.boundedClose.test.ts and launch.test.ts: 4 passed on three runs on Linux; oxlint and oxfmt clean. The new test sets the platform to win32 and checks the tree kill runs before the root is killed.

What I did NOT execute

A real Windows machine. The cause is reasoned from the code and the reported errors, not reproduced there. The snapshot message process <pid> child of <pid> could not be terminated was not traced to a source in this repo. The EPERM in the Desktop app's own copy of the browser is Desktop's code, not this repo's.

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: REQUEST CHANGES · 1 blocker · 1 important · 0 nits · head f4ac1f16 (the blocker is unchanged at a9de586c)

fails without the fix: line verified locally: restoring child.kill("SIGKILL") fails the new test. ↓ inline

Scope

Windows render verification run 41001527004 (engine-cli) at f4ac1f16: launch.boundedClose.test.ts > kills a browser that acknowledged close but never exits… fails with Cannot read properties of undefined (reading 'catch'). I reproduced it on Linux by setting the platform to win32 in that test. a9de586c adds only launch.windowsTree.browser.test.ts, so the same failure remains on the required Tests on windows-latest.

import { afterEach, expect, it, vi } from "vitest";
import { launchManagedBrowser } from "./launch.js";

const treeKill = vi.hoisted(() => vi.fn<(pid: number) => Promise<void>>());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocker — On Windows CI (process.platform really is win32), the first test goes through the force path too. There treeKill is a bare vi.fn() that returns undefined, so terminateWindowsProcessTree(pid).catch throws a TypeError, close() rejects, and the required Tests on windows-latest engine-cli lane goes red (run 41001527004). Give the mock a default resolved implementation, and re-apply it after mockReset:

Suggested change
const treeKill = vi.hoisted(() => vi.fn<(pid: number) => Promise<void>>());
const treeKill = vi.hoisted(() => vi.fn<(pid: number) => Promise<void>>(async () => {}));

Then use treeKill.mockReset().mockImplementation(async () => {}) in afterEach, or mockClear().


async function killBrowser(child: ChildProcess): Promise<void> {
if (process.platform === "win32" && child.pid !== undefined) {
await terminateWindowsProcessTree(child.pid).catch(() => undefined);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

important — This await has no bound. If taskkill stalls (AV scan, a hung process under load), close() never returns, and that undoes the bounded-close guarantee closedWithoutKill exists for (grace has already fired, and exitLimit only starts after this). Race it against CLOSE_GRACE_MS so the root SIGKILL always follows.

@miguel-heygen

Copy link
Copy Markdown
Collaborator Author

Closing: the hypothesis (a forced close leaves Chrome children alive on Windows) was not shown on a real Windows runner. With only the real-Chrome test and no fix, the test passes (run 41002532567). A repro for the hard-killed-CLI case will come as a separate PR.

@github-actions

Copy link
Copy Markdown
Contributor

Edit accuracy: accurate 271 (base branch 271), smooth 145 of those

The gate passes.
Smoothness is reported in the artifact, not gated. A case fails only if it fails 2 of 3 runs.

Quarantined, measured but not gated (0)

@miguel-heygen
miguel-heygen deleted the fix/windows-browser-teardown branch October 30, 2026 05:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant