-
Notifications
You must be signed in to change notification settings - Fork 0
test(browser): require Agent Task browser-process termination evidence #142
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/mv3-failure-profile-cleanup-evidence
Are you sure you want to change the base?
Changes from all commits
146f742
52b6ca2
b9201c8
a66fb27
6c01e53
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 |
|---|---|---|
|
|
@@ -21,6 +21,7 @@ | |
| import http.client | ||
| import http.server | ||
| import json | ||
| import math | ||
| import os | ||
| import pathlib | ||
| import socket | ||
|
|
@@ -42,8 +43,10 @@ | |
| REQUEST_TIMEOUT_SECONDS = 5.0 | ||
| STARTUP_TIMEOUT_SECONDS = 20.0 | ||
| FIXTURE_TIMEOUT_SECONDS = 20.0 | ||
| PROCESS_EXIT_TIMEOUT_SECONDS = 5.0 | ||
| MAX_WEBDRIVER_RESPONSE_BYTES = 1_048_576 | ||
| MAX_PROC_STATUS_CHARACTERS = 65_536 | ||
| MAX_PROC_STAT_CHARACTERS = 65_536 | ||
| MAX_BROWSER_PROCESS_TREE_SIZE = 256 | ||
| MAX_PROC_PROCESS_SCAN_SIZE = 32_768 | ||
| MAX_SEMANTIC_LOCATOR_CANDIDATES = 128 | ||
|
|
@@ -405,6 +408,100 @@ def _parse_linux_proc_status_process_identity(status_text: str) -> tuple[int, in | |
| return process_id, parent_process_id | ||
|
|
||
|
|
||
| def _parse_linux_proc_stat_process_identity(stat_text: str) -> tuple[int, int]: | ||
| """Parse one Linux proc-stat PID/start-time identity without trusting ``comm`` text.""" | ||
|
|
||
| if not isinstance(stat_text, str) or not stat_text: | ||
| raise ValueError("Linux proc stat must be non-empty text") | ||
| command_open = stat_text.find(" (") | ||
| command_close = stat_text.rfind(") ") | ||
| if command_open <= 0 or command_close <= command_open + 2: | ||
| raise ValueError("malformed Linux proc stat process identity") | ||
|
|
||
| raw_process_id = stat_text[:command_open] | ||
| if not raw_process_id.isascii() or not raw_process_id.isdigit(): | ||
| raise ValueError("malformed Linux proc stat process identifier") | ||
| process_id = int(raw_process_id, 10) | ||
| if process_id <= 0: | ||
| raise ValueError("Linux proc stat process identifier must be positive") | ||
|
|
||
| command_text = stat_text[command_open + 2 : command_close] | ||
| if not command_text: | ||
| raise ValueError("Linux proc stat command must not be empty") | ||
| suffix_fields = stat_text[command_close + 2 :].split() | ||
| if len(suffix_fields) < 20 or len(suffix_fields[0]) != 1: | ||
| raise ValueError("Linux proc stat does not contain field 22 start time") | ||
| for raw_field in suffix_fields[1:]: | ||
| unsigned_field = raw_field[1:] if raw_field[:1] in {"+", "-"} else raw_field | ||
| if not unsigned_field or not unsigned_field.isascii() or not unsigned_field.isdigit(): | ||
| raise ValueError("malformed Linux proc stat numeric field") | ||
|
|
||
| raw_start_time_ticks = suffix_fields[19] | ||
| if not raw_start_time_ticks.isascii() or not raw_start_time_ticks.isdigit(): | ||
| raise ValueError("malformed Linux proc stat start time") | ||
| start_time_ticks = int(raw_start_time_ticks, 10) | ||
| if start_time_ticks <= 0: | ||
| raise ValueError("Linux proc stat start time must be positive") | ||
| if start_time_ticks > MAX_U64: | ||
| raise OverflowError("Linux proc stat start time exceeds u64 range") | ||
| return process_id, start_time_ticks | ||
|
|
||
|
|
||
| def _read_linux_proc_stat_process_identity(process_id: int) -> tuple[int, int] | None: | ||
| """Read one bounded Linux PID/start-time identity, returning absence after exit.""" | ||
|
|
||
| if isinstance(process_id, bool) or not isinstance(process_id, int) or process_id <= 0: | ||
| raise ValueError("invalid Linux process identifier") | ||
| stat_path = pathlib.Path("/proc") / str(process_id) / "stat" | ||
| try: | ||
| with stat_path.open("r", encoding="utf-8", errors="strict") as stat_file: | ||
| stat_text = stat_file.read(MAX_PROC_STAT_CHARACTERS + 1) | ||
| except FileNotFoundError: | ||
| return None | ||
| if len(stat_text) > MAX_PROC_STAT_CHARACTERS: | ||
| raise RuntimeError("Linux proc stat exceeded the bounded text limit") | ||
| identity = _parse_linux_proc_stat_process_identity(stat_text) | ||
| if identity[0] != process_id: | ||
| raise RuntimeError("Linux proc stat identity did not match its directory") | ||
| return identity | ||
|
|
||
|
|
||
| def _wait_for_linux_process_identity_exit( | ||
| process_id: int, | ||
| start_time_ticks: int, | ||
| *, | ||
| timeout_seconds: float = PROCESS_EXIT_TIMEOUT_SECONDS, | ||
| ) -> bool: | ||
| """Wait boundedly until the exact PID/start-time identity exits or is reused.""" | ||
|
|
||
| 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 ( | ||
| 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-exit timeout") | ||
|
|
||
| deadline = time.monotonic() + float(timeout_seconds) | ||
| expected_identity = (process_id, start_time_ticks) | ||
| while True: | ||
| current_identity = _read_linux_proc_stat_process_identity(process_id) | ||
| if current_identity is None or current_identity != expected_identity: | ||
| 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.""" | ||
|
|
||
|
|
@@ -824,6 +921,9 @@ def _run_agent_task_browser_pass( | |
| started = time.monotonic() | ||
| driver_port = _free_loopback_port() | ||
| session_id: str | None = None | ||
| browser_process_id: int | None = None | ||
| browser_process_start_time_ticks: 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, | ||
|
|
@@ -884,6 +984,10 @@ def _run_agent_task_browser_pass( | |
| or browser_process_id <= 0 | ||
| ): | ||
| raise RuntimeError("ChromeDriver did not return a valid 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 browser process identity disappeared after launch") | ||
| browser_process_start_time_ticks = browser_process_identity[1] | ||
|
|
||
| _json_request( | ||
| driver_port, | ||
|
|
@@ -1009,7 +1113,7 @@ def _run_agent_task_browser_pass( | |
| task_duration_ms = round((time.monotonic() - started) * 1000, 3) | ||
| if task_duration_ms <= 0: | ||
| raise RuntimeError("Agent Task measured a non-positive task duration") | ||
| return { | ||
| result = { | ||
| "browser_version": browser_version, | ||
| "post_condition": True, | ||
| "input_echo_verified": True, | ||
|
|
@@ -1048,6 +1152,18 @@ def _run_agent_task_browser_pass( | |
| driver.kill() | ||
| driver.wait(timeout=5) | ||
|
|
||
| if result is None: | ||
| raise RuntimeError("Agent Task browser pass returned no result after shutdown") | ||
| if browser_process_id is None or browser_process_start_time_ticks is None: | ||
| raise RuntimeError("Agent Task browser process identity was not captured") | ||
| if not _wait_for_linux_process_identity_exit( | ||
| browser_process_id, | ||
| browser_process_start_time_ticks, | ||
| ): | ||
| raise RuntimeError("Agent Task browser process did not terminate") | ||
|
Comment on lines
+1159
to
+1163
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: Fixed 5s termination wait is a flakiness watch-point
Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| result["browser_process_terminated"] = True | ||
| return result | ||
|
|
||
|
|
||
| def _run_agent_task_trial( | ||
| chrome_bin: pathlib.Path, | ||
|
|
@@ -1116,6 +1232,7 @@ def _run_agent_task_trial( | |
| "saved_credential_services_disabled" | ||
| ], | ||
| "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_set_rss_bytes": result["chromium_process_set_rss_bytes"], | ||
| "semantic_observation_bytes": result["semantic_observation_bytes"], | ||
|
|
@@ -1548,6 +1665,7 @@ def main() -> int: | |
| and trial["structured_value_sha256"].startswith("sha256:") | ||
| and trial.get("extensions_disabled") is True | ||
| and trial.get("profile_cleaned") is True | ||
| and trial.get("browser_process_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) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,117 @@ | ||
| """Contract for proving the controlled Agent Task browser process 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" | ||
|
|
||
|
|
||
| def _proc_stat(process_id: int, command: str, start_time_ticks: int) -> str: | ||
| """Build the bounded `/proc/<pid>/stat` prefix through field 22.""" | ||
|
|
||
| fields_three_through_twenty_one = ["S", *[str(value) for value in range(4, 22)]] | ||
| return ( | ||
| f"{process_id} ({command}) " | ||
| + " ".join(fields_three_through_twenty_one) | ||
| + f" {start_time_ticks}\n" | ||
| ) | ||
|
|
||
|
|
||
| class AgentTaskProcessTerminationContractTests(unittest.TestCase): | ||
| """Keep process-cleanup evidence PID-reuse-safe and fail closed.""" | ||
|
|
||
| def test_runner_exposes_bounded_process_identity_and_exit_helpers(self) -> None: | ||
| """The runner needs a Linux process identity boundary, not PID-only polling.""" | ||
|
|
||
| namespace = runpy.run_path(str(RUNNER), run_name="agent_task_process_termination") | ||
| for expected in ( | ||
| "MAX_PROC_STAT_CHARACTERS", | ||
| "PROCESS_EXIT_TIMEOUT_SECONDS", | ||
| "_parse_linux_proc_stat_process_identity", | ||
| "_read_linux_proc_stat_process_identity", | ||
| "_wait_for_linux_process_identity_exit", | ||
| ): | ||
| with self.subTest(expected=expected): | ||
| self.assertIn(expected, namespace) | ||
|
|
||
| def test_proc_stat_identity_parser_handles_command_text_and_rejects_ambiguity(self) -> None: | ||
| """PID reuse proof must bind a positive PID to the exact Linux start-time field.""" | ||
|
|
||
| namespace = runpy.run_path(str(RUNNER), run_name="agent_task_proc_stat_parser") | ||
| parser = namespace["_parse_linux_proc_stat_process_identity"] | ||
|
|
||
| self.assertEqual(parser(_proc_stat(321, "chrome worker", 987654)), (321, 987654)) | ||
| self.assertEqual(parser(_proc_stat(322, "chrome ) helper", 987655)), (322, 987655)) | ||
|
|
||
| for malformed in ( | ||
| "", | ||
| "321 chrome S 1 2 3\n", | ||
| _proc_stat(0, "chrome", 10), | ||
| _proc_stat(321, "chrome", 0), | ||
| _proc_stat(321, "chrome", -1), | ||
| "321 (chrome) S 1 2 3\n", | ||
| "not-a-pid (chrome) S " + " ".join(["1"] * 20) + "\n", | ||
| "321 (chrome) S " + " ".join(["1"] * 19) + " not-a-time\n", | ||
| ): | ||
| with self.subTest(malformed=malformed): | ||
| with self.assertRaises(ValueError): | ||
| parser(malformed) | ||
|
|
||
| def test_process_exit_waiter_distinguishes_exit_pid_reuse_and_live_identity(self) -> None: | ||
| """A reused PID is not the original browser process and a live identity must fail closed.""" | ||
|
|
||
| namespace = runpy.run_path(str(RUNNER), run_name="agent_task_process_exit_waiter") | ||
| waiter = namespace["_wait_for_linux_process_identity_exit"] | ||
| original_reader = waiter.__globals__["_read_linux_proc_stat_process_identity"] | ||
| try: | ||
| waiter.__globals__["_read_linux_proc_stat_process_identity"] = ( | ||
| lambda _process_id: None | ||
| ) | ||
| self.assertTrue(waiter(321, 987654, timeout_seconds=0.0)) | ||
|
|
||
| waiter.__globals__["_read_linux_proc_stat_process_identity"] = ( | ||
| lambda process_id: (process_id, 987655) | ||
| ) | ||
| self.assertTrue(waiter(321, 987654, timeout_seconds=0.0)) | ||
|
|
||
| waiter.__globals__["_read_linux_proc_stat_process_identity"] = ( | ||
| lambda process_id: (process_id, 987654) | ||
| ) | ||
| self.assertFalse(waiter(321, 987654, timeout_seconds=0.0)) | ||
| finally: | ||
| waiter.__globals__["_read_linux_proc_stat_process_identity"] = original_reader | ||
|
|
||
| for process_id, start_time_ticks, timeout_seconds in ( | ||
| (0, 987654, 0.0), | ||
| (321, 0, 0.0), | ||
| (321, 987654, -0.1), | ||
| ): | ||
| with self.subTest( | ||
| process_id=process_id, | ||
| start_time_ticks=start_time_ticks, | ||
| timeout_seconds=timeout_seconds, | ||
| ): | ||
| with self.assertRaises(ValueError): | ||
| waiter(process_id, start_time_ticks, timeout_seconds=timeout_seconds) | ||
|
|
||
| def test_successful_agent_task_requires_post_shutdown_process_termination_evidence(self) -> None: | ||
| """A successful task must not be accepted while its original browser root is still live.""" | ||
|
|
||
| runner = RUNNER.read_text(encoding="utf-8") | ||
| for expected in ( | ||
| "browser_process_start_time_ticks", | ||
| '"browser_process_terminated"', | ||
| 'result["browser_process_terminated"]', | ||
| 'trial.get("browser_process_terminated") is True', | ||
| "Agent Task browser process 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: Strict UTF-8 decode of stat can raise on PID reuse
_read_linux_proc_stat_process_identityreads the stat file witherrors="strict". If the polled PID is reused during the wait by a process whosecommholds non-UTF-8 bytes, the decode raises and escapes_wait_for_linux_process_identity_exitinstead of counting as termination. The reuse window is short and the browser is ASCII-named, so this is not treated as a bug.Was this helpful? React with 👍 or 👎 to provide feedback.