Repository navigation
Local browser close on Windows waits 3 s for a taskkill headless Chrome ignores, then sometimes rejects with "taskkill exited with code 255" #3166
Description
Before submitting an issue, please:
- Check the documentation for relevant information
- Search existing issues to avoid duplicates
Environment Information
Stagehand:
- Language/SDK: TypeScript (
@browserbasehq/stagehand) - Stagehand version: 4.2.1 (the same code is on
main,packages/sdk-ts/src/browser/localBrowser.ts, last changed in [fix]: close parity gaps across SDKs for local browser launching #2864)
AI Provider:
- Provider / Model: none (deterministic page API only, no model configured)
Runtime: Windows 11 Pro 23H2, Node 24.14.1, Google Chrome 154.0.8037.98, localBrowser.launch({ headless: true })
Issue Description
On Windows, closing a locally launched headless Chrome always takes about 3.5 s, and it
intermittently rejects with "taskkill exited with code 255" even though Chrome is gone.
closeChromeProcess first runs taskkill /PID <pid> /T without /F. That asks the process tree to close its windows, and headless Chrome has no top-level window, so nothing happens. waitForExit(monitor, CHROME_TERMINATION_TIMEOUT_MS) then waits the full 3 s before taskkill /PID <pid> /T /F ends Chrome. Every browser.close() pays the 3 s.
The forced taskkill /T sometimes exits with code 255 while Chrome's process tree is already going away. isFinishedProcessError forgives only ESRCH and 128, so browser.close() rejects. In our E2E suite (27 files, one browser per file) this showed up as an occasional teardown failure of a file whose tests all passed.
Steps to Reproduce
- On Windows, launch Chrome with
localBrowser.launch({ headless: true })and create a Stagehand on it. - Call
stagehand.close()and thenbrowser.close(), and timebrowser.close(). - Compare with asking Chrome to exit over DevTools (
Browser.close) beforebrowser.close().
Minimal Reproduction Code
import { mkdtemp, rm } from "node:fs/promises";
import { createServer } from "node:net";
import { tmpdir } from "node:os";
import path from "node:path";
import { localBrowser, Stagehand } from "@browserbasehq/stagehand";
async function freePort(): Promise<number> {
const server = createServer();
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const { port } = server.address() as { port: number };
await new Promise<void>((resolve) => server.close(() => resolve()));
return port;
}
async function cdpBrowserClose(port: number): Promise<void> {
const { webSocketDebuggerUrl } = (await (await fetch(`http://127.0.0.1:${port}/json/version`)).json()) as {
webSocketDebuggerUrl: string;
};
const socket = new WebSocket(webSocketDebuggerUrl);
await new Promise<void>((resolve) => {
socket.addEventListener("open", () => socket.send(JSON.stringify({ id: 1, method: "Browser.close" })));
socket.addEventListener("close", () => resolve());
socket.addEventListener("error", () => resolve()); // Chrome drops the socket as it exits
});
}
for (const cdpFirst of [false, true]) {
const userDataDir = await mkdtemp(path.join(tmpdir(), "repro-"));
const port = await freePort();
const browser = await localBrowser.launch({ headless: true, userDataDir, port });
const stagehand = await Stagehand.create({ browser, logging: { level: "off" } });
await stagehand.close();
const started = performance.now();
if (cdpFirst) await cdpBrowserClose(port);
await browser.close();
console.log(`${cdpFirst ? "Browser.close first" : "default close"}: ${((performance.now() - started) / 1000).toFixed(2)}s`);
await rm(userDataDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
}Error Messages / Log trace
Timings on the machine above, four runs each:
default close: browser.close 3.47 s, 3.49 s, 3.48 s, 3.47 s
Browser.close first: browser.close 0.25 s, 0.28 s, 0.24 s, 0.26 s (no taskkill runs)
The intermittent failure:
Error: taskkill exited with code 255
❯ ChildProcess.<anonymous> node_modules/@browserbasehq/stagehand/src/browser/localBrowser.ts:606:25
Proposed fix
- Before any
taskkill, ask Chrome to exit over the DevTools connection the launcher already owns (Browser.closeon the browser target), thenwaitForExitbriefly. Chrome exits in about 0.3 s and the monitor sees the exit, so notaskkillis needed. - On Windows, skip the non-forced
taskkill /Tfor headless Chrome, since it cannot take effect, and go straight to/T /Fwhen step 1 did not end the process. - When a
taskkillfails, checkmonitor.currentExit()(or wait for the exit briefly) before rejecting, so a process tree that finished on its own does not turn intotaskkill exited with code 255.
Our workaround, in our E2E helper: pass an explicit port to localBrowser.launch, then before browser.close() send Browser.close over /json/version's webSocketDebuggerUrl and wait until the port refuses connections. Per-file browser cost went from 4.3 s to 0.9 s.
Happy to send a PR for this if the approach fits.
Related Issues
- Related to: [fix]: close parity gaps across SDKs for local browser launching #2864 (local browser launch parity)
Activity