Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
a1aa983
test(mv3): require ephemeral profile isolation evidence
seonghobae Aug 10, 2026
96a4e94
test(mv3): prove ephemeral profile lifecycle directly
seonghobae Aug 10, 2026
f0875d1
test(mv3): require browser process-group cleanup
seonghobae Aug 10, 2026
832e496
fix(mv3): terminate isolated browser process group
seonghobae Aug 10, 2026
6cfe3f3
merge: align MV3 profile isolation with current prerequisite
seonghobae Aug 15, 2026
ff8e75d
merge: align MV3 profile isolation with current prerequisite
seonghobae Aug 15, 2026
bd79a30
merge: align MV3 download restart fix from prerequisite
seonghobae Aug 15, 2026
41b184d
merge: preserve prerequisite MV3 restart regression
seonghobae Aug 15, 2026
d634a22
merge: align ephemeral profile isolation with current MV3 download pr…
seonghobae Aug 15, 2026
ed96f7c
merge: align profile isolation with cleanup exception contract
seonghobae Aug 16, 2026
90d9ed7
test(mv3): inherit cleanup import normalization
seonghobae Aug 16, 2026
fdac75a
merge: realign profile isolation to current downloads prerequisite
seonghobae Aug 16, 2026
3dc0cfd
test(mv3): preserve cleanup cause across group teardown failure
seonghobae Aug 16, 2026
4344b2d
chore(mv3): realign profile isolation with current downloads head
seonghobae Aug 16, 2026
6d49ffd
chore(mv3): realign profile isolation with current downloads head
seonghobae Aug 19, 2026
f1169dd
test(mv3): preserve ChromeDriver status authority on stack realignment
seonghobae Aug 19, 2026
2fef789
fix(mv3): preserve status authority across profile stack
seonghobae Aug 19, 2026
72b0d11
chore(stack): reconcile profile isolation with current downloads head
seonghobae Aug 19, 2026
336a0ad
test(mv3): restore atomic ChromeDriver port authority regression
seonghobae Aug 19, 2026
06f7a90
fix(mv3): preserve atomic driver port with process-group isolation
seonghobae Aug 19, 2026
d1fc751
test(mv3): preserve portable ChromeDriver decoding
seonghobae Aug 19, 2026
8514aec
fix(mv3): preserve portable decoding in profile isolation
seonghobae Aug 19, 2026
e33bba9
chore(mv3): reconcile profile isolation with prerequisite
seonghobae Aug 19, 2026
4f7aaf0
chore(mv3): match prerequisite regression exactly
seonghobae Aug 19, 2026
8a6e0fd
test(mv3): restore bounded startup-line regression from prerequisite
seonghobae Aug 20, 2026
4ced8d1
fix(mv3): preserve bounded startup reads in profile-isolation child
seonghobae Aug 20, 2026
8ed76e4
merge: realign profile-isolation child with current MV3 prerequisite
seonghobae Aug 20, 2026
3a16ab0
merge: realign profile isolation with current MV3 prerequisite
seonghobae Aug 20, 2026
27ebddc
fix(mv3): preserve current startup hardening in profile isolation stack
seonghobae Aug 20, 2026
57403cc
chore(mv3): remap profile isolation onto live parent
seonghobae Aug 21, 2026
c8692c4
chore(mv3): merge live downloads prerequisite
seonghobae Aug 21, 2026
e765c34
chore(mv3): align ephemeral profile stack to live prerequisite
seonghobae Aug 21, 2026
e2ef396
test(mv3): reproduce descendant leak after leader exit
seonghobae Aug 21, 2026
54e76d9
fix(mv3): reap surviving browser process groups
seonghobae Aug 21, 2026
ff6590e
test(mv3): close descendant fixture pipe
seonghobae Aug 21, 2026
90849df
test(mv3): model process-group exit after SIGTERM
seonghobae Aug 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 85 additions & 2 deletions scripts/ci/run_mv3_compatibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import os
import pathlib
import queue
import signal
import string
import subprocess
import tempfile
Expand All @@ -38,6 +39,8 @@
REQUEST_TIMEOUT_SECONDS = 5.0
STARTUP_TIMEOUT_SECONDS = 20.0
FIXTURE_TIMEOUT_SECONDS = 20.0
PROCESS_GROUP_EXIT_TIMEOUT_SECONDS = 5.0
PROCESS_GROUP_EXIT_POLL_SECONDS = 0.05
MAX_WEBDRIVER_RESPONSE_BYTES = 1_048_576
MAX_CHROMEDRIVER_STARTUP_LINE_BYTES = 512
CHROMEDRIVER_BOUND_PORT_PREFIX = "ChromeDriver was started successfully on port "
Expand Down Expand Up @@ -396,8 +399,87 @@ def _exercise_real_click(driver_port: int, session_id: str) -> str:
return str(text)


def _wait_for_process_group_exit(process_group_id: int) -> Exception | None:
"""Wait a bounded interval until one isolated process group no longer exists."""

deadline = time.monotonic() + PROCESS_GROUP_EXIT_TIMEOUT_SECONDS
while True:
try:
os.killpg(process_group_id, 0)
except ProcessLookupError:
return None
except OSError as error:
return error
if time.monotonic() >= deadline:
return RuntimeError(
"ChromeDriver process group remained alive after bounded teardown"
)
time.sleep(PROCESS_GROUP_EXIT_POLL_SECONDS)


def _kill_and_reap_process_group(
driver: subprocess.Popen[bytes], process_group_id: int
) -> Exception | None:
"""Force one surviving isolated process group down and verify bounded disappearance."""

try:
os.killpg(process_group_id, signal.SIGKILL)
except ProcessLookupError:
pass
except OSError as kill_error:
return kill_error
try:
driver.wait(timeout=5)
except (OSError, subprocess.TimeoutExpired) as wait_error:
return wait_error
return _wait_for_process_group_exit(process_group_id)


def _teardown_driver_process(driver: subprocess.Popen[bytes]) -> Exception | None:
"""Best-effort reap ChromeDriver while preserving unrecovered process failures."""
"""Reap ChromeDriver and, for real Popen instances, its isolated process group.

Production ChromeDriver launches expose a positive `pid` and run in a fresh
process session. Teardown signals the group with SIGTERM, reaps the leader,
verifies whether descendants still occupy the group, and applies bounded
SIGKILL recovery before reporting success. A pid-less test double retains the
older bounded leader-only path so cleanup-failure contracts can isolate
session semantics without sending operating-system signals.
"""

driver_pid = getattr(driver, "pid", None)
if isinstance(driver_pid, int) and driver_pid > 0:
try:
os.killpg(driver_pid, signal.SIGTERM)
except ProcessLookupError:
try:
driver.wait(timeout=5)
except (OSError, subprocess.TimeoutExpired) as wait_error:
return wait_error
return None
except OSError as terminate_error:
fallback_error = _kill_and_reap_process_group(driver, driver_pid)
if fallback_error is not None:
terminate_error.add_note(
"bounded ChromeDriver process-group kill fallback also failed: "
f"{type(fallback_error).__name__}"
)
return terminate_error
return None

try:
driver.wait(timeout=5)
except subprocess.TimeoutExpired:
return _kill_and_reap_process_group(driver, driver_pid)
except OSError as wait_error:
return wait_error

try:
os.killpg(driver_pid, 0)
except ProcessLookupError:
return None
except OSError as probe_error:
return probe_error
return _kill_and_reap_process_group(driver, driver_pid)

try:
driver.terminate()
Expand Down Expand Up @@ -474,6 +556,7 @@ def _start_chromedriver(
[str(chromedriver_bin), "--port=0", "--allowed-ips=127.0.0.1"],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
start_new_session=True,
)
if driver.stdout is None:
teardown_error = _teardown_driver_process(driver)
Expand Down Expand Up @@ -882,4 +965,4 @@ def main() -> int:


if __name__ == "__main__":
raise SystemExit(main())
raise SystemExit(main())
131 changes: 131 additions & 0 deletions tests/test_mv3_ephemeral_profile_contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
"""Regression contract for bounded ephemeral Chromium profile lifecycle."""

from __future__ import annotations

import pathlib
import runpy
import signal
import tempfile
import unittest
import unittest.mock

ROOT = pathlib.Path(__file__).resolve().parents[1]
RUNNER = ROOT / "scripts" / "ci" / "run_mv3_compatibility.py"


class ManifestV3EphemeralProfileContractTests(unittest.TestCase):
"""Prove each Chromium trial creates, reuses, and deletes one isolated profile."""

def test_restart_trial_uses_empty_profile_then_deletes_it(self) -> None:
"""A trial must start empty, reuse only its own profile, then remove it."""

namespace = runpy.run_path(str(RUNNER), run_name="mv3_ephemeral_profile_contract")
run_restart_trial = namespace["_run_restart_trial"]
globals_ = run_restart_trial.__globals__
observed_profiles: list[pathlib.Path] = []
call_count = 0

def fake_browser_pass(
_chrome_bin: pathlib.Path,
_chromedriver_bin: pathlib.Path,
_fixture_url: str,
profile_dir: str,
expected_storage_persistence: str,
) -> dict[str, object]:
nonlocal call_count
profile_path = pathlib.Path(profile_dir)
if call_count == 0:
self.assertTrue(profile_path.is_dir())
self.assertEqual(list(profile_path.iterdir()), [])
profile_path.joinpath("profile-created-by-browser").write_text(
"fixture", encoding="utf-8"
)
else:
self.assertEqual(profile_path, observed_profiles[0])
self.assertTrue(
profile_path.joinpath("profile-created-by-browser").is_file()
)
observed_profiles.append(profile_path)
call_count += 1
return {
"browser_version": namespace["PINNED_CHROME_VERSION"],
"worker_start_count": call_count,
"storage_persistence": expected_storage_persistence,
"surfaces": {"fixture": True},
}

with unittest.mock.patch.dict(
globals_, {"_run_browser_pass": fake_browser_pass}
):
result = run_restart_trial(
pathlib.Path("/unused/chrome"),
pathlib.Path("/unused/chromedriver"),
"http://127.0.0.1/fixture",
1,
)

self.assertEqual(len(observed_profiles), 2)
self.assertEqual(observed_profiles[0], observed_profiles[1])
self.assertFalse(observed_profiles[0].exists())
self.assertNotIn(str(observed_profiles[0]), repr(result))
self.assertIs(result.get("passed"), True)

def test_browser_pass_owns_and_terminates_the_chromedriver_process_group(self) -> None:
"""Failure cleanup must signal the isolated driver group, not only its leader."""

source = RUNNER.read_text(encoding="utf-8")
self.assertIn("start_new_session=True", source)

namespace = runpy.run_path(str(RUNNER), run_name="mv3_process_group_contract")
run_browser_pass = namespace["_run_browser_pass"]
globals_ = run_browser_pass.__globals__
driver = unittest.mock.Mock()
driver.pid = 4242
driver.wait.return_value = 0

def fake_kill_process_group(process_group_id: int, process_signal: int) -> None:
self.assertEqual(process_group_id, driver.pid)
if process_signal == signal.SIGTERM:
return
if process_signal == 0:
raise ProcessLookupError
raise AssertionError(f"unexpected process-group signal: {process_signal}")

with tempfile.TemporaryDirectory(prefix="originweave-mv3-cleanup-") as profile_dir:
with (
unittest.mock.patch.dict(
globals_,
{
"_start_chromedriver": lambda _binary: (driver, 43123),
"_wait_for_driver": unittest.mock.Mock(
side_effect=RuntimeError("controlled startup failure")
),
},
),
unittest.mock.patch.object(
globals_["os"], "killpg", side_effect=fake_kill_process_group
) as kill_process_group,
):
with self.assertRaisesRegex(RuntimeError, "controlled startup failure"):
run_browser_pass(
pathlib.Path("/unused/chrome"),
pathlib.Path("/unused/chromedriver"),
"http://127.0.0.1/fixture",
profile_dir,
"initialized",
)

self.assertEqual(
kill_process_group.call_args_list,
[
unittest.mock.call(driver.pid, signal.SIGTERM),
unittest.mock.call(driver.pid, 0),
],
)
driver.wait.assert_called_once_with(timeout=5)
driver.terminate.assert_not_called()
driver.kill.assert_not_called()


if __name__ == "__main__":
unittest.main()
Loading
Loading