fix(browser): close automation children on exit - #2762
Conversation
Coverage Gate❌ Test suite failed under instrumentation — coverage not measurable. See the job log. |
b4f83c8 to
7a4704c
Compare
|
Cold review at Two findings, both in the new 1. The documented 45s bound can be exceeded by up to 30s.
if (!context && launchPromise) {
const launchedContext = await launchPromise.catch(() => null);That If the intent is a real command-level bound, the launch await needs its own deadline derived from the remaining budget rather than a fresh 30s. 2. After the first signal, SIGINT and SIGTERM are swallowed until cleanup finishes. The handler is: const handler = () => {
if (receivedSignal) return;
...
};and handlers are detached on the last line of Worth deciding explicitly: either let the second signal Nit. Not gating on any of this — no approval from me either way, since my |
|
@cla-assistant check |
bassilkhilo-ag2
left a comment
There was a problem hiding this comment.
Verified in an isolated worktree:
python3 tests/browser-persistent.test.py -vat HEAD: 7/7 pass (1 real-Chrome smoke test skipped — playwright not installed on this host, expected).- Reverted only
src/browser.mjstoorigin/mainwhile keeping the PR's new tests: 6 failures + 1 error, including the exact ones the PR body cites ('page.close' not found, signal handling not reaching cleanup, the--timeout=cap not being enforced) — confirms both the defect and that the new tests actually pin the fix rather than passing vacuously. npx eslint src/browser.mjs: clean.- Read the change: catch-block
process.exit(1)bypassingfinallywas the root cause of the leak; the fix routes errors, the 45s overall timeout, and SIGINT/SIGTERM all through the same cleanup path, and closes page/context/browser independently and idempotently — including the late-launch-after-cancellation case, which is the one that's easy to miss (a persistent context that finishes launching after the command already decided to exit).
Well-scoped, evidence matches the diff, before/after reproduced independently. Approving.
|
Cold review at head The defect is real at the merge-base. The exit sits inside the catch, one line above the The new tests fail in the broken state — which is the part worth knowing. I reverted only Seven of eight fail on the old source. That is the assertion actually failing in the broken state, so these are regressions rather than tests that would pass either way. Restored the file afterwards;
One nit, not a blocker — the real-Chrome case does not run anywhere automated. So the "retained real Chrome smoke test" skips on this host and in CI, and the deterministic fake-Playwright fixtures are carrying the entire suite. That is a reasonable trade — they clearly work, per the control above — but worth one line in the PR body, otherwise a green matrix later reads as "real browser verified" when nothing exercised a real browser. Same shape as the live-cadence point on #109: a gate that runs only on your machine should say so. What I did not verify: I did not send a real SIGINT/SIGTERM to a live Chrome process tree, and I did not test the 45s deadline against a genuinely hanging real browser — the fixtures simulate both, so my evidence covers the logic and not the OS-level teardown. Also No formal approval from me — approvals and merges here are the owner's call. |
What changed, and why
src/browser.mjscalledprocess.exit(1)from inside its catch block, which bypassedfinally; it also had no SIGINT/SIGTERM handlers or whole-command timeout. A failed or interrupted browser command could therefore leave its Chrome process tree alive.This change:
--timeout=<ms>override;Review first
src/browser.mjs— bounded lifecycle and cleanup orderingtests/browser-persistent.test.py— subprocess-level exit/cleanup assertionstests/fixtures/fake-playwright.mjs— deterministic hanging/error/late-launch fixtureUser behavior / bug proved
Browser QA can fail, time out, or be interrupted without leaving owner-owned headless Chrome roots behind. Successful commands retain the existing text/action/screenshot behavior.
Documentation consistency
docs/built-in-tools.mddocuments the default limit, override, and cleanup contract.docs/catalog.jsonadvances that canonical document'slast_verifieddate to 2026-08-09. Navigation is unchanged, sodocs/README.mdneeds no change.Before / after evidence
Parent implementation
deaaaf50with the initial lifecycle regressions applied:HEAD
aa6d04c7d4475dbf216caf682915754da441dd64:The late-launch regression proves cleanup completes inside the original command deadline. Additional regressions prove an over-cap timeout is rejected and a second signal retains Node's normal immediate-termination behavior.
Review follow-up
Addressed Sonichi's
7a4704c4cold-review findings inaa6d04c7:--timeoutvalues above 300,000 ms rather than silently clamping them.Real managed-browser lifecycle proof
Using the exact HEAD wrapper, a fresh temporary profile per run, and an outer Python subprocess timeout:
The error case used a missing selector after Chrome launched; the timeout case interrupted a pending wait; the signal cases interrupted a 30-second page wait. Every audit was scoped to the current macOS UID. No private files or another user's processes were inspected or terminated.
Tests and checks
python3 tests/browser-persistent.test.py— 8 passed (includes real Chrome smoke)npx eslint src/browser.mjs tests/fixtures/*.mjs— passedpython3 -m py_compile tests/browser-persistent.test.py— passedpython3 -m json.tool docs/catalog.json— passedgit diff --check— passedEdge cases checked
Worst-case disruption and mitigation
The new default timeout could end an unusually long non-interactive action sequence at 45 seconds. Callers that intentionally need longer can use
--timeout=<ms>up to five minutes; over-cap values are rejected instead of silently clamped. Interactivesetupintentionally has no overall timeout because the user must finish sign-in and close the visible window; signals and errors still use the same cleanup path.Stack, migration, config, permissions, rollback
Not stacked. No migration, durable config, new permission, or cross-user process handling. The timeout flag is additive; existing commands receive a finite default. Rollback is a single commit revert.