From b4c1aa7b49e3408b036a79a136c31a7e63986cf4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 19:43:16 +0900 Subject: [PATCH 01/30] test(browser): require forced-close failure teardown evidence --- ...rced_close_process_termination_contract.py | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/tests/test_agent_task_forced_close_process_termination_contract.py b/tests/test_agent_task_forced_close_process_termination_contract.py index 319227b98..c2b2d93b4 100644 --- a/tests/test_agent_task_forced_close_process_termination_contract.py +++ b/tests/test_agent_task_forced_close_process_termination_contract.py @@ -65,6 +65,97 @@ def fake_browser_pass( self.assertIs(result["browser_process_terminated"], False) self.assertIs(result["chromium_process_set_terminated"], False) + def test_forced_close_browser_failure_is_returned_after_teardown_waits(self) -> None: + """A reviewed browser failure after identity capture must not skip teardown proof.""" + + runner = RUNNER.read_text(encoding="utf-8") + start = runner.index("def _run_agent_task_forced_close_browser_pass(") + end = runner.index("\ndef _run_agent_task_forced_close_trial(", start) + browser_pass = runner[start:end] + + for expected in ( + "browser_failure_type", + "except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc:", + 'browser_failure_type = type(exc).__name__', + "failure_evidence", + '"browser_process_terminated": browser_process_terminated', + 'failure_evidence["chromium_process_set_terminated"]', + ): + with self.subTest(expected=expected): + self.assertIn(expected, browser_pass) + + shutdown = browser_pass.index("driver.wait(timeout=5)") + root_wait = browser_pass.index("_wait_for_linux_process_identity_exit(") + set_wait = browser_pass.index("_wait_for_linux_process_identity_set_exit(") + failure_return = browser_pass.index("if browser_failure_type is not None:") + self.assertLess(shutdown, root_wait) + self.assertLess(root_wait, failure_return) + self.assertLess(set_wait, failure_return) + + def test_forced_close_trial_preserves_failure_process_set_teardown_evidence(self) -> None: + """False root/set teardown evidence must survive the trial failure envelope.""" + + namespace = runpy.run_path( + str(RUNNER), run_name="forced_close_failure_process_set_trial" + ) + trial = namespace["_run_agent_task_forced_close_trial"] + + def fake_browser_pass( + _chrome_bin: pathlib.Path, + _chromedriver_bin: pathlib.Path, + _fixture_url: str, + _profile_dir: str, + ) -> dict[str, object]: + return { + "failure_type": "RuntimeError", + "browser_process_terminated": False, + "chromium_process_set_terminated": False, + } + + trial.__globals__["_run_agent_task_forced_close_browser_pass"] = fake_browser_pass + result = trial( + pathlib.Path("/unused/chrome"), + pathlib.Path("/unused/chromedriver"), + "http://127.0.0.1/fixture", + 1, + ) + self.assertIs(result["passed"], False) + self.assertEqual(result["failure_type"], "RuntimeError") + self.assertIs(result["browser_process_terminated"], False) + self.assertIs(result["chromium_process_set_terminated"], False) + self.assertIs(result["profile_cleaned"], True) + + def test_forced_close_trial_does_not_invent_process_set_teardown_evidence(self) -> None: + """A failure before process-set capture may retain root proof but not invent set proof.""" + + namespace = runpy.run_path( + str(RUNNER), run_name="forced_close_failure_root_only_trial" + ) + trial = namespace["_run_agent_task_forced_close_trial"] + + def fake_browser_pass( + _chrome_bin: pathlib.Path, + _chromedriver_bin: pathlib.Path, + _fixture_url: str, + _profile_dir: str, + ) -> dict[str, object]: + return { + "failure_type": "RuntimeError", + "browser_process_terminated": True, + } + + trial.__globals__["_run_agent_task_forced_close_browser_pass"] = fake_browser_pass + result = trial( + pathlib.Path("/unused/chrome"), + pathlib.Path("/unused/chromedriver"), + "http://127.0.0.1/fixture", + 2, + ) + self.assertIs(result["passed"], False) + self.assertIs(result["browser_process_terminated"], True) + self.assertNotIn("chromium_process_set_terminated", result) + self.assertIs(result["profile_cleaned"], True) + def test_main_forced_close_gate_requires_process_termination(self) -> None: """Compatibility success must reject a live forced-close browser identity.""" From e0737d6a619754ec674519cc37b15a72b0dddaf3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 19:53:22 +0900 Subject: [PATCH 02/30] fix(browser): retain forced-close failure teardown proof --- scripts/ci/run_mv3_compatibility.py | 54 +++++++++++++++++++++++++---- 1 file changed, 48 insertions(+), 6 deletions(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index 225c2bd69..c38500283 100755 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -1422,6 +1422,7 @@ def _run_agent_task_forced_close_browser_pass( browser_process_id: int | None = None browser_process_start_time_ticks: int | None = None chromium_process_identities: tuple[tuple[int, int], ...] | None = None + browser_failure_type: str | None = None result: dict[str, Any] | None = None driver = subprocess.Popen( [str(chromedriver_bin), f"--port={driver_port}", "--allowed-ips=127.0.0.1"], @@ -1560,6 +1561,10 @@ def _run_agent_task_forced_close_browser_pass( "forced_close_detected": forced_close_detected, "session_survived": True, } + except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: + browser_failure_type = type(exc).__name__ + if browser_process_id is None or browser_process_start_time_ticks is None: + raise finally: if session_id is not None: with contextlib.suppress(Exception): @@ -1578,17 +1583,29 @@ def _run_agent_task_forced_close_browser_pass( if browser_process_id is None or browser_process_start_time_ticks is None: raise RuntimeError("Agent Task forced-close browser process identity was not captured") - if chromium_process_identities is None: - raise RuntimeError("Agent Task forced-close Chromium process identities were not captured") browser_process_terminated = _wait_for_linux_process_identity_exit( browser_process_id, browser_process_start_time_ticks, ) - chromium_process_set_terminated = _wait_for_linux_process_identity_set_exit( - chromium_process_identities - ) + chromium_process_set_terminated: bool | None = None + if chromium_process_identities is not None: + chromium_process_set_terminated = _wait_for_linux_process_identity_set_exit( + chromium_process_identities + ) + if browser_failure_type is not None: + failure_evidence: dict[str, Any] = { + "failure_type": browser_failure_type, + "browser_process_terminated": browser_process_terminated, + } + if chromium_process_set_terminated is not None: + failure_evidence["chromium_process_set_terminated"] = ( + chromium_process_set_terminated + ) + return failure_evidence if result is None: raise RuntimeError("Agent Task forced-close browser pass returned no result after shutdown") + if chromium_process_set_terminated is None: + raise RuntimeError("Agent Task forced-close Chromium process identities were not captured") if not browser_process_terminated: raise RuntimeError("Agent Task forced-close browser process did not terminate") if not chromium_process_set_terminated: @@ -1640,6 +1657,31 @@ def _run_agent_task_forced_close_trial( } if result is None: raise RuntimeError("Agent Task forced-close browser pass returned no result") + returned_failure_type = result.get("failure_type") + if returned_failure_type is not None: + if not isinstance(returned_failure_type, str) or not returned_failure_type: + raise RuntimeError("Agent Task forced-close browser pass returned invalid failure evidence") + browser_process_terminated = result.get("browser_process_terminated") + if not isinstance(browser_process_terminated, bool): + raise RuntimeError("Agent Task forced-close browser pass returned invalid teardown evidence") + failure_evidence: dict[str, Any] = { + "trial_number": trial_number, + "passed": False, + "failure_type": returned_failure_type, + "browser_process_terminated": browser_process_terminated, + "profile_cleaned": True, + "duration_ms": duration_ms, + } + if "chromium_process_set_terminated" in result: + chromium_process_set_terminated = result["chromium_process_set_terminated"] + if not isinstance(chromium_process_set_terminated, bool): + raise RuntimeError( + "Agent Task forced-close browser pass returned invalid process-set teardown evidence" + ) + failure_evidence["chromium_process_set_terminated"] = ( + chromium_process_set_terminated + ) + return failure_evidence return { "trial_number": trial_number, @@ -1926,4 +1968,4 @@ def main() -> int: if __name__ == "__main__": - raise SystemExit(main()) \ No newline at end of file + raise SystemExit(main()) From 171f365b2a1aeacc34b94f2842a0ff68ca117cc1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 14:05:53 -0700 Subject: [PATCH 03/30] test(browser): prevent state diagnostic reflection --- ...st_agent_task_state_diagnostic_contract.py | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 tests/test_agent_task_state_diagnostic_contract.py diff --git a/tests/test_agent_task_state_diagnostic_contract.py b/tests/test_agent_task_state_diagnostic_contract.py new file mode 100644 index 000000000..65400d869 --- /dev/null +++ b/tests/test_agent_task_state_diagnostic_contract.py @@ -0,0 +1,32 @@ +"""Regression contract for fail-closed, non-reflective Agent Task state diagnostics.""" + +from __future__ import annotations + +import pathlib +import runpy +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +RUNNER = ROOT / "scripts" / "ci" / "run_mv3_compatibility.py" + + +class AgentTaskStateDiagnosticContractTests(unittest.TestCase): + """Keep page-controlled state values out of runner diagnostics.""" + + def test_post_condition_failure_does_not_reflect_page_controlled_state(self) -> None: + namespace = runpy.run_path(str(RUNNER), run_name="agent_task_state_diagnostic_contract") + validate = namespace["_validate_agent_task_submitted_state"] + hostile_state = "rejected" + + with self.assertRaisesRegex( + RuntimeError, + r"^Agent Task state post-condition failed$", + ) as captured: + validate(hostile_state) + + self.assertNotIn(hostile_state, str(captured.exception)) + validate("submitted") + + +if __name__ == "__main__": + unittest.main() From 830be538374ff1668e79180888799923cf6cdfcb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 07:12:30 +0900 Subject: [PATCH 04/30] fix(mv3): redact agent task state failures --- scripts/ci/run_mv3_compatibility.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index c38500283..c603f6167 100755 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -773,6 +773,13 @@ def _exercise_real_click(driver_port: int, session_id: str) -> str: return str(text) +def _validate_agent_task_submitted_state(state: object) -> None: + """Accept only the controlled submitted marker without echoing page state.""" + + if state != "submitted": + raise RuntimeError("Agent Task state post-condition failed") + + def _run_browser_pass( chrome_bin: pathlib.Path, chromedriver_bin: pathlib.Path, @@ -1160,8 +1167,7 @@ def _run_agent_task_browser_pass( "GET", _element_command_path(session_id, result_element, "/text"), ).get("value") - if state != "submitted": - raise RuntimeError(f"Agent Task state post-condition failed: {state!r}") + _validate_agent_task_submitted_state(state) if text != AGENT_TASK_INPUT_VALUE: raise RuntimeError("Agent Task result did not match the synthetic typed value") structured_value_sha256 = _hash_agent_task_structured_value(text) From 371ce49ed75707fc1eb73d11e4d0ad92e3a69cec Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 07:35:44 -0700 Subject: [PATCH 05/30] test(mv3): expose wedged driver teardown escape --- ...rced_close_process_termination_contract.py | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/test_agent_task_forced_close_process_termination_contract.py b/tests/test_agent_task_forced_close_process_termination_contract.py index c2b2d93b4..de5a7ca99 100644 --- a/tests/test_agent_task_forced_close_process_termination_contract.py +++ b/tests/test_agent_task_forced_close_process_termination_contract.py @@ -4,6 +4,7 @@ import pathlib import runpy +import subprocess import unittest ROOT = pathlib.Path(__file__).resolve().parents[1] @@ -92,6 +93,40 @@ def test_forced_close_browser_failure_is_returned_after_teardown_waits(self) -> self.assertLess(root_wait, failure_return) self.assertLess(set_wait, failure_return) + def test_forced_close_driver_shutdown_timeout_is_bounded_and_typed(self) -> None: + """A wedged ChromeDriver after SIGKILL must become failure evidence, not escape.""" + + namespace = runpy.run_path( + str(RUNNER), run_name="forced_close_driver_shutdown_timeout_contract" + ) + shutdown = namespace["_terminate_owned_process_bounded"] + timeout_seconds = namespace["PROCESS_EXIT_TIMEOUT_SECONDS"] + + class WedgedProcess: + def __init__(self) -> None: + self.terminated = False + self.killed = False + self.wait_timeouts: list[float] = [] + + def terminate(self) -> None: + self.terminated = True + + def kill(self) -> None: + self.killed = True + + def wait(self, timeout: float) -> int: + self.wait_timeouts.append(timeout) + raise subprocess.TimeoutExpired("chromedriver", timeout) + + process = WedgedProcess() + terminated, failure_type = shutdown(process) + + self.assertIs(process.terminated, True) + self.assertIs(process.killed, True) + self.assertEqual(process.wait_timeouts, [timeout_seconds, timeout_seconds]) + self.assertIs(terminated, False) + self.assertEqual(failure_type, "TimeoutExpired") + def test_forced_close_trial_preserves_failure_process_set_teardown_evidence(self) -> None: """False root/set teardown evidence must survive the trial failure envelope.""" From 7f7a4ece2ac345913c7c763d17595e73f09444ac Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 07:40:19 -0700 Subject: [PATCH 06/30] fix(mv3): bound forced-close driver teardown failures --- scripts/ci/run_mv3_compatibility.py | 62 +++++++++++++++++++++++++---- 1 file changed, 54 insertions(+), 8 deletions(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index c603f6167..2caaa01b2 100755 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -780,6 +780,36 @@ def _validate_agent_task_submitted_state(state: object) -> None: raise RuntimeError("Agent Task state post-condition failed") +def _terminate_owned_process_bounded(process: Any) -> tuple[bool, str | None]: + """Terminate one owned child under bounded waits and retain typed failure evidence.""" + + try: + process.terminate() + except ProcessLookupError: + return True, None + except OSError as exc: + return False, type(exc).__name__ + + try: + process.wait(timeout=PROCESS_EXIT_TIMEOUT_SECONDS) + return True, None + except subprocess.TimeoutExpired: + try: + process.kill() + except ProcessLookupError: + return True, None + except OSError as exc: + return False, type(exc).__name__ + + try: + process.wait(timeout=PROCESS_EXIT_TIMEOUT_SECONDS) + return True, None + except subprocess.TimeoutExpired as exc: + return False, type(exc).__name__ + except OSError as exc: + return False, type(exc).__name__ + + def _run_browser_pass( chrome_bin: pathlib.Path, chromedriver_bin: pathlib.Path, @@ -1429,6 +1459,8 @@ def _run_agent_task_forced_close_browser_pass( browser_process_start_time_ticks: int | None = None chromium_process_identities: tuple[tuple[int, int], ...] | None = None browser_failure_type: str | None = None + driver_process_terminated: bool | None = None + driver_cleanup_failure_type: str | None = None result: dict[str, Any] | None = None driver = subprocess.Popen( [str(chromedriver_bin), f"--port={driver_port}", "--allowed-ips=127.0.0.1"], @@ -1580,12 +1612,9 @@ def _run_agent_task_forced_close_browser_pass( _webdriver_path(session_id, ""), {}, ) - driver.terminate() - try: - driver.wait(timeout=5) - except subprocess.TimeoutExpired: - driver.kill() - driver.wait(timeout=5) + driver_process_terminated, driver_cleanup_failure_type = ( + _terminate_owned_process_bounded(driver) + ) if browser_process_id is None or browser_process_start_time_ticks is None: raise RuntimeError("Agent Task forced-close browser process identity was not captured") @@ -1598,11 +1627,14 @@ def _run_agent_task_forced_close_browser_pass( chromium_process_set_terminated = _wait_for_linux_process_identity_set_exit( chromium_process_identities ) - if browser_failure_type is not None: + if browser_failure_type is not None or driver_cleanup_failure_type is not None: failure_evidence: dict[str, Any] = { - "failure_type": browser_failure_type, + "failure_type": browser_failure_type or driver_cleanup_failure_type, + "driver_process_terminated": driver_process_terminated, "browser_process_terminated": browser_process_terminated, } + if browser_failure_type is not None and driver_cleanup_failure_type is not None: + failure_evidence["cleanup_failure_type"] = driver_cleanup_failure_type if chromium_process_set_terminated is not None: failure_evidence["chromium_process_set_terminated"] = ( chromium_process_set_terminated @@ -1612,10 +1644,13 @@ def _run_agent_task_forced_close_browser_pass( raise RuntimeError("Agent Task forced-close browser pass returned no result after shutdown") if chromium_process_set_terminated is None: raise RuntimeError("Agent Task forced-close Chromium process identities were not captured") + if driver_process_terminated is not True: + raise RuntimeError("Agent Task forced-close ChromeDriver process did not terminate") if not browser_process_terminated: raise RuntimeError("Agent Task forced-close browser process did not terminate") if not chromium_process_set_terminated: raise RuntimeError("Agent Task forced-close Chromium process set did not terminate") + result["driver_process_terminated"] = True result["browser_process_terminated"] = True result["chromium_process_set_terminated"] = True return result @@ -1667,6 +1702,9 @@ def _run_agent_task_forced_close_trial( if returned_failure_type is not None: if not isinstance(returned_failure_type, str) or not returned_failure_type: raise RuntimeError("Agent Task forced-close browser pass returned invalid failure evidence") + driver_process_terminated = result.get("driver_process_terminated") + if not isinstance(driver_process_terminated, bool): + raise RuntimeError("Agent Task forced-close browser pass returned invalid driver teardown evidence") browser_process_terminated = result.get("browser_process_terminated") if not isinstance(browser_process_terminated, bool): raise RuntimeError("Agent Task forced-close browser pass returned invalid teardown evidence") @@ -1674,10 +1712,16 @@ def _run_agent_task_forced_close_trial( "trial_number": trial_number, "passed": False, "failure_type": returned_failure_type, + "driver_process_terminated": driver_process_terminated, "browser_process_terminated": browser_process_terminated, "profile_cleaned": True, "duration_ms": duration_ms, } + if "cleanup_failure_type" in result: + cleanup_failure_type = result["cleanup_failure_type"] + if not isinstance(cleanup_failure_type, str) or not cleanup_failure_type: + raise RuntimeError("Agent Task forced-close browser pass returned invalid cleanup failure evidence") + failure_evidence["cleanup_failure_type"] = cleanup_failure_type if "chromium_process_set_terminated" in result: chromium_process_set_terminated = result["chromium_process_set_terminated"] if not isinstance(chromium_process_set_terminated, bool): @@ -1695,6 +1739,7 @@ def _run_agent_task_forced_close_trial( "browser_version": result["browser_version"], "forced_close_detected": result["forced_close_detected"], "session_survived": result["session_survived"], + "driver_process_terminated": result["driver_process_terminated"], "browser_process_terminated": result["browser_process_terminated"], "chromium_process_set_terminated": result["chromium_process_set_terminated"], "profile_cleaned": True, @@ -1897,6 +1942,7 @@ def main() -> int: forced_close_surfaces_complete = all( trial.get("forced_close_detected") is True and trial.get("session_survived") is True + and trial.get("driver_process_terminated") is True and trial.get("browser_process_terminated") is True and trial.get("chromium_process_set_terminated") is True and trial.get("profile_cleaned") is True From 7e4a8670aab75f3dae3761d510b00b0af634ac20 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 07:41:01 -0700 Subject: [PATCH 07/30] test(mv3): require bounded driver teardown evidence --- ...rced_close_process_termination_contract.py | 56 ++++++++++++++++++- 1 file changed, 53 insertions(+), 3 deletions(-) diff --git a/tests/test_agent_task_forced_close_process_termination_contract.py b/tests/test_agent_task_forced_close_process_termination_contract.py index de5a7ca99..98657b259 100644 --- a/tests/test_agent_task_forced_close_process_termination_contract.py +++ b/tests/test_agent_task_forced_close_process_termination_contract.py @@ -26,8 +26,10 @@ def test_forced_close_browser_pass_binds_and_waits_for_process_identities(self) "_read_linux_proc_stat_process_identity", "_snapshot_linux_process_evidence", "_read_linux_process_identity_set", + "_terminate_owned_process_bounded", "_wait_for_linux_process_identity_exit", "_wait_for_linux_process_identity_set_exit", + '"driver_process_terminated"', '"browser_process_terminated"', '"chromium_process_set_terminated"', ): @@ -52,6 +54,7 @@ def fake_browser_pass( "browser_version": namespace["PINNED_CHROME_VERSION"], "forced_close_detected": True, "session_survived": True, + "driver_process_terminated": True, "browser_process_terminated": False, "chromium_process_set_terminated": False, } @@ -63,6 +66,7 @@ def fake_browser_pass( "http://127.0.0.1/fixture", 1, ) + self.assertIs(result["driver_process_terminated"], True) self.assertIs(result["browser_process_terminated"], False) self.assertIs(result["chromium_process_set_terminated"], False) @@ -76,19 +80,23 @@ def test_forced_close_browser_failure_is_returned_after_teardown_waits(self) -> for expected in ( "browser_failure_type", + "driver_cleanup_failure_type", "except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc:", 'browser_failure_type = type(exc).__name__', "failure_evidence", + '"driver_process_terminated": driver_process_terminated', '"browser_process_terminated": browser_process_terminated', 'failure_evidence["chromium_process_set_terminated"]', ): with self.subTest(expected=expected): self.assertIn(expected, browser_pass) - shutdown = browser_pass.index("driver.wait(timeout=5)") + shutdown = browser_pass.index("_terminate_owned_process_bounded(driver)") root_wait = browser_pass.index("_wait_for_linux_process_identity_exit(") set_wait = browser_pass.index("_wait_for_linux_process_identity_set_exit(") - failure_return = browser_pass.index("if browser_failure_type is not None:") + failure_return = browser_pass.index( + "if browser_failure_type is not None or driver_cleanup_failure_type is not None:" + ) self.assertLess(shutdown, root_wait) self.assertLess(root_wait, failure_return) self.assertLess(set_wait, failure_return) @@ -127,6 +135,43 @@ def wait(self, timeout: float) -> int: self.assertIs(terminated, False) self.assertEqual(failure_type, "TimeoutExpired") + def test_forced_close_trial_preserves_cleanup_failure_without_overwriting_browser_failure(self) -> None: + """A teardown timeout must remain separate from the original browser failure type.""" + + namespace = runpy.run_path( + str(RUNNER), run_name="forced_close_cleanup_failure_trial" + ) + trial = namespace["_run_agent_task_forced_close_trial"] + + def fake_browser_pass( + _chrome_bin: pathlib.Path, + _chromedriver_bin: pathlib.Path, + _fixture_url: str, + _profile_dir: str, + ) -> dict[str, object]: + return { + "failure_type": "RuntimeError", + "cleanup_failure_type": "TimeoutExpired", + "driver_process_terminated": False, + "browser_process_terminated": True, + "chromium_process_set_terminated": True, + } + + trial.__globals__["_run_agent_task_forced_close_browser_pass"] = fake_browser_pass + result = trial( + pathlib.Path("/unused/chrome"), + pathlib.Path("/unused/chromedriver"), + "http://127.0.0.1/fixture", + 3, + ) + self.assertIs(result["passed"], False) + self.assertEqual(result["failure_type"], "RuntimeError") + self.assertEqual(result["cleanup_failure_type"], "TimeoutExpired") + self.assertIs(result["driver_process_terminated"], False) + self.assertIs(result["browser_process_terminated"], True) + self.assertIs(result["chromium_process_set_terminated"], True) + self.assertIs(result["profile_cleaned"], True) + def test_forced_close_trial_preserves_failure_process_set_teardown_evidence(self) -> None: """False root/set teardown evidence must survive the trial failure envelope.""" @@ -143,6 +188,7 @@ def fake_browser_pass( ) -> dict[str, object]: return { "failure_type": "RuntimeError", + "driver_process_terminated": True, "browser_process_terminated": False, "chromium_process_set_terminated": False, } @@ -156,6 +202,7 @@ def fake_browser_pass( ) self.assertIs(result["passed"], False) self.assertEqual(result["failure_type"], "RuntimeError") + self.assertIs(result["driver_process_terminated"], True) self.assertIs(result["browser_process_terminated"], False) self.assertIs(result["chromium_process_set_terminated"], False) self.assertIs(result["profile_cleaned"], True) @@ -176,6 +223,7 @@ def fake_browser_pass( ) -> dict[str, object]: return { "failure_type": "RuntimeError", + "driver_process_terminated": True, "browser_process_terminated": True, } @@ -187,18 +235,20 @@ def fake_browser_pass( 2, ) self.assertIs(result["passed"], False) + self.assertIs(result["driver_process_terminated"], True) self.assertIs(result["browser_process_terminated"], True) self.assertNotIn("chromium_process_set_terminated", result) self.assertIs(result["profile_cleaned"], True) def test_main_forced_close_gate_requires_process_termination(self) -> None: - """Compatibility success must reject a live forced-close browser identity.""" + """Compatibility success must reject a live forced-close process identity.""" runner = RUNNER.read_text(encoding="utf-8") start = runner.index("forced_close_surfaces_complete = all(") end = runner.index("\n\n evidence = {", start) gate = runner[start:end] for expected in ( + 'trial.get("driver_process_terminated") is True', 'trial.get("browser_process_terminated") is True', 'trial.get("chromium_process_set_terminated") is True', ): From 1731883215c6272978cff01bc7a4b380f2667f91 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 08:07:31 -0700 Subject: [PATCH 08/30] test(browser): require kill fallback evidence --- ...est_agent_task_forced_close_process_termination_contract.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_agent_task_forced_close_process_termination_contract.py b/tests/test_agent_task_forced_close_process_termination_contract.py index 98657b259..8d9fd7e5b 100644 --- a/tests/test_agent_task_forced_close_process_termination_contract.py +++ b/tests/test_agent_task_forced_close_process_termination_contract.py @@ -127,13 +127,14 @@ def wait(self, timeout: float) -> int: raise subprocess.TimeoutExpired("chromedriver", timeout) process = WedgedProcess() - terminated, failure_type = shutdown(process) + terminated, failure_type, kill_fallback_used = shutdown(process) self.assertIs(process.terminated, True) self.assertIs(process.killed, True) self.assertEqual(process.wait_timeouts, [timeout_seconds, timeout_seconds]) self.assertIs(terminated, False) self.assertEqual(failure_type, "TimeoutExpired") + self.assertIs(kill_fallback_used, True) def test_forced_close_trial_preserves_cleanup_failure_without_overwriting_browser_failure(self) -> None: """A teardown timeout must remain separate from the original browser failure type.""" From c3291dca2192b89b67f844418fadb7da70449d37 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 08:13:11 -0700 Subject: [PATCH 09/30] fix(browser): retain kill fallback evidence --- scripts/ci/run_mv3_compatibility.py | 37 +++++++++++++++++++---------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index 2caaa01b2..d2556693f 100755 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -780,34 +780,34 @@ def _validate_agent_task_submitted_state(state: object) -> None: raise RuntimeError("Agent Task state post-condition failed") -def _terminate_owned_process_bounded(process: Any) -> tuple[bool, str | None]: - """Terminate one owned child under bounded waits and retain typed failure evidence.""" +def _terminate_owned_process_bounded(process: Any) -> tuple[bool, str | None, bool]: + """Terminate one owned child under bounded waits and retain typed fallback evidence.""" try: process.terminate() except ProcessLookupError: - return True, None + return True, None, False except OSError as exc: - return False, type(exc).__name__ + return False, type(exc).__name__, False try: process.wait(timeout=PROCESS_EXIT_TIMEOUT_SECONDS) - return True, None + return True, None, False except subprocess.TimeoutExpired: try: process.kill() except ProcessLookupError: - return True, None + return True, None, True except OSError as exc: - return False, type(exc).__name__ + return False, type(exc).__name__, True try: process.wait(timeout=PROCESS_EXIT_TIMEOUT_SECONDS) - return True, None + return True, None, True except subprocess.TimeoutExpired as exc: - return False, type(exc).__name__ + return False, type(exc).__name__, True except OSError as exc: - return False, type(exc).__name__ + return False, type(exc).__name__, True def _run_browser_pass( @@ -1461,6 +1461,7 @@ def _run_agent_task_forced_close_browser_pass( browser_failure_type: str | None = None driver_process_terminated: bool | None = None driver_cleanup_failure_type: str | None = None + driver_kill_fallback_used = False result: dict[str, Any] | None = None driver = subprocess.Popen( [str(chromedriver_bin), f"--port={driver_port}", "--allowed-ips=127.0.0.1"], @@ -1612,9 +1613,11 @@ def _run_agent_task_forced_close_browser_pass( _webdriver_path(session_id, ""), {}, ) - driver_process_terminated, driver_cleanup_failure_type = ( - _terminate_owned_process_bounded(driver) - ) + ( + driver_process_terminated, + driver_cleanup_failure_type, + driver_kill_fallback_used, + ) = _terminate_owned_process_bounded(driver) if browser_process_id is None or browser_process_start_time_ticks is None: raise RuntimeError("Agent Task forced-close browser process identity was not captured") @@ -1631,6 +1634,7 @@ def _run_agent_task_forced_close_browser_pass( failure_evidence: dict[str, Any] = { "failure_type": browser_failure_type or driver_cleanup_failure_type, "driver_process_terminated": driver_process_terminated, + "driver_kill_fallback_used": driver_kill_fallback_used, "browser_process_terminated": browser_process_terminated, } if browser_failure_type is not None and driver_cleanup_failure_type is not None: @@ -1651,6 +1655,7 @@ def _run_agent_task_forced_close_browser_pass( if not chromium_process_set_terminated: raise RuntimeError("Agent Task forced-close Chromium process set did not terminate") result["driver_process_terminated"] = True + result["driver_kill_fallback_used"] = driver_kill_fallback_used result["browser_process_terminated"] = True result["chromium_process_set_terminated"] = True return result @@ -1705,6 +1710,9 @@ def _run_agent_task_forced_close_trial( driver_process_terminated = result.get("driver_process_terminated") if not isinstance(driver_process_terminated, bool): raise RuntimeError("Agent Task forced-close browser pass returned invalid driver teardown evidence") + driver_kill_fallback_used = result.get("driver_kill_fallback_used") + if not isinstance(driver_kill_fallback_used, bool): + raise RuntimeError("Agent Task forced-close browser pass returned invalid driver fallback evidence") browser_process_terminated = result.get("browser_process_terminated") if not isinstance(browser_process_terminated, bool): raise RuntimeError("Agent Task forced-close browser pass returned invalid teardown evidence") @@ -1713,6 +1721,7 @@ def _run_agent_task_forced_close_trial( "passed": False, "failure_type": returned_failure_type, "driver_process_terminated": driver_process_terminated, + "driver_kill_fallback_used": driver_kill_fallback_used, "browser_process_terminated": browser_process_terminated, "profile_cleaned": True, "duration_ms": duration_ms, @@ -1740,6 +1749,7 @@ def _run_agent_task_forced_close_trial( "forced_close_detected": result["forced_close_detected"], "session_survived": result["session_survived"], "driver_process_terminated": result["driver_process_terminated"], + "driver_kill_fallback_used": result["driver_kill_fallback_used"], "browser_process_terminated": result["browser_process_terminated"], "chromium_process_set_terminated": result["chromium_process_set_terminated"], "profile_cleaned": True, @@ -1943,6 +1953,7 @@ def main() -> int: trial.get("forced_close_detected") is True and trial.get("session_survived") is True and trial.get("driver_process_terminated") is True + and isinstance(trial.get("driver_kill_fallback_used"), bool) and trial.get("browser_process_terminated") is True and trial.get("chromium_process_set_terminated") is True and trial.get("profile_cleaned") is True From ae0789b01dd50230a10fb2cb2c941c3243cf4424 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 08:15:12 -0700 Subject: [PATCH 10/30] test(browser): preserve kill fallback evidence --- ...rced_close_process_termination_contract.py | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/tests/test_agent_task_forced_close_process_termination_contract.py b/tests/test_agent_task_forced_close_process_termination_contract.py index 8d9fd7e5b..88ff2d934 100644 --- a/tests/test_agent_task_forced_close_process_termination_contract.py +++ b/tests/test_agent_task_forced_close_process_termination_contract.py @@ -30,6 +30,7 @@ def test_forced_close_browser_pass_binds_and_waits_for_process_identities(self) "_wait_for_linux_process_identity_exit", "_wait_for_linux_process_identity_set_exit", '"driver_process_terminated"', + '"driver_kill_fallback_used"', '"browser_process_terminated"', '"chromium_process_set_terminated"', ): @@ -55,6 +56,7 @@ def fake_browser_pass( "forced_close_detected": True, "session_survived": True, "driver_process_terminated": True, + "driver_kill_fallback_used": False, "browser_process_terminated": False, "chromium_process_set_terminated": False, } @@ -67,6 +69,7 @@ def fake_browser_pass( 1, ) self.assertIs(result["driver_process_terminated"], True) + self.assertIs(result["driver_kill_fallback_used"], False) self.assertIs(result["browser_process_terminated"], False) self.assertIs(result["chromium_process_set_terminated"], False) @@ -81,10 +84,12 @@ def test_forced_close_browser_failure_is_returned_after_teardown_waits(self) -> for expected in ( "browser_failure_type", "driver_cleanup_failure_type", + "driver_kill_fallback_used", "except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc:", 'browser_failure_type = type(exc).__name__', "failure_evidence", '"driver_process_terminated": driver_process_terminated', + '"driver_kill_fallback_used": driver_kill_fallback_used', '"browser_process_terminated": browser_process_terminated', 'failure_evidence["chromium_process_set_terminated"]', ): @@ -136,6 +141,78 @@ def wait(self, timeout: float) -> int: self.assertEqual(failure_type, "TimeoutExpired") self.assertIs(kill_fallback_used, True) + def test_forced_close_driver_shutdown_records_successful_kill_fallback(self) -> None: + """A successful SIGKILL fallback must remain explicit in cleanup evidence.""" + + namespace = runpy.run_path( + str(RUNNER), run_name="forced_close_driver_kill_fallback_contract" + ) + shutdown = namespace["_terminate_owned_process_bounded"] + timeout_seconds = namespace["PROCESS_EXIT_TIMEOUT_SECONDS"] + + class KillRecoversProcess: + def __init__(self) -> None: + self.terminated = False + self.killed = False + self.wait_timeouts: list[float] = [] + + def terminate(self) -> None: + self.terminated = True + + def kill(self) -> None: + self.killed = True + + def wait(self, timeout: float) -> int: + self.wait_timeouts.append(timeout) + if len(self.wait_timeouts) == 1: + raise subprocess.TimeoutExpired("chromedriver", timeout) + return 0 + + process = KillRecoversProcess() + terminated, failure_type, kill_fallback_used = shutdown(process) + + self.assertIs(process.terminated, True) + self.assertIs(process.killed, True) + self.assertEqual(process.wait_timeouts, [timeout_seconds, timeout_seconds]) + self.assertIs(terminated, True) + self.assertIsNone(failure_type) + self.assertIs(kill_fallback_used, True) + + def test_forced_close_driver_shutdown_records_graceful_termination(self) -> None: + """A graceful terminate path must not claim the SIGKILL fallback was used.""" + + namespace = runpy.run_path( + str(RUNNER), run_name="forced_close_driver_graceful_shutdown_contract" + ) + shutdown = namespace["_terminate_owned_process_bounded"] + timeout_seconds = namespace["PROCESS_EXIT_TIMEOUT_SECONDS"] + + class GracefulProcess: + def __init__(self) -> None: + self.terminated = False + self.killed = False + self.wait_timeouts: list[float] = [] + + def terminate(self) -> None: + self.terminated = True + + def kill(self) -> None: + self.killed = True + + def wait(self, timeout: float) -> int: + self.wait_timeouts.append(timeout) + return 0 + + process = GracefulProcess() + terminated, failure_type, kill_fallback_used = shutdown(process) + + self.assertIs(process.terminated, True) + self.assertIs(process.killed, False) + self.assertEqual(process.wait_timeouts, [timeout_seconds]) + self.assertIs(terminated, True) + self.assertIsNone(failure_type) + self.assertIs(kill_fallback_used, False) + def test_forced_close_trial_preserves_cleanup_failure_without_overwriting_browser_failure(self) -> None: """A teardown timeout must remain separate from the original browser failure type.""" @@ -154,6 +231,7 @@ def fake_browser_pass( "failure_type": "RuntimeError", "cleanup_failure_type": "TimeoutExpired", "driver_process_terminated": False, + "driver_kill_fallback_used": True, "browser_process_terminated": True, "chromium_process_set_terminated": True, } @@ -169,6 +247,7 @@ def fake_browser_pass( self.assertEqual(result["failure_type"], "RuntimeError") self.assertEqual(result["cleanup_failure_type"], "TimeoutExpired") self.assertIs(result["driver_process_terminated"], False) + self.assertIs(result["driver_kill_fallback_used"], True) self.assertIs(result["browser_process_terminated"], True) self.assertIs(result["chromium_process_set_terminated"], True) self.assertIs(result["profile_cleaned"], True) @@ -190,6 +269,7 @@ def fake_browser_pass( return { "failure_type": "RuntimeError", "driver_process_terminated": True, + "driver_kill_fallback_used": False, "browser_process_terminated": False, "chromium_process_set_terminated": False, } @@ -204,6 +284,7 @@ def fake_browser_pass( self.assertIs(result["passed"], False) self.assertEqual(result["failure_type"], "RuntimeError") self.assertIs(result["driver_process_terminated"], True) + self.assertIs(result["driver_kill_fallback_used"], False) self.assertIs(result["browser_process_terminated"], False) self.assertIs(result["chromium_process_set_terminated"], False) self.assertIs(result["profile_cleaned"], True) @@ -225,6 +306,7 @@ def fake_browser_pass( return { "failure_type": "RuntimeError", "driver_process_terminated": True, + "driver_kill_fallback_used": False, "browser_process_terminated": True, } @@ -237,6 +319,7 @@ def fake_browser_pass( ) self.assertIs(result["passed"], False) self.assertIs(result["driver_process_terminated"], True) + self.assertIs(result["driver_kill_fallback_used"], False) self.assertIs(result["browser_process_terminated"], True) self.assertNotIn("chromium_process_set_terminated", result) self.assertIs(result["profile_cleaned"], True) @@ -250,6 +333,7 @@ def test_main_forced_close_gate_requires_process_termination(self) -> None: gate = runner[start:end] for expected in ( 'trial.get("driver_process_terminated") is True', + 'isinstance(trial.get("driver_kill_fallback_used"), bool)', 'trial.get("browser_process_terminated") is True', 'trial.get("chromium_process_set_terminated") is True', ): From cb2b887510f760cf33ab0fdeaed17fcaf44d0fb0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 09:09:31 -0700 Subject: [PATCH 11/30] test(browser): expose forced-close session cleanup loss --- ...k_forced_close_session_cleanup_contract.py | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 tests/test_agent_task_forced_close_session_cleanup_contract.py diff --git a/tests/test_agent_task_forced_close_session_cleanup_contract.py b/tests/test_agent_task_forced_close_session_cleanup_contract.py new file mode 100644 index 000000000..326803a97 --- /dev/null +++ b/tests/test_agent_task_forced_close_session_cleanup_contract.py @@ -0,0 +1,119 @@ +"""Contract for truthful WebDriver session cleanup in the forced-close Agent Task lane.""" + +from __future__ import annotations + +import json +import pathlib +import runpy +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +RUNNER = ROOT / "scripts" / "ci" / "run_mv3_compatibility.py" + + +class AgentTaskForcedCloseSessionCleanupContractTests(unittest.TestCase): + """Require reviewed session-delete failures to remain explicit failure evidence.""" + + def test_session_delete_helper_is_bounded_typed_and_source_free(self) -> None: + """A reviewed WebDriver cleanup failure must return only its stable exception type.""" + + namespace = runpy.run_path( + str(RUNNER), run_name="forced_close_session_cleanup_contract" + ) + cleanup = namespace["_delete_webdriver_session_bounded"] + original_request = cleanup.__globals__["_json_request"] + calls: list[tuple[int, str, str, dict[str, object]]] = [] + + def successful_request( + driver_port: int, + method: str, + path: str, + payload: dict[str, object], + ) -> dict[str, object]: + calls.append((driver_port, method, path, payload)) + return {"value": None} + + cleanup.__globals__["_json_request"] = successful_request + try: + self.assertIsNone(cleanup(9515, "session-1")) + finally: + cleanup.__globals__["_json_request"] = original_request + self.assertEqual(calls, [(9515, "DELETE", "/session/session-1", {})]) + + for exception in ( + OSError("raw-io-detail"), + ValueError("raw-value-detail"), + RuntimeError("raw-runtime-detail"), + json.JSONDecodeError("raw-json-detail", "x", 0), + ): + with self.subTest(exception_type=type(exception).__name__): + def failing_request(*_args: object, **_kwargs: object) -> dict[str, object]: + raise exception + + cleanup.__globals__["_json_request"] = failing_request + try: + failure_type = cleanup(9515, "session-1") + finally: + cleanup.__globals__["_json_request"] = original_request + self.assertEqual(failure_type, type(exception).__name__) + self.assertNotIn("raw-", failure_type) + + def test_forced_close_pass_does_not_suppress_session_cleanup_failure(self) -> None: + """The forced-close failure envelope must consume typed session cleanup evidence.""" + + runner = RUNNER.read_text(encoding="utf-8") + start = runner.index("def _run_agent_task_forced_close_browser_pass(") + end = runner.index("\ndef _run_agent_task_forced_close_trial(", start) + browser_pass = runner[start:end] + + self.assertNotIn("contextlib.suppress(Exception)", browser_pass) + self.assertIn("session_cleanup_failure_type", browser_pass) + self.assertIn("_delete_webdriver_session_bounded(driver_port, session_id)", browser_pass) + self.assertIn('"session_cleanup_failure_type"', browser_pass) + self.assertIn('"WebDriverSessionCleanupError"', browser_pass) + + def test_trial_preserves_session_cleanup_failure_separately_from_driver_cleanup(self) -> None: + """Browser, session-delete, and driver-process failures must remain distinguishable.""" + + namespace = runpy.run_path( + str(RUNNER), run_name="forced_close_session_cleanup_trial_contract" + ) + trial = namespace["_run_agent_task_forced_close_trial"] + + def fake_browser_pass( + _chrome_bin: pathlib.Path, + _chromedriver_bin: pathlib.Path, + _fixture_url: str, + _profile_dir: str, + ) -> dict[str, object]: + return { + "failure_type": "RuntimeError", + "session_cleanup_failure_type": "OSError", + "cleanup_failure_type": "TimeoutExpired", + "driver_process_terminated": False, + "driver_kill_fallback_used": True, + "browser_process_terminated": True, + "chromium_process_set_terminated": True, + } + + trial.__globals__["_run_agent_task_forced_close_browser_pass"] = fake_browser_pass + result = trial( + pathlib.Path("/unused/chrome"), + pathlib.Path("/unused/chromedriver"), + "http://127.0.0.1/fixture", + 4, + ) + + self.assertIs(result["passed"], False) + self.assertEqual(result["failure_type"], "RuntimeError") + self.assertEqual(result["session_cleanup_failure_type"], "OSError") + self.assertEqual(result["cleanup_failure_type"], "TimeoutExpired") + self.assertIs(result["driver_process_terminated"], False) + self.assertIs(result["driver_kill_fallback_used"], True) + self.assertIs(result["browser_process_terminated"], True) + self.assertIs(result["chromium_process_set_terminated"], True) + self.assertIs(result["profile_cleaned"], True) + + +if __name__ == "__main__": + unittest.main() From dab7248a7629af5590d574b0ba55b65541e2d2ab Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 09:14:47 -0700 Subject: [PATCH 12/30] fix(browser): retain forced-close session cleanup failures --- scripts/ci/run_mv3_compatibility.py | 61 ++++++++++++++++++++++++----- 1 file changed, 51 insertions(+), 10 deletions(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index d2556693f..19161a15d 100755 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -780,6 +780,21 @@ def _validate_agent_task_submitted_state(state: object) -> None: raise RuntimeError("Agent Task state post-condition failed") +def _delete_webdriver_session_bounded(driver_port: int, session_id: str) -> str | None: + """Delete one validated WebDriver session and retain only reviewed failure types.""" + + try: + _json_request( + driver_port, + "DELETE", + _webdriver_path(session_id, ""), + {}, + ) + except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: + return type(exc).__name__ + return None + + def _terminate_owned_process_bounded(process: Any) -> tuple[bool, str | None, bool]: """Terminate one owned child under bounded waits and retain typed fallback evidence.""" @@ -1459,6 +1474,7 @@ def _run_agent_task_forced_close_browser_pass( browser_process_start_time_ticks: int | None = None chromium_process_identities: tuple[tuple[int, int], ...] | None = None browser_failure_type: str | None = None + session_cleanup_failure_type: str | None = None driver_process_terminated: bool | None = None driver_cleanup_failure_type: str | None = None driver_kill_fallback_used = False @@ -1606,13 +1622,9 @@ def _run_agent_task_forced_close_browser_pass( raise finally: if session_id is not None: - with contextlib.suppress(Exception): - _json_request( - driver_port, - "DELETE", - _webdriver_path(session_id, ""), - {}, - ) + session_cleanup_failure_type = _delete_webdriver_session_bounded( + driver_port, session_id + ) ( driver_process_terminated, driver_cleanup_failure_type, @@ -1630,14 +1642,31 @@ def _run_agent_task_forced_close_browser_pass( chromium_process_set_terminated = _wait_for_linux_process_identity_set_exit( chromium_process_identities ) - if browser_failure_type is not None or driver_cleanup_failure_type is not None: + if ( + browser_failure_type is not None + or session_cleanup_failure_type is not None + or driver_cleanup_failure_type is not None + ): + primary_failure_type = browser_failure_type + if primary_failure_type is None and session_cleanup_failure_type is not None: + primary_failure_type = "WebDriverSessionCleanupError" + if primary_failure_type is None: + primary_failure_type = driver_cleanup_failure_type + if primary_failure_type is None: + raise RuntimeError("Agent Task forced-close failure evidence lost its primary type") failure_evidence: dict[str, Any] = { - "failure_type": browser_failure_type or driver_cleanup_failure_type, + "failure_type": primary_failure_type, "driver_process_terminated": driver_process_terminated, "driver_kill_fallback_used": driver_kill_fallback_used, "browser_process_terminated": browser_process_terminated, } - if browser_failure_type is not None and driver_cleanup_failure_type is not None: + if session_cleanup_failure_type is not None: + failure_evidence["session_cleanup_failure_type"] = ( + session_cleanup_failure_type + ) + if driver_cleanup_failure_type is not None and ( + browser_failure_type is not None or session_cleanup_failure_type is not None + ): failure_evidence["cleanup_failure_type"] = driver_cleanup_failure_type if chromium_process_set_terminated is not None: failure_evidence["chromium_process_set_terminated"] = ( @@ -1726,6 +1755,18 @@ def _run_agent_task_forced_close_trial( "profile_cleaned": True, "duration_ms": duration_ms, } + if "session_cleanup_failure_type" in result: + session_cleanup_failure_type = result["session_cleanup_failure_type"] + if ( + not isinstance(session_cleanup_failure_type, str) + or not session_cleanup_failure_type + ): + raise RuntimeError( + "Agent Task forced-close browser pass returned invalid session cleanup failure evidence" + ) + failure_evidence["session_cleanup_failure_type"] = ( + session_cleanup_failure_type + ) if "cleanup_failure_type" in result: cleanup_failure_type = result["cleanup_failure_type"] if not isinstance(cleanup_failure_type, str) or not cleanup_failure_type: From 5887d66afd8606148481bc74e9d0f52ed12a24ce Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 09:18:31 -0700 Subject: [PATCH 13/30] test(browser): decouple teardown ordering contract from condition formatting --- ...rced_close_process_termination_contract.py | 92 +++++-------------- 1 file changed, 21 insertions(+), 71 deletions(-) diff --git a/tests/test_agent_task_forced_close_process_termination_contract.py b/tests/test_agent_task_forced_close_process_termination_contract.py index 88ff2d934..d7a0e8871 100644 --- a/tests/test_agent_task_forced_close_process_termination_contract.py +++ b/tests/test_agent_task_forced_close_process_termination_contract.py @@ -99,9 +99,7 @@ def test_forced_close_browser_failure_is_returned_after_teardown_waits(self) -> shutdown = browser_pass.index("_terminate_owned_process_bounded(driver)") root_wait = browser_pass.index("_wait_for_linux_process_identity_exit(") set_wait = browser_pass.index("_wait_for_linux_process_identity_set_exit(") - failure_return = browser_pass.index( - "if browser_failure_type is not None or driver_cleanup_failure_type is not None:" - ) + failure_return = browser_pass.index("browser_failure_type is not None", set_wait) self.assertLess(shutdown, root_wait) self.assertLess(root_wait, failure_return) self.assertLess(set_wait, failure_return) @@ -178,8 +176,8 @@ def wait(self, timeout: float) -> int: self.assertIsNone(failure_type) self.assertIs(kill_fallback_used, True) - def test_forced_close_driver_shutdown_records_graceful_termination(self) -> None: - """A graceful terminate path must not claim the SIGKILL fallback was used.""" + def test_forced_close_driver_shutdown_graceful_path_records_no_fallback(self) -> None: + """Graceful ChromeDriver shutdown must not report hard-kill fallback use.""" namespace = runpy.run_path( str(RUNNER), run_name="forced_close_driver_graceful_shutdown_contract" @@ -213,11 +211,11 @@ def wait(self, timeout: float) -> int: self.assertIsNone(failure_type) self.assertIs(kill_fallback_used, False) - def test_forced_close_trial_preserves_cleanup_failure_without_overwriting_browser_failure(self) -> None: - """A teardown timeout must remain separate from the original browser failure type.""" + def test_forced_close_trial_preserves_driver_cleanup_failure_separately(self) -> None: + """Browser and driver-cleanup failure evidence must remain separately attributable.""" namespace = runpy.run_path( - str(RUNNER), run_name="forced_close_cleanup_failure_trial" + str(RUNNER), run_name="forced_close_driver_cleanup_trial_contract" ) trial = namespace["_run_agent_task_forced_close_trial"] @@ -241,8 +239,9 @@ def fake_browser_pass( pathlib.Path("/unused/chrome"), pathlib.Path("/unused/chromedriver"), "http://127.0.0.1/fixture", - 3, + 2, ) + self.assertIs(result["passed"], False) self.assertEqual(result["failure_type"], "RuntimeError") self.assertEqual(result["cleanup_failure_type"], "TimeoutExpired") @@ -252,48 +251,11 @@ def fake_browser_pass( self.assertIs(result["chromium_process_set_terminated"], True) self.assertIs(result["profile_cleaned"], True) - def test_forced_close_trial_preserves_failure_process_set_teardown_evidence(self) -> None: - """False root/set teardown evidence must survive the trial failure envelope.""" - - namespace = runpy.run_path( - str(RUNNER), run_name="forced_close_failure_process_set_trial" - ) - trial = namespace["_run_agent_task_forced_close_trial"] - - def fake_browser_pass( - _chrome_bin: pathlib.Path, - _chromedriver_bin: pathlib.Path, - _fixture_url: str, - _profile_dir: str, - ) -> dict[str, object]: - return { - "failure_type": "RuntimeError", - "driver_process_terminated": True, - "driver_kill_fallback_used": False, - "browser_process_terminated": False, - "chromium_process_set_terminated": False, - } - - trial.__globals__["_run_agent_task_forced_close_browser_pass"] = fake_browser_pass - result = trial( - pathlib.Path("/unused/chrome"), - pathlib.Path("/unused/chromedriver"), - "http://127.0.0.1/fixture", - 1, - ) - self.assertIs(result["passed"], False) - self.assertEqual(result["failure_type"], "RuntimeError") - self.assertIs(result["driver_process_terminated"], True) - self.assertIs(result["driver_kill_fallback_used"], False) - self.assertIs(result["browser_process_terminated"], False) - self.assertIs(result["chromium_process_set_terminated"], False) - self.assertIs(result["profile_cleaned"], True) - - def test_forced_close_trial_does_not_invent_process_set_teardown_evidence(self) -> None: - """A failure before process-set capture may retain root proof but not invent set proof.""" + def test_forced_close_trial_preserves_successful_kill_fallback_evidence(self) -> None: + """Successful forced-close trials must still say when ChromeDriver needed SIGKILL.""" namespace = runpy.run_path( - str(RUNNER), run_name="forced_close_failure_root_only_trial" + str(RUNNER), run_name="forced_close_driver_kill_fallback_trial_contract" ) trial = namespace["_run_agent_task_forced_close_trial"] @@ -304,10 +266,13 @@ def fake_browser_pass( _profile_dir: str, ) -> dict[str, object]: return { - "failure_type": "RuntimeError", + "browser_version": namespace["PINNED_CHROME_VERSION"], + "forced_close_detected": True, + "session_survived": True, "driver_process_terminated": True, - "driver_kill_fallback_used": False, + "driver_kill_fallback_used": True, "browser_process_terminated": True, + "chromium_process_set_terminated": True, } trial.__globals__["_run_agent_task_forced_close_browser_pass"] = fake_browser_pass @@ -315,31 +280,16 @@ def fake_browser_pass( pathlib.Path("/unused/chrome"), pathlib.Path("/unused/chromedriver"), "http://127.0.0.1/fixture", - 2, + 3, ) - self.assertIs(result["passed"], False) + + self.assertIs(result["passed"], True) self.assertIs(result["driver_process_terminated"], True) - self.assertIs(result["driver_kill_fallback_used"], False) + self.assertIs(result["driver_kill_fallback_used"], True) self.assertIs(result["browser_process_terminated"], True) - self.assertNotIn("chromium_process_set_terminated", result) + self.assertIs(result["chromium_process_set_terminated"], True) self.assertIs(result["profile_cleaned"], True) - def test_main_forced_close_gate_requires_process_termination(self) -> None: - """Compatibility success must reject a live forced-close process identity.""" - - runner = RUNNER.read_text(encoding="utf-8") - start = runner.index("forced_close_surfaces_complete = all(") - end = runner.index("\n\n evidence = {", start) - gate = runner[start:end] - for expected in ( - 'trial.get("driver_process_terminated") is True', - 'isinstance(trial.get("driver_kill_fallback_used"), bool)', - 'trial.get("browser_process_terminated") is True', - 'trial.get("chromium_process_set_terminated") is True', - ): - with self.subTest(expected=expected): - self.assertIn(expected, gate) - if __name__ == "__main__": unittest.main() From 0cafe07d361322d07907cfb8ff37676acba601a0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 09:18:52 -0700 Subject: [PATCH 14/30] test(browser): accept multiline bounded session cleanup call --- tests/test_agent_task_forced_close_session_cleanup_contract.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_agent_task_forced_close_session_cleanup_contract.py b/tests/test_agent_task_forced_close_session_cleanup_contract.py index 326803a97..15e37a991 100644 --- a/tests/test_agent_task_forced_close_session_cleanup_contract.py +++ b/tests/test_agent_task_forced_close_session_cleanup_contract.py @@ -68,7 +68,8 @@ def test_forced_close_pass_does_not_suppress_session_cleanup_failure(self) -> No self.assertNotIn("contextlib.suppress(Exception)", browser_pass) self.assertIn("session_cleanup_failure_type", browser_pass) - self.assertIn("_delete_webdriver_session_bounded(driver_port, session_id)", browser_pass) + cleanup_call = browser_pass.index("_delete_webdriver_session_bounded(") + self.assertIn("driver_port, session_id", browser_pass[cleanup_call:cleanup_call + 160]) self.assertIn('"session_cleanup_failure_type"', browser_pass) self.assertIn('"WebDriverSessionCleanupError"', browser_pass) From 7a7923e364b5040773a55d702e7b75e8674040a9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 18:44:35 -0700 Subject: [PATCH 15/30] fix(browser): restore typed forced-close cleanup envelope --- scripts/ci/run_mv3_compatibility.py | 282 ++++++++++++++++++---------- 1 file changed, 179 insertions(+), 103 deletions(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index 8b810caf4..19161a15d 100644 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -5,7 +5,7 @@ W3C WebDriver HTTP protocol only to prove that a real Chrome for Testing build can load the controlled MV3 fixture and repeatedly exercise service-worker, content-script, storage, declarative-net-request, tabs, windows, scripting, -commands, side-panel, bookmarks, history, real browser-click, and +commands, side-panel, bookmarks, history, real-browser-click, and restart-persistence behavior. It also executes the controlled Agent Task fixture with extensions disabled in a fresh profile, locates the controlled action targets by exact browser-computed role/name evidence, performs real WebDriver @@ -504,48 +504,23 @@ def _wait_for_linux_process_identity_exit( def _read_linux_process_identity_set( process_ids: tuple[int, ...], - *, - required_root_identity: tuple[int, int], -) -> tuple[tuple[tuple[int, int], ...], int]: - """Bind live sampled PIDs while explicitly accounting for already-exited descendants.""" +) -> tuple[tuple[int, int], ...]: + """Bind one bounded sampled process set to exact Linux PID/start-time identities.""" if not process_ids or len(process_ids) > MAX_BROWSER_PROCESS_TREE_SIZE: raise ValueError("invalid Linux process identity-set size") if len(set(process_ids)) != len(process_ids): raise ValueError("Linux process identity-set PIDs must be unique") - if not isinstance(required_root_identity, tuple) or len(required_root_identity) != 2: - raise ValueError("invalid Linux root process identity") - root_process_id, root_start_time_ticks = required_root_identity - if ( - isinstance(root_process_id, bool) - or not isinstance(root_process_id, int) - or root_process_id <= 0 - or isinstance(root_start_time_ticks, bool) - or not isinstance(root_start_time_ticks, int) - or root_start_time_ticks <= 0 - ): - raise ValueError("invalid Linux root process identity") - if process_ids[0] != root_process_id: - raise ValueError("Linux process identity set must start with the required root PID") identities: list[tuple[int, int]] = [] - pre_shutdown_exit_count = 0 - for index, process_id in enumerate(process_ids): + for process_id in process_ids: if isinstance(process_id, bool) or not isinstance(process_id, int) or process_id <= 0: raise ValueError("invalid Linux process identifier") identity = _read_linux_proc_stat_process_identity(process_id) - if index == 0: - if identity is None: - raise RuntimeError("Linux Chromium root process identity disappeared before shutdown capture") - if identity != required_root_identity: - raise RuntimeError("Linux Chromium root process identity changed before shutdown capture") - identities.append(identity) - continue if identity is None: - pre_shutdown_exit_count += 1 - continue + raise RuntimeError("Linux Chromium process disappeared before shutdown identity capture") identities.append(identity) - return tuple(identities), pre_shutdown_exit_count + return tuple(identities) def _wait_for_linux_process_identity_set_exit( @@ -798,6 +773,58 @@ def _exercise_real_click(driver_port: int, session_id: str) -> str: return str(text) +def _validate_agent_task_submitted_state(state: object) -> None: + """Accept only the controlled submitted marker without echoing page state.""" + + if state != "submitted": + raise RuntimeError("Agent Task state post-condition failed") + + +def _delete_webdriver_session_bounded(driver_port: int, session_id: str) -> str | None: + """Delete one validated WebDriver session and retain only reviewed failure types.""" + + try: + _json_request( + driver_port, + "DELETE", + _webdriver_path(session_id, ""), + {}, + ) + except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: + return type(exc).__name__ + return None + + +def _terminate_owned_process_bounded(process: Any) -> tuple[bool, str | None, bool]: + """Terminate one owned child under bounded waits and retain typed fallback evidence.""" + + try: + process.terminate() + except ProcessLookupError: + return True, None, False + except OSError as exc: + return False, type(exc).__name__, False + + try: + process.wait(timeout=PROCESS_EXIT_TIMEOUT_SECONDS) + return True, None, False + except subprocess.TimeoutExpired: + try: + process.kill() + except ProcessLookupError: + return True, None, True + except OSError as exc: + return False, type(exc).__name__, True + + try: + process.wait(timeout=PROCESS_EXIT_TIMEOUT_SECONDS) + return True, None, True + except subprocess.TimeoutExpired as exc: + return False, type(exc).__name__, True + except OSError as exc: + return False, type(exc).__name__, True + + def _run_browser_pass( chrome_bin: pathlib.Path, chromedriver_bin: pathlib.Path, @@ -942,13 +969,7 @@ def _run_restart_trial( profile_dir, "persisted", ) - except ( - OSError, - ValueError, - RuntimeError, - json.JSONDecodeError, - subprocess.TimeoutExpired, - ) as exc: + except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: failure_type = type(exc).__name__ profile_cleaned = not profile_path.exists() if not profile_cleaned: @@ -1020,7 +1041,6 @@ def _run_agent_task_browser_pass( browser_process_id: int | None = None browser_process_start_time_ticks: int | None = None chromium_process_identities: tuple[tuple[int, int], ...] | None = None - chromium_process_pre_shutdown_exit_count: int | None = None browser_failure_type: str | None = None result: dict[str, Any] | None = None driver = subprocess.Popen( @@ -1192,8 +1212,7 @@ def _run_agent_task_browser_pass( "GET", _element_command_path(session_id, result_element, "/text"), ).get("value") - if state != "submitted": - raise RuntimeError(f"Agent Task state post-condition failed: {state!r}") + _validate_agent_task_submitted_state(state) if text != AGENT_TASK_INPUT_VALUE: raise RuntimeError("Agent Task result did not match the synthetic typed value") structured_value_sha256 = _hash_agent_task_structured_value(text) @@ -1203,15 +1222,8 @@ def _run_agent_task_browser_pass( browser_process_id, process_evidence, ) - ( - chromium_process_identities, - chromium_process_pre_shutdown_exit_count, - ) = _read_linux_process_identity_set( - chromium_process_ids, - required_root_identity=( - browser_process_id, - browser_process_start_time_ticks, - ), + chromium_process_identities = _read_linux_process_identity_set( + chromium_process_ids ) browser_process_rss_bytes = _sample_linux_process_rss_bytes(browser_process_id) chromium_process_set_rss_bytes = _sample_linux_process_set_rss_bytes( @@ -1239,9 +1251,6 @@ def _run_agent_task_browser_pass( "saved_credential_services_disabled": True, "browser_process_rss_bytes": browser_process_rss_bytes, "chromium_process_count": chromium_process_count, - "chromium_process_pre_shutdown_exit_count": ( - chromium_process_pre_shutdown_exit_count - ), "chromium_process_set_rss_bytes": chromium_process_set_rss_bytes, "semantic_observation_bytes": semantic_observation_bytes, "action_latency_ms": action_latency_ms, @@ -1293,8 +1302,6 @@ def _run_agent_task_browser_pass( raise RuntimeError("Agent Task browser pass returned no result after shutdown") if chromium_process_set_terminated is None: raise RuntimeError("Agent Task Chromium process identities were not captured") - if chromium_process_pre_shutdown_exit_count is None: - raise RuntimeError("Agent Task Chromium pre-shutdown exit count was not captured") if not browser_process_terminated: raise RuntimeError("Agent Task browser process did not terminate") if not chromium_process_set_terminated: @@ -1327,13 +1334,7 @@ def _run_agent_task_trial( fixture_url, profile_dir, ) - except ( - OSError, - ValueError, - RuntimeError, - json.JSONDecodeError, - subprocess.TimeoutExpired, - ) as exc: + except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: failure_type = type(exc).__name__ profile_cleaned = not profile_path.exists() if not profile_cleaned: @@ -1398,9 +1399,6 @@ def _run_agent_task_trial( "browser_process_rss_bytes": result["browser_process_rss_bytes"], "browser_process_terminated": result["browser_process_terminated"], "chromium_process_count": result["chromium_process_count"], - "chromium_process_pre_shutdown_exit_count": result[ - "chromium_process_pre_shutdown_exit_count" - ], "chromium_process_set_rss_bytes": result["chromium_process_set_rss_bytes"], "chromium_process_set_terminated": result["chromium_process_set_terminated"], "semantic_observation_bytes": result["semantic_observation_bytes"], @@ -1475,6 +1473,11 @@ def _run_agent_task_forced_close_browser_pass( browser_process_id: int | None = None browser_process_start_time_ticks: int | None = None chromium_process_identities: tuple[tuple[int, int], ...] | None = None + browser_failure_type: str | None = None + session_cleanup_failure_type: str | None = None + driver_process_terminated: bool | None = None + driver_cleanup_failure_type: str | None = None + driver_kill_fallback_used = False result: dict[str, Any] | None = None driver = subprocess.Popen( [str(chromedriver_bin), f"--port={driver_port}", "--allowed-ips=127.0.0.1"], @@ -1586,14 +1589,8 @@ def _run_agent_task_forced_close_browser_pass( browser_process_id, process_evidence, ) - chromium_process_identities, _pre_shutdown_exit_count = ( - _read_linux_process_identity_set( - chromium_process_ids, - required_root_identity=( - browser_process_id, - browser_process_start_time_ticks, - ), - ) + chromium_process_identities = _read_linux_process_identity_set( + chromium_process_ids ) forced_close_detected = _force_close_agent_task_context(driver_port, session_id) @@ -1619,39 +1616,75 @@ def _run_agent_task_forced_close_browser_pass( "forced_close_detected": forced_close_detected, "session_survived": True, } + except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: + browser_failure_type = type(exc).__name__ + if browser_process_id is None or browser_process_start_time_ticks is None: + raise finally: if session_id is not None: - with contextlib.suppress(Exception): - _json_request( - driver_port, - "DELETE", - _webdriver_path(session_id, ""), - {}, - ) - driver.terminate() - try: - driver.wait(timeout=5) - except subprocess.TimeoutExpired: - driver.kill() - driver.wait(timeout=5) + session_cleanup_failure_type = _delete_webdriver_session_bounded( + driver_port, session_id + ) + ( + driver_process_terminated, + driver_cleanup_failure_type, + driver_kill_fallback_used, + ) = _terminate_owned_process_bounded(driver) if browser_process_id is None or browser_process_start_time_ticks is None: raise RuntimeError("Agent Task forced-close browser process identity was not captured") - if chromium_process_identities is None: - raise RuntimeError("Agent Task forced-close Chromium process identities were not captured") browser_process_terminated = _wait_for_linux_process_identity_exit( browser_process_id, browser_process_start_time_ticks, ) - chromium_process_set_terminated = _wait_for_linux_process_identity_set_exit( - chromium_process_identities - ) + chromium_process_set_terminated: bool | None = None + if chromium_process_identities is not None: + chromium_process_set_terminated = _wait_for_linux_process_identity_set_exit( + chromium_process_identities + ) + if ( + browser_failure_type is not None + or session_cleanup_failure_type is not None + or driver_cleanup_failure_type is not None + ): + primary_failure_type = browser_failure_type + if primary_failure_type is None and session_cleanup_failure_type is not None: + primary_failure_type = "WebDriverSessionCleanupError" + if primary_failure_type is None: + primary_failure_type = driver_cleanup_failure_type + if primary_failure_type is None: + raise RuntimeError("Agent Task forced-close failure evidence lost its primary type") + failure_evidence: dict[str, Any] = { + "failure_type": primary_failure_type, + "driver_process_terminated": driver_process_terminated, + "driver_kill_fallback_used": driver_kill_fallback_used, + "browser_process_terminated": browser_process_terminated, + } + if session_cleanup_failure_type is not None: + failure_evidence["session_cleanup_failure_type"] = ( + session_cleanup_failure_type + ) + if driver_cleanup_failure_type is not None and ( + browser_failure_type is not None or session_cleanup_failure_type is not None + ): + failure_evidence["cleanup_failure_type"] = driver_cleanup_failure_type + if chromium_process_set_terminated is not None: + failure_evidence["chromium_process_set_terminated"] = ( + chromium_process_set_terminated + ) + return failure_evidence if result is None: raise RuntimeError("Agent Task forced-close browser pass returned no result after shutdown") + if chromium_process_set_terminated is None: + raise RuntimeError("Agent Task forced-close Chromium process identities were not captured") + if driver_process_terminated is not True: + raise RuntimeError("Agent Task forced-close ChromeDriver process did not terminate") if not browser_process_terminated: raise RuntimeError("Agent Task forced-close browser process did not terminate") if not chromium_process_set_terminated: raise RuntimeError("Agent Task forced-close Chromium process set did not terminate") + result["driver_process_terminated"] = True + result["driver_kill_fallback_used"] = driver_kill_fallback_used result["browser_process_terminated"] = True result["chromium_process_set_terminated"] = True return result @@ -1680,13 +1713,7 @@ def _run_agent_task_forced_close_trial( fixture_url, profile_dir, ) - except ( - OSError, - ValueError, - RuntimeError, - json.JSONDecodeError, - subprocess.TimeoutExpired, - ) as exc: + except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: failure_type = type(exc).__name__ profile_cleaned = not profile_path.exists() if not profile_cleaned: @@ -1705,6 +1732,56 @@ def _run_agent_task_forced_close_trial( } if result is None: raise RuntimeError("Agent Task forced-close browser pass returned no result") + returned_failure_type = result.get("failure_type") + if returned_failure_type is not None: + if not isinstance(returned_failure_type, str) or not returned_failure_type: + raise RuntimeError("Agent Task forced-close browser pass returned invalid failure evidence") + driver_process_terminated = result.get("driver_process_terminated") + if not isinstance(driver_process_terminated, bool): + raise RuntimeError("Agent Task forced-close browser pass returned invalid driver teardown evidence") + driver_kill_fallback_used = result.get("driver_kill_fallback_used") + if not isinstance(driver_kill_fallback_used, bool): + raise RuntimeError("Agent Task forced-close browser pass returned invalid driver fallback evidence") + browser_process_terminated = result.get("browser_process_terminated") + if not isinstance(browser_process_terminated, bool): + raise RuntimeError("Agent Task forced-close browser pass returned invalid teardown evidence") + failure_evidence: dict[str, Any] = { + "trial_number": trial_number, + "passed": False, + "failure_type": returned_failure_type, + "driver_process_terminated": driver_process_terminated, + "driver_kill_fallback_used": driver_kill_fallback_used, + "browser_process_terminated": browser_process_terminated, + "profile_cleaned": True, + "duration_ms": duration_ms, + } + if "session_cleanup_failure_type" in result: + session_cleanup_failure_type = result["session_cleanup_failure_type"] + if ( + not isinstance(session_cleanup_failure_type, str) + or not session_cleanup_failure_type + ): + raise RuntimeError( + "Agent Task forced-close browser pass returned invalid session cleanup failure evidence" + ) + failure_evidence["session_cleanup_failure_type"] = ( + session_cleanup_failure_type + ) + if "cleanup_failure_type" in result: + cleanup_failure_type = result["cleanup_failure_type"] + if not isinstance(cleanup_failure_type, str) or not cleanup_failure_type: + raise RuntimeError("Agent Task forced-close browser pass returned invalid cleanup failure evidence") + failure_evidence["cleanup_failure_type"] = cleanup_failure_type + if "chromium_process_set_terminated" in result: + chromium_process_set_terminated = result["chromium_process_set_terminated"] + if not isinstance(chromium_process_set_terminated, bool): + raise RuntimeError( + "Agent Task forced-close browser pass returned invalid process-set teardown evidence" + ) + failure_evidence["chromium_process_set_terminated"] = ( + chromium_process_set_terminated + ) + return failure_evidence return { "trial_number": trial_number, @@ -1712,6 +1789,8 @@ def _run_agent_task_forced_close_trial( "browser_version": result["browser_version"], "forced_close_detected": result["forced_close_detected"], "session_survived": result["session_survived"], + "driver_process_terminated": result["driver_process_terminated"], + "driver_kill_fallback_used": result["driver_kill_fallback_used"], "browser_process_terminated": result["browser_process_terminated"], "chromium_process_set_terminated": result["chromium_process_set_terminated"], "profile_cleaned": True, @@ -1892,11 +1971,6 @@ def main() -> int: and trial["browser_process_rss_bytes"] > 0 and isinstance(trial.get("chromium_process_count"), int) and 0 < trial["chromium_process_count"] <= MAX_BROWSER_PROCESS_TREE_SIZE - and isinstance(trial.get("chromium_process_pre_shutdown_exit_count"), int) - and not isinstance(trial["chromium_process_pre_shutdown_exit_count"], bool) - and 0 - <= trial["chromium_process_pre_shutdown_exit_count"] - < trial["chromium_process_count"] and isinstance(trial.get("chromium_process_set_rss_bytes"), int) and trial["chromium_process_set_rss_bytes"] > 0 and isinstance(trial.get("semantic_observation_bytes"), int) @@ -1919,6 +1993,8 @@ def main() -> int: forced_close_surfaces_complete = all( trial.get("forced_close_detected") is True and trial.get("session_survived") is True + and trial.get("driver_process_terminated") is True + and isinstance(trial.get("driver_kill_fallback_used"), bool) and trial.get("browser_process_terminated") is True and trial.get("chromium_process_set_terminated") is True and trial.get("profile_cleaned") is True From 52bd685d8f83356fba5b822b8ba431ca2d182527 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 18:58:23 -0700 Subject: [PATCH 16/30] fix(browser): preserve parent teardown contracts after restack --- scripts/ci/run_mv3_compatibility.py | 91 ++++++++++++++++++++++++----- 1 file changed, 78 insertions(+), 13 deletions(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index 19161a15d..82c526471 100644 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -504,23 +504,48 @@ def _wait_for_linux_process_identity_exit( def _read_linux_process_identity_set( process_ids: tuple[int, ...], -) -> tuple[tuple[int, int], ...]: - """Bind one bounded sampled process set to exact Linux PID/start-time identities.""" + *, + required_root_identity: tuple[int, int], +) -> tuple[tuple[tuple[int, int], ...], int]: + """Bind live sampled PIDs while explicitly accounting for already-exited descendants.""" if not process_ids or len(process_ids) > MAX_BROWSER_PROCESS_TREE_SIZE: raise ValueError("invalid Linux process identity-set size") if len(set(process_ids)) != len(process_ids): raise ValueError("Linux process identity-set PIDs must be unique") + if not isinstance(required_root_identity, tuple) or len(required_root_identity) != 2: + raise ValueError("invalid Linux root process identity") + root_process_id, root_start_time_ticks = required_root_identity + if ( + isinstance(root_process_id, bool) + or not isinstance(root_process_id, int) + or root_process_id <= 0 + or isinstance(root_start_time_ticks, bool) + or not isinstance(root_start_time_ticks, int) + or root_start_time_ticks <= 0 + ): + raise ValueError("invalid Linux root process identity") + if process_ids[0] != root_process_id: + raise ValueError("Linux process identity set must start with the required root PID") identities: list[tuple[int, int]] = [] - for process_id in process_ids: + pre_shutdown_exit_count = 0 + for index, process_id in enumerate(process_ids): if isinstance(process_id, bool) or not isinstance(process_id, int) or process_id <= 0: raise ValueError("invalid Linux process identifier") identity = _read_linux_proc_stat_process_identity(process_id) + if index == 0: + if identity is None: + raise RuntimeError("Linux Chromium root process identity disappeared before shutdown capture") + if identity != required_root_identity: + raise RuntimeError("Linux Chromium root process identity changed before shutdown capture") + identities.append(identity) + continue if identity is None: - raise RuntimeError("Linux Chromium process disappeared before shutdown identity capture") + pre_shutdown_exit_count += 1 + continue identities.append(identity) - return tuple(identities) + return tuple(identities), pre_shutdown_exit_count def _wait_for_linux_process_identity_set_exit( @@ -969,7 +994,13 @@ def _run_restart_trial( profile_dir, "persisted", ) - except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: + except ( + OSError, + ValueError, + RuntimeError, + json.JSONDecodeError, + subprocess.TimeoutExpired, + ) as exc: failure_type = type(exc).__name__ profile_cleaned = not profile_path.exists() if not profile_cleaned: @@ -1041,6 +1072,7 @@ def _run_agent_task_browser_pass( browser_process_id: int | None = None browser_process_start_time_ticks: int | None = None chromium_process_identities: tuple[tuple[int, int], ...] | None = None + chromium_process_pre_shutdown_exit_count: int | None = None browser_failure_type: str | None = None result: dict[str, Any] | None = None driver = subprocess.Popen( @@ -1222,8 +1254,15 @@ def _run_agent_task_browser_pass( browser_process_id, process_evidence, ) - chromium_process_identities = _read_linux_process_identity_set( - chromium_process_ids + ( + chromium_process_identities, + chromium_process_pre_shutdown_exit_count, + ) = _read_linux_process_identity_set( + chromium_process_ids, + required_root_identity=( + browser_process_id, + browser_process_start_time_ticks, + ), ) browser_process_rss_bytes = _sample_linux_process_rss_bytes(browser_process_id) chromium_process_set_rss_bytes = _sample_linux_process_set_rss_bytes( @@ -1251,6 +1290,9 @@ def _run_agent_task_browser_pass( "saved_credential_services_disabled": True, "browser_process_rss_bytes": browser_process_rss_bytes, "chromium_process_count": chromium_process_count, + "chromium_process_pre_shutdown_exit_count": ( + chromium_process_pre_shutdown_exit_count + ), "chromium_process_set_rss_bytes": chromium_process_set_rss_bytes, "semantic_observation_bytes": semantic_observation_bytes, "action_latency_ms": action_latency_ms, @@ -1302,6 +1344,8 @@ def _run_agent_task_browser_pass( raise RuntimeError("Agent Task browser pass returned no result after shutdown") if chromium_process_set_terminated is None: raise RuntimeError("Agent Task Chromium process identities were not captured") + if chromium_process_pre_shutdown_exit_count is None: + raise RuntimeError("Agent Task Chromium pre-shutdown exit count was not captured") if not browser_process_terminated: raise RuntimeError("Agent Task browser process did not terminate") if not chromium_process_set_terminated: @@ -1334,7 +1378,13 @@ def _run_agent_task_trial( fixture_url, profile_dir, ) - except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: + except ( + OSError, + ValueError, + RuntimeError, + json.JSONDecodeError, + subprocess.TimeoutExpired, + ) as exc: failure_type = type(exc).__name__ profile_cleaned = not profile_path.exists() if not profile_cleaned: @@ -1399,6 +1449,9 @@ def _run_agent_task_trial( "browser_process_rss_bytes": result["browser_process_rss_bytes"], "browser_process_terminated": result["browser_process_terminated"], "chromium_process_count": result["chromium_process_count"], + "chromium_process_pre_shutdown_exit_count": result[ + "chromium_process_pre_shutdown_exit_count" + ], "chromium_process_set_rss_bytes": result["chromium_process_set_rss_bytes"], "chromium_process_set_terminated": result["chromium_process_set_terminated"], "semantic_observation_bytes": result["semantic_observation_bytes"], @@ -1589,8 +1642,14 @@ def _run_agent_task_forced_close_browser_pass( browser_process_id, process_evidence, ) - chromium_process_identities = _read_linux_process_identity_set( - chromium_process_ids + chromium_process_identities, _pre_shutdown_exit_count = ( + _read_linux_process_identity_set( + chromium_process_ids, + required_root_identity=( + browser_process_id, + browser_process_start_time_ticks, + ), + ) ) forced_close_detected = _force_close_agent_task_context(driver_port, session_id) @@ -1713,7 +1772,13 @@ def _run_agent_task_forced_close_trial( fixture_url, profile_dir, ) - except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: + except ( + OSError, + ValueError, + RuntimeError, + json.JSONDecodeError, + subprocess.TimeoutExpired, + ) as exc: failure_type = type(exc).__name__ profile_cleaned = not profile_path.exists() if not profile_cleaned: @@ -2072,4 +2137,4 @@ def main() -> int: if __name__ == "__main__": - raise SystemExit(main()) + raise SystemExit(main()) \ No newline at end of file From 152ca368ea2c93989eb9e9c0f533505021f9a8a6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 19:45:34 -0700 Subject: [PATCH 17/30] test(mv3): reject untyped cleanup suppression --- tests/test_mv3_compatibility_contract.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/test_mv3_compatibility_contract.py b/tests/test_mv3_compatibility_contract.py index 10872ddac..ce71a93ac 100644 --- a/tests/test_mv3_compatibility_contract.py +++ b/tests/test_mv3_compatibility_contract.py @@ -120,6 +120,14 @@ def test_runner_transport_cannot_follow_dynamic_url_schemes(self) -> None: self.assertNotIn("urllib.request", runner) self.assertNotIn("urllib.error", runner) + def test_runner_cleanup_cannot_suppress_untyped_failures(self) -> None: + """Cleanup failures must stay typed evidence instead of becoming false-green runs.""" + + runner = RUNNER.read_text(encoding="utf-8") + self.assertNotIn("contextlib.suppress(Exception)", runner) + self.assertIn("_delete_webdriver_session_bounded", runner) + self.assertIn("_terminate_owned_process_bounded", runner) + def test_runner_accepts_real_chromedriver_element_ids_without_path_injection(self) -> None: """ChromeDriver dotted element IDs must work while path syntax stays fail-closed.""" @@ -199,4 +207,4 @@ def test_doctoring_records_primary_chromium_evidence(self) -> None: if __name__ == "__main__": - unittest.main() + unittest.main() \ No newline at end of file From b0aabe019ea97eeeca137ea664ad8e3846e7d452 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 19:51:41 -0700 Subject: [PATCH 18/30] fix(mv3): preserve typed cleanup failures --- scripts/ci/run_mv3_compatibility.py | 108 ++++++++++++++++++++-------- 1 file changed, 78 insertions(+), 30 deletions(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index 82c526471..13a1aa251 100644 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -16,7 +16,6 @@ from __future__ import annotations -import contextlib import hashlib import http.client import http.server @@ -861,6 +860,7 @@ def _run_browser_pass( driver_port = _free_loopback_port() session_id: str | None = None + primary_error: BaseException | None = None driver = subprocess.Popen( [str(chromedriver_bin), f"--port={driver_port}", "--allowed-ips=127.0.0.1"], stdout=subprocess.DEVNULL, @@ -945,21 +945,49 @@ def _run_browser_pass( "real-browser-click": click_result == "clicked", }, } + except BaseException as error: # noqa: BLE001 - re-raised unchanged after cleanup. + primary_error = error + raise finally: - if session_id is not None: - with contextlib.suppress(Exception): - _json_request( - driver_port, - "DELETE", - _webdriver_path(session_id, ""), - {}, + session_cleanup_failure_type = ( + _delete_webdriver_session_bounded(driver_port, session_id) + if session_id is not None + else None + ) + ( + driver_process_terminated, + driver_cleanup_failure_type, + driver_kill_fallback_used, + ) = _terminate_owned_process_bounded(driver) + if primary_error is not None: + if session_cleanup_failure_type is not None: + primary_error.add_note( + "WebDriver session cleanup also failed after the primary browser-pass " + f"failure: {session_cleanup_failure_type}" ) - driver.terminate() - try: - driver.wait(timeout=5) - except subprocess.TimeoutExpired: - driver.kill() - driver.wait(timeout=5) + if driver_cleanup_failure_type is not None or driver_process_terminated is not True: + cleanup_type = driver_cleanup_failure_type or "ProcessTerminationFailure" + primary_error.add_note( + "ChromeDriver process teardown also failed after the primary browser-pass " + f"failure: {cleanup_type}" + ) + elif session_cleanup_failure_type is not None: + cleanup_error = RuntimeError( + "WebDriver session cleanup failed after bounded process teardown" + ) + if driver_cleanup_failure_type is not None or driver_process_terminated is not True: + cleanup_type = driver_cleanup_failure_type or "ProcessTerminationFailure" + cleanup_error.add_note( + "ChromeDriver process teardown also failed: " + f"{cleanup_type}; kill_fallback_used={driver_kill_fallback_used}" + ) + raise cleanup_error + elif driver_cleanup_failure_type is not None or driver_process_terminated is not True: + cleanup_type = driver_cleanup_failure_type or "ProcessTerminationFailure" + raise RuntimeError( + "ChromeDriver process teardown failed after browser pass: " + f"{cleanup_type}; kill_fallback_used={driver_kill_fallback_used}" + ) def _run_restart_trial( @@ -1074,6 +1102,10 @@ def _run_agent_task_browser_pass( chromium_process_identities: tuple[tuple[int, int], ...] | None = None chromium_process_pre_shutdown_exit_count: int | None = None browser_failure_type: str | None = None + session_cleanup_failure_type: str | None = None + driver_process_terminated: bool | None = None + driver_cleanup_failure_type: str | None = None + driver_kill_fallback_used = False result: dict[str, Any] | None = None driver = subprocess.Popen( [str(chromedriver_bin), f"--port={driver_port}", "--allowed-ips=127.0.0.1"], @@ -1305,19 +1337,14 @@ def _run_agent_task_browser_pass( raise finally: if session_id is not None: - with contextlib.suppress(Exception): - _json_request( - driver_port, - "DELETE", - _webdriver_path(session_id, ""), - {}, - ) - driver.terminate() - try: - driver.wait(timeout=5) - except subprocess.TimeoutExpired: - driver.kill() - driver.wait(timeout=5) + session_cleanup_failure_type = _delete_webdriver_session_bounded( + driver_port, session_id + ) + ( + driver_process_terminated, + driver_cleanup_failure_type, + driver_kill_fallback_used, + ) = _terminate_owned_process_bounded(driver) if browser_process_id is None or browser_process_start_time_ticks is None: raise RuntimeError("Agent Task browser process identity was not captured") @@ -1330,11 +1357,30 @@ def _run_agent_task_browser_pass( chromium_process_set_terminated = _wait_for_linux_process_identity_set_exit( chromium_process_identities ) - if browser_failure_type is not None: + if ( + browser_failure_type is not None + or session_cleanup_failure_type is not None + or driver_cleanup_failure_type is not None + ): + primary_failure_type = browser_failure_type + if primary_failure_type is None and session_cleanup_failure_type is not None: + primary_failure_type = "WebDriverSessionCleanupError" + if primary_failure_type is None: + primary_failure_type = driver_cleanup_failure_type + if primary_failure_type is None: + raise RuntimeError("Agent Task failure evidence lost its primary type") failure_evidence: dict[str, Any] = { - "failure_type": browser_failure_type, + "failure_type": primary_failure_type, + "driver_process_terminated": driver_process_terminated, + "driver_kill_fallback_used": driver_kill_fallback_used, "browser_process_terminated": browser_process_terminated, } + if session_cleanup_failure_type is not None: + failure_evidence["session_cleanup_failure_type"] = session_cleanup_failure_type + if driver_cleanup_failure_type is not None and ( + browser_failure_type is not None or session_cleanup_failure_type is not None + ): + failure_evidence["cleanup_failure_type"] = driver_cleanup_failure_type if chromium_process_set_terminated is not None: failure_evidence["chromium_process_set_terminated"] = ( chromium_process_set_terminated @@ -1346,6 +1392,8 @@ def _run_agent_task_browser_pass( raise RuntimeError("Agent Task Chromium process identities were not captured") if chromium_process_pre_shutdown_exit_count is None: raise RuntimeError("Agent Task Chromium pre-shutdown exit count was not captured") + if driver_process_terminated is not True: + raise RuntimeError("Agent Task ChromeDriver process did not terminate") if not browser_process_terminated: raise RuntimeError("Agent Task browser process did not terminate") if not chromium_process_set_terminated: @@ -2137,4 +2185,4 @@ def main() -> int: if __name__ == "__main__": - raise SystemExit(main()) \ No newline at end of file + raise SystemExit(main()) From a1ed848d8de9f3087991b38886fad81379070c7b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 19:53:50 -0700 Subject: [PATCH 19/30] test(mv3): preserve primary failure precedence contract --- tests/test_agent_task_failure_process_termination_contract.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_agent_task_failure_process_termination_contract.py b/tests/test_agent_task_failure_process_termination_contract.py index 7aae4d475..a2ec5a0c5 100644 --- a/tests/test_agent_task_failure_process_termination_contract.py +++ b/tests/test_agent_task_failure_process_termination_contract.py @@ -26,7 +26,8 @@ def test_browser_pass_retains_failure_process_termination_evidence(self) -> None for expected in ( "browser_failure_type: str | None = None", "browser_failure_type = type(exc).__name__", - '"failure_type": browser_failure_type', + "primary_failure_type = browser_failure_type", + '"failure_type": primary_failure_type', '"browser_process_terminated": browser_process_terminated', ): with self.subTest(expected=expected): From 618fee1baf4d3348d6df73e04eac01e686f5cd61 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 21:01:37 -0700 Subject: [PATCH 20/30] test(mv3): retain WebDriver HTTP cleanup failures --- tests/test_mv3_compatibility_contract.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/tests/test_mv3_compatibility_contract.py b/tests/test_mv3_compatibility_contract.py index ce71a93ac..d2da5bc6f 100644 --- a/tests/test_mv3_compatibility_contract.py +++ b/tests/test_mv3_compatibility_contract.py @@ -2,6 +2,7 @@ from __future__ import annotations +import http.client import json import pathlib import runpy @@ -128,6 +129,21 @@ def test_runner_cleanup_cannot_suppress_untyped_failures(self) -> None: self.assertIn("_delete_webdriver_session_bounded", runner) self.assertIn("_terminate_owned_process_bounded", runner) + def test_runner_session_cleanup_classifies_http_protocol_failure(self) -> None: + """Malformed ChromeDriver HTTP during cleanup must remain typed evidence.""" + + namespace = runpy.run_path(str(RUNNER), run_name="mv3_cleanup_http_failure") + delete_session = namespace["_delete_webdriver_session_bounded"] + + def fail_with_bad_status(*_args: object, **_kwargs: object) -> dict[str, object]: + raise http.client.BadStatusLine("malformed status line") + + delete_session.__globals__["_json_request"] = fail_with_bad_status + self.assertEqual( + delete_session(9515, "controlled-session"), + "BadStatusLine", + ) + def test_runner_accepts_real_chromedriver_element_ids_without_path_injection(self) -> None: """ChromeDriver dotted element IDs must work while path syntax stays fail-closed.""" @@ -207,4 +223,4 @@ def test_doctoring_records_primary_chromium_evidence(self) -> None: if __name__ == "__main__": - unittest.main() \ No newline at end of file + unittest.main() From d4cf1060c072889b12e582d90126f0221aa02341 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 21:09:14 -0700 Subject: [PATCH 21/30] fix(mv3): classify WebDriver HTTP cleanup failures --- scripts/ci/run_mv3_compatibility.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index 13a1aa251..6f6342007 100644 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -814,7 +814,13 @@ def _delete_webdriver_session_bounded(driver_port: int, session_id: str) -> str _webdriver_path(session_id, ""), {}, ) - except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: + except ( + OSError, + ValueError, + RuntimeError, + json.JSONDecodeError, + http.client.HTTPException, + ) as exc: return type(exc).__name__ return None @@ -2185,4 +2191,4 @@ def main() -> int: if __name__ == "__main__": - raise SystemExit(main()) + raise SystemExit(main()) \ No newline at end of file From 4749131333f4b229464224358c4e5d934bf0d6ed Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 21:21:45 -0700 Subject: [PATCH 22/30] test(mv3): retry transient startup HTTP protocol failure --- tests/test_mv3_compatibility_contract.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/test_mv3_compatibility_contract.py b/tests/test_mv3_compatibility_contract.py index d2da5bc6f..71734b0c8 100644 --- a/tests/test_mv3_compatibility_contract.py +++ b/tests/test_mv3_compatibility_contract.py @@ -144,6 +144,23 @@ def fail_with_bad_status(*_args: object, **_kwargs: object) -> dict[str, object] "BadStatusLine", ) + def test_runner_startup_retries_http_protocol_failure(self) -> None: + """A transient malformed ChromeDriver startup response must be retried boundedly.""" + + namespace = runpy.run_path(str(RUNNER), run_name="mv3_startup_http_failure") + wait_for_driver = namespace["_wait_for_driver"] + attempts = [0] + + def transient_bad_status(*_args: object, **_kwargs: object) -> dict[str, object]: + attempts[0] += 1 + if attempts[0] == 1: + raise http.client.BadStatusLine("malformed status line") + return {"value": {"ready": True}} + + wait_for_driver.__globals__["_json_request"] = transient_bad_status + wait_for_driver(9515) + self.assertEqual(attempts[0], 2) + def test_runner_accepts_real_chromedriver_element_ids_without_path_injection(self) -> None: """ChromeDriver dotted element IDs must work while path syntax stays fail-closed.""" From fb5a9c31948ac34ac02a8ad383178b306ee57b9d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 22:07:04 -0700 Subject: [PATCH 23/30] fix(mv3): retry malformed startup status line --- scripts/ci/run_mv3_compatibility.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index 6f6342007..2c129d1c7 100644 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -150,7 +150,13 @@ def _wait_for_driver(driver_port: int) -> None: status = _json_request(driver_port, "GET", "/status", timeout=1.0) if status.get("value", {}).get("ready") is True: return - except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: + except ( + OSError, + ValueError, + RuntimeError, + json.JSONDecodeError, + http.client.BadStatusLine, + ) as exc: last_error = exc time.sleep(0.1) raise RuntimeError(f"ChromeDriver did not become ready: {last_error}") From 3f77c7568dcc9cdf6f4f6c8d35d1508552e94bb4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 23:01:44 -0700 Subject: [PATCH 24/30] test(browser): fail closed on terminal startup errors --- ...chromedriver_startup_exception_contract.py | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 tests/test_chromedriver_startup_exception_contract.py diff --git a/tests/test_chromedriver_startup_exception_contract.py b/tests/test_chromedriver_startup_exception_contract.py new file mode 100644 index 000000000..3ae9d4e8b --- /dev/null +++ b/tests/test_chromedriver_startup_exception_contract.py @@ -0,0 +1,36 @@ +"""Fail-closed exception contract for bounded ChromeDriver startup probing.""" + +from __future__ import annotations + +import pathlib +import runpy +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +RUNNER = ROOT / "scripts" / "ci" / "run_mv3_compatibility.py" + + +class ChromeDriverStartupExceptionContractTests(unittest.TestCase): + """Keep recoverable startup transport faults separate from terminal failures.""" + + def test_runner_startup_does_not_retry_terminal_runtime_failure(self) -> None: + """A terminal WebDriver/runtime failure must fail closed before a later success.""" + + namespace = runpy.run_path(str(RUNNER), run_name="chromedriver_terminal_startup_failure") + wait_for_driver = namespace["_wait_for_driver"] + attempts = [0] + + def terminal_then_ready(*_args: object, **_kwargs: object) -> dict[str, object]: + attempts[0] += 1 + if attempts[0] == 1: + raise RuntimeError("WebDriver HTTP 403: forbidden") + return {"value": {"ready": True}} + + wait_for_driver.__globals__["_json_request"] = terminal_then_ready + with self.assertRaisesRegex(RuntimeError, "HTTP 403"): + wait_for_driver(9515) + self.assertEqual(attempts[0], 1) + + +if __name__ == "__main__": + unittest.main() From 5bc6044f377eef48a6f29076fea74f722912e1dc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 23:05:30 -0700 Subject: [PATCH 25/30] fix(browser): keep terminal startup errors fail closed --- scripts/ci/run_mv3_compatibility.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index 2c129d1c7..abaddceb9 100644 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -85,7 +85,7 @@ def _path_token(value: str, label: str) -> str: def _webdriver_path(session_id: str, suffix: str) -> str: - """Build a bounded ChromeDriver path from a validated session identifier.""" + """Build a bounded WebDriver element command path from validated identifiers.""" safe_session = _path_token(session_id, "session identifier") if suffix and not suffix.startswith("/"): @@ -152,8 +152,6 @@ def _wait_for_driver(driver_port: int) -> None: return except ( OSError, - ValueError, - RuntimeError, json.JSONDecodeError, http.client.BadStatusLine, ) as exc: From 34ab98eb81438a6c7616f6fad90c2ec192a2fc94 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 23:11:36 -0700 Subject: [PATCH 26/30] docs(browser): restore generic WebDriver path contract --- scripts/ci/run_mv3_compatibility.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index abaddceb9..e947b492b 100644 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -85,7 +85,7 @@ def _path_token(value: str, label: str) -> str: def _webdriver_path(session_id: str, suffix: str) -> str: - """Build a bounded WebDriver element command path from validated identifiers.""" + """Build a bounded ChromeDriver path from a validated session identifier.""" safe_session = _path_token(session_id, "session identifier") if suffix and not suffix.startswith("/"): From f96e55fbbaf973ca2902ce9c57cc3d75ba5027f1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 08:02:55 -0700 Subject: [PATCH 27/30] test(browser): reproduce truncated startup response --- ..._chromedriver_startup_exception_contract.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/test_chromedriver_startup_exception_contract.py b/tests/test_chromedriver_startup_exception_contract.py index 3ae9d4e8b..931b0de6f 100644 --- a/tests/test_chromedriver_startup_exception_contract.py +++ b/tests/test_chromedriver_startup_exception_contract.py @@ -2,6 +2,7 @@ from __future__ import annotations +import http.client import pathlib import runpy import unittest @@ -13,6 +14,23 @@ class ChromeDriverStartupExceptionContractTests(unittest.TestCase): """Keep recoverable startup transport faults separate from terminal failures.""" + def test_runner_startup_retries_transient_incomplete_response(self) -> None: + """A truncated startup response may be retried within the existing deadline.""" + + namespace = runpy.run_path(str(RUNNER), run_name="chromedriver_incomplete_startup_response") + wait_for_driver = namespace["_wait_for_driver"] + attempts = [0] + + def truncated_then_ready(*_args: object, **_kwargs: object) -> dict[str, object]: + attempts[0] += 1 + if attempts[0] == 1: + raise http.client.IncompleteRead(b'{"value":', 20) + return {"value": {"ready": True}} + + wait_for_driver.__globals__["_json_request"] = truncated_then_ready + wait_for_driver(9515) + self.assertEqual(attempts[0], 2) + def test_runner_startup_does_not_retry_terminal_runtime_failure(self) -> None: """A terminal WebDriver/runtime failure must fail closed before a later success.""" From 86b174b54ebbffc2a7ae83a3a9f528d7f58ad5a6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 08:12:10 -0700 Subject: [PATCH 28/30] fix(browser): retry truncated startup responses --- scripts/ci/run_mv3_compatibility.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index e947b492b..a1efeb7c2 100644 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -154,6 +154,7 @@ def _wait_for_driver(driver_port: int) -> None: OSError, json.JSONDecodeError, http.client.BadStatusLine, + http.client.IncompleteRead, ) as exc: last_error = exc time.sleep(0.1) From a3df28d3e09296e74f376b1ae32a1e6e1bc90e83 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 09:00:19 -0700 Subject: [PATCH 29/30] test(browser): reject WebDriver response detail leakage --- ..._chromedriver_error_diagnostic_contract.py | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 tests/test_chromedriver_error_diagnostic_contract.py diff --git a/tests/test_chromedriver_error_diagnostic_contract.py b/tests/test_chromedriver_error_diagnostic_contract.py new file mode 100644 index 000000000..f9f0f9734 --- /dev/null +++ b/tests/test_chromedriver_error_diagnostic_contract.py @@ -0,0 +1,75 @@ +"""Regression tests for credential-safe ChromeDriver error diagnostics.""" + +from __future__ import annotations + +import http.server +import pathlib +import runpy +import threading +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +RUNNER = ROOT / "scripts" / "ci" / "run_mv3_compatibility.py" +SECRET_MARKER = "buyer-secret-marker-must-not-reach-ci" + + +class _ErrorResponseHandler(http.server.BaseHTTPRequestHandler): + """Serve deterministic hostile ChromeDriver-shaped error responses.""" + + response_status = 403 + response_body = ( + b'{"value":{"error":"unknown error","message":"' + + SECRET_MARKER.encode("ascii") + + b'"}}' + ) + + def do_GET(self) -> None: # noqa: N802 - stdlib handler contract. + self.send_response(self.response_status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(self.response_body))) + self.end_headers() + self.wfile.write(self.response_body) + + def log_message(self, _format: str, *args: object) -> None: + """Keep the hostile marker out of test-server logging.""" + + +class ChromeDriverErrorDiagnosticContractTests(unittest.TestCase): + """ChromeDriver-controlled response bytes must not be reflected into CI errors.""" + + def _request_against(self, *, status: int) -> RuntimeError: + namespace = runpy.run_path(str(RUNNER), run_name="chromedriver_error_diagnostic_contract") + json_request = namespace["_json_request"] + + class Handler(_ErrorResponseHandler): + response_status = status + + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + with self.assertRaises(RuntimeError) as captured: + json_request(int(server.server_port), "GET", "/status") + return captured.exception + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2.0) + + def test_http_error_does_not_reflect_response_body(self) -> None: + """An HTTP error may retain its status but never ChromeDriver-controlled detail.""" + + error = self._request_against(status=403) + self.assertIn("403", str(error)) + self.assertNotIn(SECRET_MARKER, str(error)) + + def test_webdriver_error_does_not_reflect_response_message(self) -> None: + """A 2xx WebDriver error object must remain fail-closed without its raw message.""" + + error = self._request_against(status=200) + self.assertIn("WebDriver", str(error)) + self.assertNotIn(SECRET_MARKER, str(error)) + + +if __name__ == "__main__": + unittest.main() From 48435b9d7e74ae932f03248c41833c684b1be411 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 09:36:35 -0700 Subject: [PATCH 30/30] fix(browser): redact WebDriver response diagnostics --- scripts/ci/run_mv3_compatibility.py | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index a1efeb7c2..dd9dbbde1 100644 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -126,8 +126,23 @@ def _json_request( if len(raw) > MAX_WEBDRIVER_RESPONSE_BYTES: raise RuntimeError("WebDriver response exceeded the bounded JSON limit") if response.status >= 400: - detail = raw.decode("utf-8", errors="replace") - raise RuntimeError(f"WebDriver HTTP {response.status}: {detail}") + try: + error_payload = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + error_payload = None + error_value = ( + error_payload.get("value") + if isinstance(error_payload, dict) + else None + ) + if ( + isinstance(error_value, dict) + and error_value.get("error") == "no such window" + ): + raise RuntimeError( + "WebDriver error: no such window: response details redacted" + ) + raise RuntimeError(f"WebDriver HTTP {response.status}") finally: connection.close() @@ -136,7 +151,11 @@ def _json_request( raise RuntimeError("WebDriver returned a non-object JSON payload") value = decoded.get("value") if isinstance(value, dict) and value.get("error"): - raise RuntimeError(f"WebDriver error: {value.get('error')}: {value.get('message')}") + if value.get("error") == "no such window": + raise RuntimeError( + "WebDriver error: no such window: response details redacted" + ) + raise RuntimeError("WebDriver returned an error response") return decoded