Skip to content

Local browser close on Windows waits 3 s for a taskkill headless Chrome ignores, then sometimes rejects with "taskkill exited with code 255" #3166

Open

Description

@mir3626

Before submitting an issue, please:

Environment Information

Stagehand:

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

  1. On Windows, launch Chrome with localBrowser.launch({ headless: true }) and create a Stagehand on it.
  2. Call stagehand.close() and then browser.close(), and time browser.close().
  3. Compare with asking Chrome to exit over DevTools (Browser.close) before browser.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

  1. Before any taskkill, ask Chrome to exit over the DevTools connection the launcher already owns (Browser.close on the browser target), then waitForExit briefly. Chrome exits in about 0.3 s and the monitor sees the exit, so no taskkill is needed.
  2. On Windows, skip the non-forced taskkill /T for headless Chrome, since it cannot take effect, and go straight to /T /F when step 1 did not end the process.
  3. When a taskkill fails, check monitor.currentExit() (or wait for the exit briefly) before rejecting, so a process tree that finished on its own does not turn into taskkill 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions