perf(fast-browser): bound readiness polling and verify asynchronous tab close
This commit is contained in:
@@ -824,7 +824,18 @@ class RealBrowserBackend:
|
|||||||
return self._helpers.cdp("Page.reload")
|
return self._helpers.cdp("Page.reload")
|
||||||
|
|
||||||
def wait_ready(self, timeout: float = NAVIGATION_TIMEOUT_SECONDS) -> bool:
|
def wait_ready(self, timeout: float = NAVIGATION_TIMEOUT_SECONDS) -> bool:
|
||||||
return bool(self._helpers.wait_for_load(timeout=timeout))
|
# Keep the harness' exact readyState query, but avoid its coarse 300ms
|
||||||
|
# sleep. This is only a readiness read; it never dispatches navigation.
|
||||||
|
if timeout <= 0:
|
||||||
|
return False
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while True:
|
||||||
|
if self._helpers.js("document.readyState") == "complete":
|
||||||
|
return True
|
||||||
|
remaining = deadline - time.monotonic()
|
||||||
|
if remaining <= 0:
|
||||||
|
return False
|
||||||
|
time.sleep(min(0.025, remaining))
|
||||||
|
|
||||||
def visible_text_content(self) -> str:
|
def visible_text_content(self) -> str:
|
||||||
# This is a fixed, worker-owned read operation. No caller expression,
|
# This is a fixed, worker-owned read operation. No caller expression,
|
||||||
@@ -1708,13 +1719,20 @@ class BrowserRuntimeWorker(BrowserWorker):
|
|||||||
raise self._fail("closeTab", "aborted", "close", "tab close was cancelled",
|
raise self._fail("closeTab", "aborted", "close", "tab close was cancelled",
|
||||||
mutation_state="unknown" if self._mutation_dispatched else "not_attempted")
|
mutation_state="unknown" if self._mutation_dispatched else "not_attempted")
|
||||||
# Do not assume a close acknowledgement means the target disappeared;
|
# Do not assume a close acknowledgement means the target disappeared;
|
||||||
# verify the current target inventory before reporting a verified close.
|
# verify inventories until the target is gone or the existing action
|
||||||
try:
|
# budget expires. The close mutation itself is dispatched exactly once.
|
||||||
self._refresh_tabs()
|
deadline = time.monotonic() + ACTION_TIMEOUT_SECONDS
|
||||||
except BrowserOperationError:
|
while True:
|
||||||
raise self._fail("closeTab", "worker_failure", "verify", "tab close could not be verified", mutation_state="unknown")
|
try:
|
||||||
if tab.target_id in self._target_to_tab:
|
self._refresh_tabs()
|
||||||
raise self._fail("closeTab", "worker_failure", "verify", "tab close could not be verified", mutation_state="unknown")
|
except BrowserOperationError:
|
||||||
|
raise self._fail("closeTab", "worker_failure", "verify", "tab close could not be verified", mutation_state="unknown")
|
||||||
|
if tab.target_id not in self._target_to_tab:
|
||||||
|
break
|
||||||
|
remaining = deadline - time.monotonic()
|
||||||
|
if remaining <= 0:
|
||||||
|
raise self._fail("closeTab", "worker_failure", "verify", "tab close could not be verified", mutation_state="unknown")
|
||||||
|
time.sleep(min(0.025, remaining))
|
||||||
self._target_to_tab.pop(tab.target_id, None)
|
self._target_to_tab.pop(tab.target_id, None)
|
||||||
self._tabs.pop(tab.tab_id, None)
|
self._tabs.pop(tab.tab_id, None)
|
||||||
if self._selected_tab_id == tab.tab_id:
|
if self._selected_tab_id == tab.tab_id:
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from unittest.mock import Mock, patch
|
|||||||
|
|
||||||
from browser_worker import (
|
from browser_worker import (
|
||||||
BrowserWorker, BrowserRuntimeWorker, ElementMetadata, WorkerError, BrowserOperationError,
|
BrowserWorker, BrowserRuntimeWorker, ElementMetadata, WorkerError, BrowserOperationError,
|
||||||
RealBrowserBackend, MAX_LINE_BYTES, MAX_READ_TEXT, process_request, run_stream,
|
RealBrowserBackend, ACTION_TIMEOUT_SECONDS, MAX_LINE_BYTES, MAX_READ_TEXT, process_request, run_stream,
|
||||||
validate_cdp_url, main,
|
validate_cdp_url, main,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -190,6 +190,46 @@ class RuntimeFakeBackend(FakeBackend):
|
|||||||
return b"\\x89PNG" + (b"x" * 32)
|
return b"\\x89PNG" + (b"x" * 32)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeClock:
|
||||||
|
def __init__(self):
|
||||||
|
self.now = 0.0
|
||||||
|
self.sleeps = []
|
||||||
|
|
||||||
|
def monotonic(self):
|
||||||
|
return self.now
|
||||||
|
|
||||||
|
def sleep(self, duration):
|
||||||
|
self.sleeps.append(duration)
|
||||||
|
self.now += duration
|
||||||
|
|
||||||
|
|
||||||
|
class DelayedCloseBackend(RuntimeFakeBackend):
|
||||||
|
def __init__(self, disappear_after=None):
|
||||||
|
super().__init__()
|
||||||
|
self.tabs = [self.tabs[0]]
|
||||||
|
self.disappear_after = disappear_after
|
||||||
|
self.inventory_reads = 0
|
||||||
|
self.post_close_reads = 0
|
||||||
|
self.pending_close = None
|
||||||
|
self.close_dispatches = []
|
||||||
|
|
||||||
|
def list_tabs(self):
|
||||||
|
self.inventory_reads += 1
|
||||||
|
rows = [dict(tab) for tab in self.tabs]
|
||||||
|
if self.pending_close is not None:
|
||||||
|
self.post_close_reads += 1
|
||||||
|
if self.disappear_after is not None and self.post_close_reads >= self.disappear_after:
|
||||||
|
target_id = self.pending_close
|
||||||
|
self.tabs[:] = [tab for tab in self.tabs if tab["targetId"] != target_id]
|
||||||
|
self.pending_close = None
|
||||||
|
rows = [dict(tab) for tab in self.tabs]
|
||||||
|
return rows
|
||||||
|
|
||||||
|
def close_tab(self, target_id):
|
||||||
|
self.close_dispatches.append(target_id)
|
||||||
|
self.pending_close = target_id
|
||||||
|
|
||||||
|
|
||||||
class RuntimeTests(unittest.TestCase):
|
class RuntimeTests(unittest.TestCase):
|
||||||
def make_runtime(self, backend=None, origins="*"):
|
def make_runtime(self, backend=None, origins="*"):
|
||||||
backend = backend or RuntimeFakeBackend()
|
backend = backend or RuntimeFakeBackend()
|
||||||
@@ -404,6 +444,102 @@ class RuntimeTests(unittest.TestCase):
|
|||||||
with self.assertRaisesRegex(BrowserOperationError, "stale|unavailable"):
|
with self.assertRaisesRegex(BrowserOperationError, "stale|unavailable"):
|
||||||
runtime.select_tab({"tabId": tab_id})
|
runtime.select_tab({"tabId": tab_id})
|
||||||
|
|
||||||
|
def test_close_waits_for_delayed_target_disappearance_without_redispatching(self):
|
||||||
|
backend = DelayedCloseBackend(disappear_after=2)
|
||||||
|
runtime = self.make_runtime(backend)
|
||||||
|
opener = runtime.list_tabs()[0]["tabId"]
|
||||||
|
backend.tabs.append({"targetId": "popup", "url": "http://fixture.test/popup",
|
||||||
|
"title": "Popup", "openerTargetId": "tab-a"})
|
||||||
|
popup = runtime.wait_popup({"openerTabId": opener})["tab"]
|
||||||
|
self.assertIsNotNone(popup)
|
||||||
|
popup_id = popup["tabId"]
|
||||||
|
clock = FakeClock()
|
||||||
|
|
||||||
|
with patch("browser_worker.time.monotonic", side_effect=clock.monotonic), \
|
||||||
|
patch("browser_worker.time.sleep", side_effect=clock.sleep):
|
||||||
|
result = runtime.close_tab({"tabId": popup_id})
|
||||||
|
|
||||||
|
self.assertEqual(result["mutationState"], "verified")
|
||||||
|
self.assertEqual(backend.close_dispatches, ["popup"])
|
||||||
|
self.assertGreaterEqual(backend.post_close_reads, 2)
|
||||||
|
self.assertGreater(clock.now, 0)
|
||||||
|
tabs = runtime.list_tabs()
|
||||||
|
self.assertEqual([tab["tabId"] for tab in tabs], [opener])
|
||||||
|
self.assertTrue(tabs[0]["selected"])
|
||||||
|
with self.assertRaisesRegex(BrowserOperationError, "stale|unavailable"):
|
||||||
|
runtime.select_tab({"tabId": popup_id})
|
||||||
|
|
||||||
|
def test_close_deadline_reports_unknown_without_retry_when_target_persists(self):
|
||||||
|
backend = DelayedCloseBackend(disappear_after=None)
|
||||||
|
runtime = self.make_runtime(backend)
|
||||||
|
opener = runtime.list_tabs()[0]["tabId"]
|
||||||
|
backend.tabs.append({"targetId": "popup", "url": "http://fixture.test/popup",
|
||||||
|
"title": "Popup", "openerTargetId": "tab-a"})
|
||||||
|
popup = runtime.wait_popup({"openerTabId": opener})["tab"]
|
||||||
|
self.assertIsNotNone(popup)
|
||||||
|
clock = FakeClock()
|
||||||
|
|
||||||
|
with patch("browser_worker.time.monotonic", side_effect=clock.monotonic), \
|
||||||
|
patch("browser_worker.time.sleep", side_effect=clock.sleep), \
|
||||||
|
self.assertRaises(BrowserOperationError) as caught:
|
||||||
|
runtime.close_tab({"tabId": popup["tabId"]})
|
||||||
|
|
||||||
|
details = caught.exception.details
|
||||||
|
self.assertEqual(details["code"], "worker_failure")
|
||||||
|
self.assertEqual(details["mutationState"], "unknown")
|
||||||
|
self.assertEqual(details["retry"], "never")
|
||||||
|
self.assertEqual(backend.close_dispatches, ["popup"])
|
||||||
|
self.assertGreaterEqual(backend.post_close_reads, 2)
|
||||||
|
self.assertGreaterEqual(clock.now, ACTION_TIMEOUT_SECONDS)
|
||||||
|
|
||||||
|
def test_close_inventory_failure_preserves_unknown_state_without_retry(self):
|
||||||
|
backend = DelayedCloseBackend(disappear_after=None)
|
||||||
|
runtime = self.make_runtime(backend)
|
||||||
|
opener = runtime.list_tabs()[0]["tabId"]
|
||||||
|
backend.tabs.append({"targetId": "popup", "url": "http://fixture.test/popup",
|
||||||
|
"title": "Popup", "openerTargetId": "tab-a"})
|
||||||
|
popup = runtime.wait_popup({"openerTabId": opener})["tab"]
|
||||||
|
self.assertIsNotNone(popup)
|
||||||
|
original_list_tabs = backend.list_tabs
|
||||||
|
def fail_after_close():
|
||||||
|
if backend.pending_close is not None:
|
||||||
|
raise RuntimeError("inventory unavailable")
|
||||||
|
return original_list_tabs()
|
||||||
|
backend.list_tabs = fail_after_close
|
||||||
|
|
||||||
|
with self.assertRaises(BrowserOperationError) as caught:
|
||||||
|
runtime.close_tab({"tabId": popup["tabId"]})
|
||||||
|
|
||||||
|
details = caught.exception.details
|
||||||
|
self.assertEqual(details["code"], "worker_failure")
|
||||||
|
self.assertEqual(details["mutationState"], "unknown")
|
||||||
|
self.assertEqual(details["retry"], "never")
|
||||||
|
self.assertEqual(backend.close_dispatches, ["popup"])
|
||||||
|
|
||||||
|
def test_close_cancellation_preserves_unknown_state_without_retry(self):
|
||||||
|
backend = DelayedCloseBackend(disappear_after=2)
|
||||||
|
runtime = self.make_runtime(backend)
|
||||||
|
opener = runtime.list_tabs()[0]["tabId"]
|
||||||
|
backend.tabs.append({"targetId": "popup", "url": "http://fixture.test/popup",
|
||||||
|
"title": "Popup", "openerTargetId": "tab-a"})
|
||||||
|
popup = runtime.wait_popup({"openerTabId": opener})["tab"]
|
||||||
|
self.assertIsNotNone(popup)
|
||||||
|
class Cancelled(BaseException):
|
||||||
|
pass
|
||||||
|
def cancel(_target_id):
|
||||||
|
backend.close_dispatches.append(_target_id)
|
||||||
|
raise Cancelled()
|
||||||
|
backend.close_tab = cancel
|
||||||
|
|
||||||
|
with self.assertRaises(BrowserOperationError) as caught:
|
||||||
|
runtime.close_tab({"tabId": popup["tabId"]})
|
||||||
|
|
||||||
|
details = caught.exception.details
|
||||||
|
self.assertEqual(details["code"], "aborted")
|
||||||
|
self.assertEqual(details["mutationState"], "unknown")
|
||||||
|
self.assertEqual(details["retry"], "never")
|
||||||
|
self.assertEqual(backend.close_dispatches, ["popup"])
|
||||||
|
|
||||||
def test_transport_wrapper_context_restores_base_and_uses_exact_result_shape(self):
|
def test_transport_wrapper_context_restores_base_and_uses_exact_result_shape(self):
|
||||||
backend = RuntimeFakeBackend()
|
backend = RuntimeFakeBackend()
|
||||||
runtime = self.make_runtime(backend)
|
runtime = self.make_runtime(backend)
|
||||||
@@ -921,6 +1057,68 @@ class BridgeRegressionTests(unittest.TestCase):
|
|||||||
if call.args[0] == "Input.dispatchKeyEvent" and call.kwargs["type"] == "rawKeyDown"]
|
if call.args[0] == "Input.dispatchKeyEvent" and call.kwargs["type"] == "rawKeyDown"]
|
||||||
self.assertEqual([call.kwargs["modifiers"] for call in keydowns], [4, 4])
|
self.assertEqual([call.kwargs["modifiers"] for call in keydowns], [4, 4])
|
||||||
|
|
||||||
|
def test_wait_ready_returns_immediately_for_complete_document(self):
|
||||||
|
backend = RealBrowserBackend.__new__(RealBrowserBackend)
|
||||||
|
helper = Mock()
|
||||||
|
helper.js.return_value = "complete"
|
||||||
|
backend._helpers = helper
|
||||||
|
clock = FakeClock()
|
||||||
|
|
||||||
|
with patch("browser_worker.time.monotonic", side_effect=clock.monotonic), \
|
||||||
|
patch("browser_worker.time.sleep", side_effect=clock.sleep):
|
||||||
|
self.assertTrue(backend.wait_ready(timeout=3.0))
|
||||||
|
|
||||||
|
helper.js.assert_called_once_with("document.readyState")
|
||||||
|
helper.wait_for_load.assert_not_called()
|
||||||
|
self.assertEqual(clock.sleeps, [])
|
||||||
|
|
||||||
|
def test_wait_ready_polls_loading_document_at_short_bounded_intervals(self):
|
||||||
|
backend = RealBrowserBackend.__new__(RealBrowserBackend)
|
||||||
|
helper = Mock()
|
||||||
|
helper.js.side_effect = ["loading", "complete"]
|
||||||
|
backend._helpers = helper
|
||||||
|
clock = FakeClock()
|
||||||
|
|
||||||
|
with patch("browser_worker.time.monotonic", side_effect=clock.monotonic), \
|
||||||
|
patch("browser_worker.time.sleep", side_effect=clock.sleep):
|
||||||
|
self.assertTrue(backend.wait_ready(timeout=3.0))
|
||||||
|
|
||||||
|
self.assertEqual(helper.js.call_count, 2)
|
||||||
|
self.assertEqual(len(clock.sleeps), 1)
|
||||||
|
self.assertGreater(clock.sleeps[0], 0)
|
||||||
|
self.assertLessEqual(clock.sleeps[0], 0.025)
|
||||||
|
helper.wait_for_load.assert_not_called()
|
||||||
|
|
||||||
|
def test_wait_ready_times_out_without_sleeping_past_deadline(self):
|
||||||
|
backend = RealBrowserBackend.__new__(RealBrowserBackend)
|
||||||
|
helper = Mock()
|
||||||
|
helper.js.return_value = "loading"
|
||||||
|
backend._helpers = helper
|
||||||
|
clock = FakeClock()
|
||||||
|
|
||||||
|
with patch("browser_worker.time.monotonic", side_effect=clock.monotonic), \
|
||||||
|
patch("browser_worker.time.sleep", side_effect=clock.sleep):
|
||||||
|
self.assertFalse(backend.wait_ready(timeout=0.075))
|
||||||
|
|
||||||
|
self.assertEqual(clock.now, 0.075)
|
||||||
|
self.assertTrue(all(duration <= 0.025 for duration in clock.sleeps))
|
||||||
|
helper.wait_for_load.assert_not_called()
|
||||||
|
|
||||||
|
def test_wait_ready_preserves_failed_ready_state_query(self):
|
||||||
|
backend = RealBrowserBackend.__new__(RealBrowserBackend)
|
||||||
|
helper = Mock()
|
||||||
|
helper.js.side_effect = RuntimeError("ready state query failed")
|
||||||
|
backend._helpers = helper
|
||||||
|
clock = FakeClock()
|
||||||
|
|
||||||
|
with patch("browser_worker.time.monotonic", side_effect=clock.monotonic), \
|
||||||
|
patch("browser_worker.time.sleep", side_effect=clock.sleep):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "ready state query failed"):
|
||||||
|
backend.wait_ready(timeout=3.0)
|
||||||
|
|
||||||
|
self.assertEqual(clock.sleeps, [])
|
||||||
|
helper.wait_for_load.assert_not_called()
|
||||||
|
|
||||||
def test_real_backend_unrestricted_policy_still_requires_one_web_tab(self):
|
def test_real_backend_unrestricted_policy_still_requires_one_web_tab(self):
|
||||||
helper = Mock()
|
helper = Mock()
|
||||||
helper.list_tabs.return_value = [
|
helper.list_tabs.return_value = [
|
||||||
|
|||||||
Reference in New Issue
Block a user