browserwright 0.6.7__tar.gz → 0.6.9__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. {browserwright-0.6.7 → browserwright-0.6.9}/PKG-INFO +1 -1
  2. {browserwright-0.6.7 → browserwright-0.6.9}/pyproject.toml +1 -1
  3. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/daemon/server/relay.py +164 -3
  4. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/repl/_smart_goto.py +22 -2
  5. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright.egg-info/PKG-INFO +1 -1
  6. {browserwright-0.6.7 → browserwright-0.6.9}/README.md +0 -0
  7. {browserwright-0.6.7 → browserwright-0.6.9}/setup.cfg +0 -0
  8. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/__init__.py +0 -0
  9. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/__main__.py +0 -0
  10. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/_executor/__init__.py +0 -0
  11. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/_executor/__main__.py +0 -0
  12. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/_executor/client.py +0 -0
  13. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/_executor/process.py +0 -0
  14. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/_executor/protocol.py +0 -0
  15. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/api.py +0 -0
  16. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/cdp.py +0 -0
  17. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/cli.py +0 -0
  18. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/daemon/__init__.py +0 -0
  19. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/daemon/_ipc.py +0 -0
  20. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/daemon/active_tab.py +0 -0
  21. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/daemon/auth.py +0 -0
  22. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/daemon/backends/__init__.py +0 -0
  23. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/daemon/backends/base.py +0 -0
  24. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/daemon/backends/cloud.py +0 -0
  25. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/daemon/backends/env.py +0 -0
  26. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/daemon/backends/extension.py +0 -0
  27. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/daemon/backends/rdp.py +0 -0
  28. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/daemon/cli.py +0 -0
  29. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/daemon/config.py +0 -0
  30. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/daemon/doctor.py +0 -0
  31. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/daemon/errors.py +0 -0
  32. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/daemon/launch_chrome.py +0 -0
  33. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/daemon/observability.py +0 -0
  34. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/daemon/platforms.py +0 -0
  35. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/daemon/resolver.py +0 -0
  36. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/daemon/server/__init__.py +0 -0
  37. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/daemon/server/daemon.py +0 -0
  38. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/daemon/server/executor_registry.py +0 -0
  39. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/daemon/server/extension_upstream.py +0 -0
  40. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/daemon/server/facade.py +0 -0
  41. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/daemon/server/facade_extension.py +0 -0
  42. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/daemon/server/listener.py +0 -0
  43. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/daemon/server/proxy.py +0 -0
  44. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/daemon/server/state.py +0 -0
  45. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/daemon/server/upstream.py +0 -0
  46. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/daemon/userscripts.py +0 -0
  47. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/discovery.py +0 -0
  48. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/errors.py +0 -0
  49. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/health.py +0 -0
  50. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/install.py +0 -0
  51. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/memory/__init__.py +0 -0
  52. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/memory/_md.py +0 -0
  53. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/memory/_yaml.py +0 -0
  54. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/memory/global_mem.py +0 -0
  55. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/memory/repl_mem.py +0 -0
  56. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/memory/session_decisions.py +0 -0
  57. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/memory/site_mem.py +0 -0
  58. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/mode_b_client.py +0 -0
  59. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/multitask.py +0 -0
  60. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/output_schema.py +0 -0
  61. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/primitives/__init__.py +0 -0
  62. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/primitives/discovery_api.py +0 -0
  63. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/primitives/http.py +0 -0
  64. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/primitives/inspect.py +0 -0
  65. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/primitives/interact.py +0 -0
  66. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/primitives/page.py +0 -0
  67. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/primitives/site.py +0 -0
  68. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/release_install.py +0 -0
  69. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/repl/__init__.py +0 -0
  70. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/repl/_namespace.py +0 -0
  71. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/repl/inline.py +0 -0
  72. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/repl/playwright_handle.py +0 -0
  73. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/repl/snapshot.py +0 -0
  74. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/session.py +0 -0
  75. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/session_create.py +0 -0
  76. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/session_ctx.py +0 -0
  77. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/session_registry.py +0 -0
  78. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/session_runtime.py +0 -0
  79. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/site_skills_starter/github.com/SKILL.md +0 -0
  80. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/site_skills_starter/github.com/memory.md +0 -0
  81. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/site_skills_starter/github.com/tasks/list_issues.py +0 -0
  82. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/site_skills_starter/google.com/SKILL.md +0 -0
  83. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/site_skills_starter/google.com/memory.md +0 -0
  84. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/site_skills_starter/google.com/tasks/search.py +0 -0
  85. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/site_skills_starter/producthunt.com/SKILL.md +0 -0
  86. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/site_skills_starter/producthunt.com/memory.md +0 -0
  87. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/site_skills_starter/producthunt.com/tasks/today.py +0 -0
  88. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/site_skills_starter/wikipedia.org/SKILL.md +0 -0
  89. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/site_skills_starter/wikipedia.org/memory.md +0 -0
  90. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/site_skills_starter/wikipedia.org/tasks/lookup.py +0 -0
  91. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/site_skills_starter/ycombinator.com/SKILL.md +0 -0
  92. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/site_skills_starter/ycombinator.com/memory.md +0 -0
  93. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/site_skills_starter/ycombinator.com/tasks/front_page.py +0 -0
  94. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/skill_doc.py +0 -0
  95. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/skill_runtime.md +0 -0
  96. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/subscriptions.py +0 -0
  97. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/task_runner.py +0 -0
  98. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright/version.py +0 -0
  99. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright.egg-info/SOURCES.txt +0 -0
  100. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright.egg-info/dependency_links.txt +0 -0
  101. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright.egg-info/entry_points.txt +0 -0
  102. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright.egg-info/requires.txt +0 -0
  103. {browserwright-0.6.7 → browserwright-0.6.9}/src/browserwright.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: browserwright
3
- Version: 0.6.7
3
+ Version: 0.6.9
4
4
  Summary: Browserwright — let AI/code agents drive a real or isolated browser and author userscripts. Single package: the agent-facing REPL/site-skills/memory layer plus the bundled browser-resolving daemon (CDP proxy + extension/cloud backends).
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: cdp-use==1.4.5
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "browserwright"
7
- version = "0.6.7"
7
+ version = "0.6.9"
8
8
  description = "Browserwright — let AI/code agents drive a real or isolated browser and author userscripts. Single package: the agent-facing REPL/site-skills/memory layer plus the bundled browser-resolving daemon (CDP proxy + extension/cloud backends)."
9
9
  requires-python = ">=3.11"
10
10
  dependencies = [
@@ -78,6 +78,9 @@ DEFAULT_RELAY_PORT = 19989
78
78
  # with the playwriter / OpenCLI experience.
79
79
  ATTACH_RETRY_LIMIT = 3
80
80
  ATTACH_RETRY_BACKOFF = (0.1, 0.3, 0.8) # seconds; len must equal ATTACH_RETRY_LIMIT
81
+ APP_PING_INTERVAL = 5.0
82
+ STALE_FRAME_AFTER = 30.0
83
+ RECONNECT_WAIT_TIMEOUT = 35.0
81
84
 
82
85
 
83
86
  @dataclass
@@ -110,6 +113,8 @@ class _ExtensionConn:
110
113
  hello_received: asyncio.Event = field(default_factory=asyncio.Event)
111
114
  pending: dict[int, asyncio.Future] = field(default_factory=dict)
112
115
  tabs: dict[int, GhostTarget] = field(default_factory=dict)
116
+ last_frame_ts: float = field(default_factory=time.monotonic)
117
+ app_ping_task: asyncio.Task | None = None
113
118
 
114
119
 
115
120
  class RelayServer:
@@ -273,6 +278,9 @@ class RelayServer:
273
278
  heuristic-recent-activate table.
274
279
  """
275
280
  ext = self._pick_active_extension()
281
+ if ext is None:
282
+ return None
283
+ ext = await self._ensure_extension_fresh(ext)
276
284
  if ext is None:
277
285
  return None
278
286
  return await self._request(ext, {"type": "queryActiveTab"},
@@ -280,7 +288,7 @@ class RelayServer:
280
288
 
281
289
  async def query_group_tabs(self, group_name: str | None = None, *,
282
290
  group_id: int | None = None,
283
- timeout: float = 5.0) -> dict | None:
291
+ timeout: float = 15.0) -> dict | None:
284
292
  """Live membership query: ask the extension for the tabs of the
285
293
  session's tab group. ``group_id`` is the durable primary key (the
286
294
  numeric Chrome groupId); ``group_name`` is accepted for older callers
@@ -291,6 +299,9 @@ class RelayServer:
291
299
  Returns None when no extension is connected (mirrors
292
300
  query_active_tab's caller-falls-back contract)."""
293
301
  ext = self._pick_active_extension()
302
+ if ext is None:
303
+ return None
304
+ ext = await self._ensure_extension_fresh(ext)
294
305
  if ext is None:
295
306
  return None
296
307
  body: dict = {"type": "queryGroup"}
@@ -322,6 +333,7 @@ class RelayServer:
322
333
  ext = self._pick_active_extension()
323
334
  if ext is None:
324
335
  raise RuntimeError("no extension connected")
336
+ ext = await self._ensure_extension_fresh_or_raise(ext)
325
337
  last_err: Exception | None = None
326
338
  body: dict = {"type": "attachActive"}
327
339
  if group_name:
@@ -367,6 +379,7 @@ class RelayServer:
367
379
  ext = self._pick_active_extension()
368
380
  if ext is None:
369
381
  raise RuntimeError("no extension connected")
382
+ ext = await self._ensure_extension_fresh_or_raise(ext)
370
383
  # Idempotency: extension may already hold chrome.debugger.attach on
371
384
  # this tab (popup click, prior daemon lifecycle — the SW survives
372
385
  # daemon restarts and re-announces attached tabs on reconnect, so
@@ -403,6 +416,9 @@ class RelayServer:
403
416
  async def detach_tab(self, tab_id: int, *,
404
417
  timeout: float = 5.0) -> None:
405
418
  ext = self._extension_for_tab(tab_id)
419
+ if ext is None:
420
+ return
421
+ ext = await self._ensure_extension_fresh(ext)
406
422
  if ext is None:
407
423
  return
408
424
  try:
@@ -440,6 +456,7 @@ class RelayServer:
440
456
  ext = self._pick_active_extension()
441
457
  if ext is None:
442
458
  raise RuntimeError("no extension connected")
459
+ ext = await self._ensure_extension_fresh_or_raise(ext)
443
460
  body: dict = {"type": "createTab", "url": url}
444
461
  if group_name:
445
462
  body["groupName"] = group_name
@@ -489,6 +506,7 @@ class RelayServer:
489
506
  ext = self._extension_for_tab(tab_id)
490
507
  if ext is None:
491
508
  raise RuntimeError(f"no extension knows tab {tab_id}")
509
+ ext = await self._ensure_extension_fresh_or_raise(ext)
492
510
  try:
493
511
  await self._request(
494
512
  ext, {"type": "closeTab", "tabId": tab_id}, timeout=timeout)
@@ -504,6 +522,7 @@ class RelayServer:
504
522
  ext = self._extension_for_tab(tab_id)
505
523
  if ext is None:
506
524
  raise RuntimeError(f"no extension owns tab {tab_id}")
525
+ ext = await self._ensure_extension_fresh_or_raise(ext)
507
526
  return await self._request(ext, {
508
527
  "type": "command",
509
528
  "tabId": tab_id,
@@ -522,6 +541,7 @@ class RelayServer:
522
541
  ext = self._pick_active_extension()
523
542
  if ext is None:
524
543
  raise RuntimeError("no extension connected")
544
+ ext = await self._ensure_extension_fresh_or_raise(ext)
525
545
  return await self._request(
526
546
  ext, {"type": f"userscript.{verb}", **payload}, timeout=timeout)
527
547
 
@@ -546,6 +566,106 @@ class RelayServer:
546
566
  self._next_cmd_id += 1
547
567
  return v
548
568
 
569
+ def _extension_is_stale(self, ext: _ExtensionConn) -> bool:
570
+ if not ext.hello_received.is_set():
571
+ return False
572
+ return (time.monotonic() - ext.last_frame_ts) > STALE_FRAME_AFTER
573
+
574
+ async def _ensure_extension_fresh(
575
+ self, ext: _ExtensionConn,
576
+ ) -> _ExtensionConn | None:
577
+ """Return a live extension connection, force-closing ghost sockets.
578
+
579
+ MV3 can suspend the SW while Chrome's network process keeps the TCP
580
+ websocket ESTABLISHED. Protocol pings still succeed there, but no app
581
+ frames arrive. The daemon treats missing app frames as authoritative
582
+ and tears down the ghost before sending a user command.
583
+ """
584
+ if not self._extension_is_stale(ext):
585
+ return ext
586
+ await self._force_close_extension(ext, reason="stale app-level heartbeat")
587
+ return await self._wait_for_replacement(ext, timeout=RECONNECT_WAIT_TIMEOUT)
588
+
589
+ async def _ensure_extension_fresh_or_raise(
590
+ self, ext: _ExtensionConn,
591
+ ) -> _ExtensionConn:
592
+ fresh = await self._ensure_extension_fresh(ext)
593
+ if fresh is None:
594
+ raise RuntimeError(
595
+ "extension relay connection appears stale and did not reconnect "
596
+ f"within {RECONNECT_WAIT_TIMEOUT:.0f}s")
597
+ return fresh
598
+
599
+ async def _force_close_extension(self, ext: _ExtensionConn, *, reason: str) -> None:
600
+ logger.warning(
601
+ "force-closing stale extension relay connection: install_id=%s reason=%s",
602
+ ext.install_id or "(pending)",
603
+ reason,
604
+ )
605
+ for fut in list(ext.pending.values()):
606
+ if not fut.done():
607
+ fut.set_exception(ConnectionError(f"extension relay closed: {reason}"))
608
+ if fut.cancelled():
609
+ continue
610
+ with contextlib.suppress(BaseException):
611
+ fut.exception()
612
+ with contextlib.suppress(Exception):
613
+ await asyncio.wait_for(
614
+ ext.conn.close(code=1011, reason=reason),
615
+ timeout=1.0,
616
+ )
617
+
618
+ async def _wait_for_replacement(
619
+ self, old_ext: _ExtensionConn, *, timeout: float,
620
+ allow_any_install: bool = False,
621
+ ) -> _ExtensionConn | None:
622
+ deadline = time.monotonic() + max(0.0, timeout)
623
+ install_id = old_ext.install_id
624
+ while time.monotonic() < deadline:
625
+ candidates = [
626
+ ext for ext in self._extensions.values()
627
+ if ext is not old_ext and ext.hello_received.is_set()
628
+ ]
629
+ if install_id:
630
+ for ext in candidates:
631
+ if ext.install_id == install_id:
632
+ return ext
633
+ if allow_any_install and candidates:
634
+ return candidates[0]
635
+ await asyncio.sleep(0.1)
636
+ if install_id:
637
+ return None
638
+ if candidates := [
639
+ ext for ext in self._extensions.values()
640
+ if ext is not old_ext and ext.hello_received.is_set()
641
+ ]:
642
+ return candidates[0]
643
+ return None
644
+
645
+ async def _retry_request_on_replacement(
646
+ self,
647
+ ext: _ExtensionConn,
648
+ body: dict,
649
+ *,
650
+ timeout: float,
651
+ loop: asyncio.AbstractEventLoop,
652
+ ) -> dict | None:
653
+ await self._force_close_extension(ext, reason=f"{body.get('type')} request failed")
654
+ replacement = await self._wait_for_replacement(
655
+ ext, timeout=RECONNECT_WAIT_TIMEOUT)
656
+ if replacement is None:
657
+ raise ConnectionError(
658
+ "extension relay did not reconnect after request failure")
659
+ retry_id = self._alloc_id()
660
+ retry_body = {**{k: v for k, v in body.items() if k != "id"}, "id": retry_id}
661
+ retry_fut: asyncio.Future = loop.create_future()
662
+ replacement.pending[retry_id] = retry_fut
663
+ try:
664
+ await replacement.conn.send(json.dumps(retry_body))
665
+ return await asyncio.wait_for(retry_fut, timeout=timeout)
666
+ finally:
667
+ replacement.pending.pop(retry_id, None)
668
+
549
669
  async def _request(self, ext: _ExtensionConn, body: dict, *,
550
670
  timeout: float) -> dict | None:
551
671
  cmd_id = self._alloc_id()
@@ -556,7 +676,18 @@ class RelayServer:
556
676
  try:
557
677
  await ext.conn.send(json.dumps(body))
558
678
  return await asyncio.wait_for(fut, timeout=timeout)
679
+ except asyncio.TimeoutError:
680
+ if not self._extension_is_stale(ext):
681
+ raise
682
+ return await self._retry_request_on_replacement(
683
+ ext, body, timeout=timeout, loop=loop)
684
+ except (ConnectionError, websockets.exceptions.ConnectionClosed):
685
+ return await self._retry_request_on_replacement(
686
+ ext, body, timeout=timeout, loop=loop)
559
687
  finally:
688
+ if not fut.cancelled():
689
+ with contextlib.suppress(BaseException):
690
+ fut.exception()
560
691
  ext.pending.pop(cmd_id, None)
561
692
 
562
693
  # ---- ws handlers -----------------------------------------------------
@@ -633,6 +764,7 @@ class RelayServer:
633
764
  self._extensions[temp_key] = ext
634
765
  try:
635
766
  async for raw in conn:
767
+ ext.last_frame_ts = time.monotonic()
636
768
  if not isinstance(raw, (str, bytes)):
637
769
  continue
638
770
  text = raw if isinstance(raw, str) else raw.decode("utf-8", errors="replace")
@@ -650,8 +782,12 @@ class RelayServer:
650
782
  logger.warning("extension handler crashed: %r", e)
651
783
  finally:
652
784
  key = ext.install_id or temp_key
653
- self._extensions.pop(key, None)
654
- self._extensions.pop(temp_key, None)
785
+ if self._extensions.get(key) is ext:
786
+ self._extensions.pop(key, None)
787
+ if self._extensions.get(temp_key) is ext:
788
+ self._extensions.pop(temp_key, None)
789
+ if ext.app_ping_task is not None:
790
+ ext.app_ping_task.cancel()
655
791
  for fut in list(ext.pending.values()):
656
792
  if not fut.done():
657
793
  fut.set_exception(ConnectionError("extension disconnected"))
@@ -673,6 +809,8 @@ class RelayServer:
673
809
  self._extensions.pop(temp_key, None)
674
810
  self._extensions[ext.install_id or temp_key] = ext
675
811
  ext.hello_received.set()
812
+ if ext.app_ping_task is None or ext.app_ping_task.done():
813
+ ext.app_ping_task = asyncio.create_task(self._app_ping_loop(ext))
676
814
  self._first_ready.set()
677
815
  if (
678
816
  ext.extension_protocol_version
@@ -709,6 +847,9 @@ class RelayServer:
709
847
  pass
710
848
  return
711
849
 
850
+ if kind == "pong":
851
+ return
852
+
712
853
  if kind == "attached":
713
854
  tab_id = int(msg.get("tabId", -1))
714
855
  if tab_id < 0:
@@ -762,6 +903,26 @@ class RelayServer:
762
903
 
763
904
  logger.debug("extension sent unknown type %r: %s", kind, str(msg)[:100])
764
905
 
906
+ async def _app_ping_loop(self, ext: _ExtensionConn) -> None:
907
+ try:
908
+ while True:
909
+ await asyncio.sleep(APP_PING_INTERVAL)
910
+ if not ext.hello_received.is_set():
911
+ continue
912
+ if self._extension_is_stale(ext):
913
+ await self._force_close_extension(
914
+ ext, reason="missing app-level frames")
915
+ return
916
+ try:
917
+ await ext.conn.send(json.dumps({
918
+ "type": "ping",
919
+ "ts": int(time.time() * 1000),
920
+ }))
921
+ except Exception:
922
+ return
923
+ except asyncio.CancelledError:
924
+ raise
925
+
765
926
  async def _fanout_listeners(self, msg: dict) -> None:
766
927
  """Call every additional fan-out observer with the raw extension
767
928
  message (PR2). Isolated from the primary `_on_event` so one observer
@@ -62,8 +62,11 @@ def patch_page_goto(page: Any) -> Any:
62
62
  response = orig_goto(url, timeout=timeout_ms,
63
63
  wait_until="commit", referer=referer)
64
64
  except Exception as exc: # noqa: BLE001 - translate Playwright failures.
65
- network.detach()
66
- raise _page_load_failed(url, "commit", exc) from exc
65
+ if _looks_loaded(self):
66
+ response = None
67
+ else:
68
+ network.detach()
69
+ raise _page_load_failed(url, "commit", exc) from exc
67
70
 
68
71
  _wait_for_domcontentloaded(self, _remaining_timeout_ms(deadline))
69
72
  try:
@@ -115,6 +118,23 @@ def _wait_for_domcontentloaded(page: Any, remaining_timeout_ms: int) -> None:
115
118
  pass
116
119
 
117
120
 
121
+ def _looks_loaded(page: Any) -> bool:
122
+ """Detect successful navigations masked by commit watcher races.
123
+
124
+ Some redirects/client transitions can leave Playwright's commit wait in an
125
+ error state even after the document is usable. Treat any probe failure as
126
+ not loaded so true failures still follow the existing PageLoadFailed path.
127
+ """
128
+ try:
129
+ url = page.url
130
+ if not url or url == "about:blank":
131
+ return False
132
+ ready_state = page.evaluate("() => document.readyState")
133
+ return ready_state != "loading"
134
+ except Exception:
135
+ return False
136
+
137
+
118
138
  def _smart_wait_settled(page: Any, deadline: float | None, network: "_NetworkMonitor") -> None:
119
139
  try:
120
140
  page.evaluate(_INSTALL_MONITOR_JS)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: browserwright
3
- Version: 0.6.7
3
+ Version: 0.6.9
4
4
  Summary: Browserwright — let AI/code agents drive a real or isolated browser and author userscripts. Single package: the agent-facing REPL/site-skills/memory layer plus the bundled browser-resolving daemon (CDP proxy + extension/cloud backends).
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: cdp-use==1.4.5
File without changes
File without changes