diff --git a/docs/doctoring.md b/docs/doctoring.md index 7ff8e6a22..4d0f676ae 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -104,6 +104,12 @@ The #144 ordinary parent integration preserves the production runner and process The prior informational review limits remain explicit: descendants are bound to start times read after lineage sampling, so a reused PID in that gap may conservatively fail a trial; root and process-set waits have separate bounded budgets, with one shared deadline only inside the set waiter; and the pre-shutdown exit-count bound is a defensive evidence invariant. None of these controlled local contracts attests cgroup ownership, later processes, OS-wide orphan absence or exact-head pinned-Chromium acceptance. The inherited failure-path regression keeps observer errors unproven and task failures unsuccessful even when temporary-profile cleanup succeeds. +### Late-failure and forced-close evidence limits + +The #145 ordinary integration adopts #144 `3c0c5c363c2da0b4e8a621226e2f7766c735b743` without changing the production runner or either child-owned process-set/forced-close regression. Its predecessor `a1341e9c8595c38446d64aaaadeab999062eab57` collected three inherited failure-path contracts rather than the parent's five. The inherited regression now runs alongside the child's separate late-failure contract; no new runtime abstraction or timeout is needed. + +Ten controlled probes of the actual browser-pass and outer-trial flows cover observed root/set exit, a surviving root, a surviving sampled set, a process-observation error, and interruption before a complete result in each lane. Session deletion and driver shutdown precede the applicable exit observations. Late ordinary failure retains observed true/false root and captured-set outcomes without becoming a pass; incomplete capture omits set evidence. Forced-close success requires both observations to be true. Successful cleanup commands do not establish that sampled processes are already dead: a surviving identity fails the trial. Forced-close interruption skips post-finally observers and leaves termination unproven, while observation errors produce only the bounded fallback error type. Private exception messages remain absent. These injected probes establish control-flow contracts, not live Linux/Chromium termination, and exact-head hosted compatibility is still required. + ## References Amazon Web Services. (n.d.). *Set up the Amazon EKS Pod Identity Agent*. Retrieved August 6, 2026, from https://docs.aws.amazon.com/eks/latest/userguide/pod-id-agent-setup.html diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py old mode 100755 new mode 100644 index d2e0107bc..8ff257dda --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -1281,20 +1281,27 @@ def _run_agent_task_browser_pass( browser_process_id, browser_process_start_time_ticks, ) + 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: - return { + 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 browser pass returned no result after shutdown") - if chromium_process_identities is None: + 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") - chromium_process_set_terminated = _wait_for_linux_process_identity_set_exit( - chromium_process_identities - ) if not browser_process_terminated: raise RuntimeError("Agent Task browser process did not terminate") if not chromium_process_set_terminated: @@ -1357,7 +1364,7 @@ def _run_agent_task_trial( browser_process_terminated = result.get("browser_process_terminated") if not isinstance(browser_process_terminated, bool): raise RuntimeError("Agent Task browser pass returned invalid teardown evidence") - return { + failure_evidence: dict[str, Any] = { "trial_number": trial_number, "passed": False, "failure_type": returned_failure_type, @@ -1365,6 +1372,16 @@ def _run_agent_task_trial( "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 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, @@ -1462,6 +1479,10 @@ def _run_agent_task_forced_close_browser_pass( driver_port = _free_loopback_port() session_id: str | None = None + browser_process_id: int | None = None + browser_process_start_time_ticks: int | None = None + chromium_process_identities: tuple[tuple[int, int], ...] | None = None + result: dict[str, Any] | None = None driver = subprocess.Popen( [str(chromedriver_bin), f"--port={driver_port}", "--allowed-ips=127.0.0.1"], stdout=subprocess.DEVNULL, @@ -1506,11 +1527,22 @@ def _run_agent_task_forced_close_browser_pass( raise RuntimeError("ChromeDriver forced-close capabilities are malformed") session_id = _path_token(raw_session_id, "session identifier") browser_version = capabilities.get("browserVersion") + browser_process_id = capabilities.get("goog:processID") if browser_version != PINNED_CHROME_VERSION: raise RuntimeError( f"unexpected forced-close Chrome version: expected {PINNED_CHROME_VERSION}, " f"got {browser_version!r}" ) + if ( + isinstance(browser_process_id, bool) + or not isinstance(browser_process_id, int) + or browser_process_id <= 0 + ): + raise RuntimeError("ChromeDriver did not return a valid forced-close browser process id") + browser_process_identity = _read_linux_proc_stat_process_identity(browser_process_id) + if browser_process_identity is None: + raise RuntimeError("Agent Task forced-close browser process identity disappeared") + browser_process_start_time_ticks = browser_process_identity[1] survivor_context = _json_request( driver_port, @@ -1556,6 +1588,21 @@ def _run_agent_task_forced_close_browser_pass( if loaded_url != fixture_url: raise RuntimeError("Agent Task forced-close probe did not load its fixture URL") + process_evidence = _snapshot_linux_process_evidence() + chromium_process_ids = _discover_linux_process_tree_ids( + 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, + ), + ) + ) + forced_close_detected = _force_close_agent_task_context(driver_port, session_id) if not forced_close_detected: raise RuntimeError("Agent Task forced-close probe did not detect the close") @@ -1574,7 +1621,7 @@ def _run_agent_task_forced_close_browser_pass( if not isinstance(surviving_url, str): raise RuntimeError("Agent Task survivor context was not usable after forced close") - return { + result = { "browser_version": browser_version, "forced_close_detected": forced_close_detected, "session_survived": True, @@ -1595,6 +1642,27 @@ def _run_agent_task_forced_close_browser_pass( driver.kill() driver.wait(timeout=5) + 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 + ) + if result is None: + raise RuntimeError("Agent Task forced-close browser pass returned no result after shutdown") + 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["browser_process_terminated"] = True + result["chromium_process_set_terminated"] = True + return result + def _run_agent_task_forced_close_trial( chrome_bin: pathlib.Path, @@ -1651,6 +1719,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"], + "browser_process_terminated": result["browser_process_terminated"], + "chromium_process_set_terminated": result["chromium_process_set_terminated"], "profile_cleaned": True, "duration_ms": duration_ms, } @@ -1856,6 +1926,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("browser_process_terminated") is True + and trial.get("chromium_process_set_terminated") is True and trial.get("profile_cleaned") is True for trial in forced_close_trials if trial.get("passed") is True diff --git a/tests/test_agent_task_failure_process_set_termination_contract.py b/tests/test_agent_task_failure_process_set_termination_contract.py new file mode 100644 index 000000000..6fe48ea9d --- /dev/null +++ b/tests/test_agent_task_failure_process_set_termination_contract.py @@ -0,0 +1,94 @@ +"""Contract for Chromium process-set termination evidence after Agent Task failure.""" + +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 AgentTaskFailureProcessSetTerminationContractTests(unittest.TestCase): + """Retain sampled descendant teardown evidence when controlled browser work fails.""" + + def _namespace(self, name: str) -> dict[str, object]: + return runpy.run_path(str(RUNNER), run_name=name) + + def test_browser_pass_retains_sampled_process_set_teardown_after_failure(self) -> None: + """Late failure must not discard identities already captured before shutdown.""" + + runner = RUNNER.read_text(encoding="utf-8") + start = runner.index("def _run_agent_task_browser_pass(") + end = runner.index("\ndef _run_agent_task_trial(", start) + browser_pass = runner[start:end] + for expected in ( + "if chromium_process_identities is not None:", + "chromium_process_set_terminated = _wait_for_linux_process_identity_set_exit(", + 'failure_evidence["chromium_process_set_terminated"]', + ): + with self.subTest(expected=expected): + self.assertIn(expected, browser_pass) + + def test_trial_preserves_failed_process_set_teardown_evidence(self) -> None: + """Profile cleanup must preserve both root and sampled-set termination outcomes.""" + + namespace = self._namespace("agent_task_failure_process_set_termination_trial") + run_trial = namespace["_run_agent_task_trial"] + + def fail_after_sampled_set_shutdown( + *_args: object, **_kwargs: object + ) -> dict[str, object]: + return { + "failure_type": "RuntimeError", + "browser_process_terminated": True, + "chromium_process_set_terminated": False, + } + + run_trial.__globals__["_run_agent_task_browser_pass"] = ( + fail_after_sampled_set_shutdown + ) + result = run_trial( + pathlib.Path("controlled-chrome"), + pathlib.Path("controlled-chromedriver"), + "http://127.0.0.1/controlled-fixture", + 13, + ) + + self.assertEqual(result["trial_number"], 13) + self.assertIs(result["passed"], False) + self.assertEqual(result["failure_type"], "RuntimeError") + self.assertIs(result["browser_process_terminated"], True) + self.assertIs(result["chromium_process_set_terminated"], False) + self.assertIs(result["profile_cleaned"], True) + + def test_failure_before_process_set_capture_does_not_invent_set_evidence(self) -> None: + """A failure without sampled identities must remain explicit rather than fabricated.""" + + namespace = self._namespace("agent_task_failure_before_process_set_capture") + run_trial = namespace["_run_agent_task_trial"] + + def fail_before_process_set_capture( + *_args: object, **_kwargs: object + ) -> dict[str, object]: + return { + "failure_type": "RuntimeError", + "browser_process_terminated": True, + } + + run_trial.__globals__["_run_agent_task_browser_pass"] = fail_before_process_set_capture + result = run_trial( + pathlib.Path("controlled-chrome"), + pathlib.Path("controlled-chromedriver"), + "http://127.0.0.1/controlled-fixture", + 14, + ) + + self.assertIs(result["passed"], False) + self.assertNotIn("chromium_process_set_terminated", result) + self.assertIs(result["profile_cleaned"], True) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_agent_task_forced_close_process_termination_contract.py b/tests/test_agent_task_forced_close_process_termination_contract.py new file mode 100644 index 000000000..319227b98 --- /dev/null +++ b/tests/test_agent_task_forced_close_process_termination_contract.py @@ -0,0 +1,84 @@ +"""Contract for post-shutdown process termination in the Agent Task forced-close lane.""" + +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 AgentTaskForcedCloseProcessTerminationContractTests(unittest.TestCase): + """Require interruption evidence to include bounded Chromium teardown proof.""" + + def test_forced_close_browser_pass_binds_and_waits_for_process_identities(self) -> None: + """The forced-close pass must prove its sampled browser process set terminates.""" + + 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 ( + 'capabilities.get("goog:processID")', + "_read_linux_proc_stat_process_identity", + "_snapshot_linux_process_evidence", + "_read_linux_process_identity_set", + "_wait_for_linux_process_identity_exit", + "_wait_for_linux_process_identity_set_exit", + '"browser_process_terminated"', + '"chromium_process_set_terminated"', + ): + with self.subTest(expected=expected): + self.assertIn(expected, browser_pass) + + def test_forced_close_trial_preserves_false_teardown_evidence(self) -> None: + """A failed teardown proof must not be omitted or normalized into success.""" + + namespace = runpy.run_path( + str(RUNNER), run_name="forced_close_process_termination_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 { + "browser_version": namespace["PINNED_CHROME_VERSION"], + "forced_close_detected": True, + "session_survived": True, + "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["browser_process_terminated"], False) + self.assertIs(result["chromium_process_set_terminated"], False) + + def test_main_forced_close_gate_requires_process_termination(self) -> None: + """Compatibility success must reject a live forced-close browser 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("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()