-
Notifications
You must be signed in to change notification settings - Fork 0
test(browser): require sampled Chromium process-set termination evidence #144
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: test/agent-task-failure-process-termination-evidence
Are you sure you want to change the base?
Changes from all commits
d20fd0f
9c3af05
fea7bec
ea2ca40
c60bafb
4a75e5a
d5c239c
cde4204
5ed8fb1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -502,6 +502,102 @@ def _wait_for_linux_process_identity_exit( | |
| time.sleep(min(0.05, remaining_seconds)) | ||
|
|
||
|
|
||
| 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.""" | ||
|
|
||
| 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): | ||
| 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 | ||
| identities.append(identity) | ||
| return tuple(identities), pre_shutdown_exit_count | ||
|
|
||
|
|
||
| def _wait_for_linux_process_identity_set_exit( | ||
| process_identities: tuple[tuple[int, int], ...], | ||
| *, | ||
| timeout_seconds: float = PROCESS_EXIT_TIMEOUT_SECONDS, | ||
| ) -> bool: | ||
| """Wait under one shared deadline for every exact sampled process identity to exit.""" | ||
|
|
||
| if not process_identities or len(process_identities) > MAX_BROWSER_PROCESS_TREE_SIZE: | ||
| raise ValueError("invalid Linux process identity-set size") | ||
| process_ids: list[int] = [] | ||
| expected: dict[int, tuple[int, int]] = {} | ||
| for identity in process_identities: | ||
| if not isinstance(identity, tuple) or len(identity) != 2: | ||
| raise ValueError("invalid Linux process identity") | ||
| process_id, start_time_ticks = identity | ||
| if isinstance(process_id, bool) or not isinstance(process_id, int) or process_id <= 0: | ||
| raise ValueError("invalid Linux process identifier") | ||
| if ( | ||
| isinstance(start_time_ticks, bool) | ||
| or not isinstance(start_time_ticks, int) | ||
| or start_time_ticks <= 0 | ||
| ): | ||
| raise ValueError("invalid Linux process start time") | ||
| if process_id in expected: | ||
| raise ValueError("Linux process identity-set PIDs must be unique") | ||
| process_ids.append(process_id) | ||
| expected[process_id] = identity | ||
| if ( | ||
| isinstance(timeout_seconds, bool) | ||
| or not isinstance(timeout_seconds, (int, float)) | ||
| or timeout_seconds < 0 | ||
| or not math.isfinite(timeout_seconds) | ||
| ): | ||
| raise ValueError("invalid Linux process-set exit timeout") | ||
|
|
||
| deadline = time.monotonic() + float(timeout_seconds) | ||
| while True: | ||
| live_identity_found = False | ||
| for process_id in process_ids: | ||
| current_identity = _read_linux_proc_stat_process_identity(process_id) | ||
| if current_identity == expected[process_id]: | ||
| live_identity_found = True | ||
| if not live_identity_found: | ||
| return True | ||
| remaining_seconds = deadline - time.monotonic() | ||
| if remaining_seconds <= 0: | ||
| return False | ||
| time.sleep(min(0.05, remaining_seconds)) | ||
|
|
||
|
|
||
| def _sample_linux_process_rss_bytes(process_id: int) -> int: | ||
| """Read one attributed Linux process RSS through a bounded ``/proc`` status file.""" | ||
|
|
||
|
|
@@ -923,6 +1019,8 @@ def _run_agent_task_browser_pass( | |
| 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 | ||
| chromium_process_pre_shutdown_exit_count: int | None = None | ||
| browser_failure_type: str | None = None | ||
| result: dict[str, Any] | None = None | ||
| driver = subprocess.Popen( | ||
|
|
@@ -1105,6 +1203,16 @@ 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, | ||
| ), | ||
| ) | ||
| browser_process_rss_bytes = _sample_linux_process_rss_bytes(browser_process_id) | ||
| chromium_process_set_rss_bytes = _sample_linux_process_set_rss_bytes( | ||
| chromium_process_ids, | ||
|
|
@@ -1131,6 +1239,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, | ||
|
|
@@ -1170,9 +1281,19 @@ def _run_agent_task_browser_pass( | |
| } | ||
| if result is None: | ||
| raise RuntimeError("Agent Task browser pass returned no result after shutdown") | ||
| if chromium_process_identities 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: | ||
| raise RuntimeError("Agent Task Chromium process set did not terminate") | ||
|
Comment on lines
+1288
to
+1294
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📝 Info: Failure path waits two full timeouts
Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| result["browser_process_terminated"] = True | ||
| result["chromium_process_set_terminated"] = True | ||
| return result | ||
|
|
||
|
|
||
|
|
@@ -1260,7 +1381,11 @@ 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"], | ||
| "action_latency_ms": result["action_latency_ms"], | ||
| "task_duration_ms": result["task_duration_ms"], | ||
|
|
@@ -1692,10 +1817,16 @@ def main() -> int: | |
| and trial.get("extensions_disabled") is True | ||
| and trial.get("profile_cleaned") is True | ||
| and trial.get("browser_process_terminated") is True | ||
| and trial.get("chromium_process_set_terminated") is True | ||
| and isinstance(trial.get("browser_process_rss_bytes"), 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"] | ||
|
Comment on lines
+1825
to
+1829
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📝 Info: Exit-count upper bound is always true The gate asserts Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| 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) | ||
|
|
@@ -1793,4 +1924,4 @@ def main() -> int: | |
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| raise SystemExit(main()) | ||
| raise SystemExit(main()) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,125 @@ | ||
| """Contract for proving the controlled Agent Task Chromium process set terminates.""" | ||
|
|
||
| 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 AgentTaskProcessSetTerminationContractTests(unittest.TestCase): | ||
| """Keep descendant cleanup evidence bounded, PID-reuse-safe, and fail closed.""" | ||
|
|
||
| def test_runner_exposes_bounded_process_set_identity_and_exit_helpers(self) -> None: | ||
| """A sampled Chromium tree needs exact PID/start-time identities before shutdown.""" | ||
|
|
||
| namespace = runpy.run_path(str(RUNNER), run_name="agent_task_process_set_termination") | ||
| for expected in ( | ||
| "_read_linux_process_identity_set", | ||
| "_wait_for_linux_process_identity_set_exit", | ||
| ): | ||
| with self.subTest(expected=expected): | ||
| self.assertIn(expected, namespace) | ||
|
|
||
| def test_process_identity_set_reader_preserves_root_and_tolerates_exited_children(self) -> None: | ||
| """Short-lived descendants may exit after the snapshot, but root identity stays exact.""" | ||
|
|
||
| namespace = runpy.run_path(str(RUNNER), run_name="agent_task_process_set_reader") | ||
| reader = namespace["_read_linux_process_identity_set"] | ||
| original_reader = reader.__globals__["_read_linux_proc_stat_process_identity"] | ||
| identities = {10: (10, 101), 20: (20, 202), 30: (30, 303)} | ||
| try: | ||
| reader.__globals__["_read_linux_proc_stat_process_identity"] = identities.get | ||
| self.assertEqual( | ||
| reader((10, 20, 30), required_root_identity=(10, 101)), | ||
| (((10, 101), (20, 202), (30, 303)), 0), | ||
| ) | ||
|
|
||
| reader.__globals__["_read_linux_proc_stat_process_identity"] = ( | ||
| lambda process_id: None if process_id == 20 else identities[process_id] | ||
| ) | ||
| self.assertEqual( | ||
| reader((10, 20, 30), required_root_identity=(10, 101)), | ||
| (((10, 101), (30, 303)), 1), | ||
| ) | ||
|
|
||
| reader.__globals__["_read_linux_proc_stat_process_identity"] = ( | ||
| lambda process_id: None if process_id == 10 else identities[process_id] | ||
| ) | ||
| with self.assertRaisesRegex(RuntimeError, "root process identity disappeared"): | ||
| reader((10, 20, 30), required_root_identity=(10, 101)) | ||
|
|
||
| reader.__globals__["_read_linux_proc_stat_process_identity"] = identities.get | ||
| with self.assertRaisesRegex(RuntimeError, "root process identity changed"): | ||
| reader((10, 20, 30), required_root_identity=(10, 999)) | ||
| finally: | ||
| reader.__globals__["_read_linux_proc_stat_process_identity"] = original_reader | ||
|
|
||
| for process_ids, root_identity in ( | ||
| ((), (10, 101)), | ||
| ((10, 10), (10, 101)), | ||
| ((10, 20), (20, 202)), | ||
| ((10, 20), (10, 0)), | ||
| ): | ||
| with self.subTest(process_ids=process_ids, root_identity=root_identity): | ||
| with self.assertRaises(ValueError): | ||
| reader(process_ids, required_root_identity=root_identity) | ||
|
|
||
| def test_process_set_exit_waiter_uses_one_deadline_and_detects_any_live_identity(self) -> None: | ||
| """A reused PID is exited evidence, but any exact surviving identity fails closed.""" | ||
|
|
||
| namespace = runpy.run_path(str(RUNNER), run_name="agent_task_process_set_exit_waiter") | ||
| waiter = namespace["_wait_for_linux_process_identity_set_exit"] | ||
| original_reader = waiter.__globals__["_read_linux_proc_stat_process_identity"] | ||
| identities = ((10, 101), (20, 202), (30, 303)) | ||
| try: | ||
| waiter.__globals__["_read_linux_proc_stat_process_identity"] = ( | ||
| lambda _process_id: None | ||
| ) | ||
| self.assertTrue(waiter(identities, timeout_seconds=0.0)) | ||
|
|
||
| waiter.__globals__["_read_linux_proc_stat_process_identity"] = ( | ||
| lambda process_id: (process_id, {10: 111, 20: 222, 30: 333}[process_id]) | ||
| ) | ||
| self.assertTrue(waiter(identities, timeout_seconds=0.0)) | ||
|
|
||
| waiter.__globals__["_read_linux_proc_stat_process_identity"] = ( | ||
| lambda process_id: (20, 202) if process_id == 20 else None | ||
| ) | ||
| self.assertFalse(waiter(identities, timeout_seconds=0.0)) | ||
| finally: | ||
| waiter.__globals__["_read_linux_proc_stat_process_identity"] = original_reader | ||
|
|
||
| for process_identities, timeout_seconds in ( | ||
| ((), 0.0), | ||
| (((10, 101), (10, 102)), 0.0), | ||
| (((10, 101),), -0.1), | ||
| ): | ||
| with self.subTest( | ||
| process_identities=process_identities, | ||
| timeout_seconds=timeout_seconds, | ||
| ): | ||
| with self.assertRaises(ValueError): | ||
| waiter(process_identities, timeout_seconds=timeout_seconds) | ||
|
|
||
| def test_successful_agent_task_requires_entire_sampled_process_set_to_terminate(self) -> None: | ||
| """Successful acceptance must preserve already-exited descendants as explicit evidence.""" | ||
|
|
||
| runner = RUNNER.read_text(encoding="utf-8") | ||
| for expected in ( | ||
| "chromium_process_identities", | ||
| "chromium_process_pre_shutdown_exit_count", | ||
| '"chromium_process_set_terminated"', | ||
| 'result["chromium_process_set_terminated"]', | ||
| 'trial.get("chromium_process_set_terminated") is True', | ||
| "Agent Task Chromium process set did not terminate", | ||
| ): | ||
| with self.subTest(expected=expected): | ||
| self.assertIn(expected, runner) | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| unittest.main() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📝 Info: Descendant identities re-sampled, not matched to the snapshot
_read_linux_process_identity_setverifies the root againstrequired_root_identitybut binds descendants to freshly-read start-times (run_mv3_compatibility.py), because_snapshot_linux_process_evidencenever recorded descendant start-times. A descendant PID reused by an unrelated process between snapshot and capture is bound as live and then waited on, so a long-lived reused PID can time out and fail the trial. Low probability on CI; acknowledged in the PR truth boundary.Was this helpful? React with 👍 or 👎 to provide feedback.