browserwright 0.17.2__tar.gz → 0.17.4__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 (111) hide show
  1. {browserwright-0.17.2/src/browserwright.egg-info → browserwright-0.17.4}/PKG-INFO +1 -1
  2. {browserwright-0.17.2 → browserwright-0.17.4}/pyproject.toml +2 -1
  3. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/_executor/process.py +184 -40
  4. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/errors.py +49 -3
  5. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/repl/_smart_goto.py +126 -19
  6. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/repl/playwright_handle.py +144 -1
  7. {browserwright-0.17.2 → browserwright-0.17.4/src/browserwright.egg-info}/PKG-INFO +1 -1
  8. {browserwright-0.17.2 → browserwright-0.17.4}/LICENSE +0 -0
  9. {browserwright-0.17.2 → browserwright-0.17.4}/README.md +0 -0
  10. {browserwright-0.17.2 → browserwright-0.17.4}/setup.cfg +0 -0
  11. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/__init__.py +0 -0
  12. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/__main__.py +0 -0
  13. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/_executor/__init__.py +0 -0
  14. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/_executor/__main__.py +0 -0
  15. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/_executor/client.py +0 -0
  16. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/_executor/protocol.py +0 -0
  17. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/_text.py +0 -0
  18. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/cdp.py +0 -0
  19. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/cli.py +0 -0
  20. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/__init__.py +0 -0
  21. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/_ipc.py +0 -0
  22. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/_net.py +0 -0
  23. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/_rpc.py +0 -0
  24. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/_stale.py +0 -0
  25. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/backends/__init__.py +0 -0
  26. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/backends/base.py +0 -0
  27. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/backends/cdp.py +0 -0
  28. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/backends/extension.py +0 -0
  29. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/cli.py +0 -0
  30. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/config.py +0 -0
  31. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/doctor.py +0 -0
  32. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/errors.py +0 -0
  33. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/launch_chrome.py +0 -0
  34. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/launchagent.py +0 -0
  35. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/observability.py +0 -0
  36. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/platforms.py +0 -0
  37. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/probe.py +0 -0
  38. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/relay_status.py +0 -0
  39. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/resolver.py +0 -0
  40. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/restart_guard.py +0 -0
  41. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/server/__init__.py +0 -0
  42. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/server/daemon.py +0 -0
  43. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/server/exec_relay.py +0 -0
  44. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/server/executor_registry.py +0 -0
  45. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/server/extension_upstream.py +0 -0
  46. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/server/facade.py +0 -0
  47. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/server/facade_extension.py +0 -0
  48. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/server/listener.py +0 -0
  49. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/server/proxy.py +0 -0
  50. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/server/relay.py +0 -0
  51. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/server/state.py +0 -0
  52. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/server/status.py +0 -0
  53. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/server/upstream.py +0 -0
  54. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/server/verbs.py +0 -0
  55. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/supervise.py +0 -0
  56. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/userscripts.py +0 -0
  57. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon_url.py +0 -0
  58. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/discovery.py +0 -0
  59. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/health.py +0 -0
  60. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/install.py +0 -0
  61. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/memory/__init__.py +0 -0
  62. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/memory/_lock.py +0 -0
  63. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/memory/_md.py +0 -0
  64. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/memory/_yaml.py +0 -0
  65. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/memory/global_mem.py +0 -0
  66. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/memory/site_mem.py +0 -0
  67. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/mode_b_client.py +0 -0
  68. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/output_schema.py +0 -0
  69. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/primitives/__init__.py +0 -0
  70. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/primitives/discovery_api.py +0 -0
  71. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/primitives/http.py +0 -0
  72. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/primitives/site.py +0 -0
  73. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/repl/__init__.py +0 -0
  74. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/repl/_md_convert.py +0 -0
  75. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/repl/_md_normalize.py +0 -0
  76. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/repl/_namespace.py +0 -0
  77. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/repl/inline.py +0 -0
  78. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/repl/markdown.py +0 -0
  79. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/repl/snapshot.py +0 -0
  80. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/repl/vendor/README.md +0 -0
  81. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/repl/vendor/readability.js +0 -0
  82. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/session.py +0 -0
  83. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/session_create.py +0 -0
  84. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/session_ctx.py +0 -0
  85. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/session_registry.py +0 -0
  86. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/session_runtime.py +0 -0
  87. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/site_skills_starter/github.com/SKILL.md +0 -0
  88. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/site_skills_starter/github.com/memory.md +0 -0
  89. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/site_skills_starter/github.com/tasks/list_issues.py +0 -0
  90. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/site_skills_starter/google.com/SKILL.md +0 -0
  91. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/site_skills_starter/google.com/memory.md +0 -0
  92. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/site_skills_starter/google.com/tasks/search.py +0 -0
  93. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/site_skills_starter/producthunt.com/SKILL.md +0 -0
  94. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/site_skills_starter/producthunt.com/memory.md +0 -0
  95. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/site_skills_starter/producthunt.com/tasks/today.py +0 -0
  96. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/site_skills_starter/wikipedia.org/SKILL.md +0 -0
  97. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/site_skills_starter/wikipedia.org/memory.md +0 -0
  98. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/site_skills_starter/wikipedia.org/tasks/lookup.py +0 -0
  99. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/site_skills_starter/ycombinator.com/SKILL.md +0 -0
  100. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/site_skills_starter/ycombinator.com/memory.md +0 -0
  101. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/site_skills_starter/ycombinator.com/tasks/front_page.py +0 -0
  102. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/skill_doc.py +0 -0
  103. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/skill_runtime.md +0 -0
  104. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/tab_surface.py +0 -0
  105. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/task_runner.py +0 -0
  106. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/version.py +0 -0
  107. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright.egg-info/SOURCES.txt +0 -0
  108. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright.egg-info/dependency_links.txt +0 -0
  109. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright.egg-info/entry_points.txt +0 -0
  110. {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright.egg-info/requires.txt +0 -0
  111. {browserwright-0.17.2 → browserwright-0.17.4}/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.17.2
3
+ Version: 0.17.4
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
  License-Expression: AGPL-3.0-only
6
6
  Requires-Python: >=3.11
@@ -15,7 +15,7 @@ name = "browserwright"
15
15
  # stamping regex is `^(version\s*=\s*)["'][^"']+["']\s*$` and CI aborts unless
16
16
  # it matches exactly once. `tests/skill/test_release_versioning.py` enforces all
17
17
  # of that before a tag is ever pushed. See RELEASING.md.
18
- version = "0.17.2"
18
+ version = "0.17.4"
19
19
  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)."
20
20
  requires-python = ">=3.11"
21
21
  license = "AGPL-3.0-only"
@@ -94,6 +94,7 @@ asyncio_mode = "auto"
94
94
  testpaths = ["tests"]
95
95
  markers = [
96
96
  "real_chrome: end-to-end test that launches a real Chrome + extension (skipped unless explicitly selected)",
97
+ "slow: long-running diagnostic/experiment; not part of the fast gate",
97
98
  "launchd: loads throwaway LaunchAgents to measure real launchd behaviour; macOS-only, skipped unless explicitly selected (issue #57)",
98
99
  ]
99
100
 
@@ -41,7 +41,7 @@ from contextlib import contextmanager, redirect_stdout
41
41
  from typing import Any
42
42
 
43
43
  from .._text import spill_text, truncate_hard
44
- from ..errors import BrowserwrightError, serialize
44
+ from ..errors import BrowserwrightError, TabRebindFailed, serialize
45
45
  from .protocol import (
46
46
  MAX_TEXT_CHARS,
47
47
  TERMINAL_DEADLINE_EXCEEDED,
@@ -78,6 +78,16 @@ def _is_target_closed_family(exc: BaseException) -> bool:
78
78
  re-attaches the session (B, target-closed self-heal)."""
79
79
  if type(exc).__name__ == "TargetClosedError":
80
80
  return True
81
+ # Issue #86: `page.goto` is wrapped by `repl._smart_goto`, which TRANSLATES
82
+ # the underlying TargetClosedError into `PageLoadFailed(reason=
83
+ # "target-closed")` — a BrowserwrightError. `_execute` catches
84
+ # BrowserwrightError in an EARLIER branch than the raw-exception one this
85
+ # predicate used to guard, so the whole self-heal was unreachable for the
86
+ # single most common way a session meets a dead tab: navigating. Read the
87
+ # classifier's own bucket rather than hoping its rendered message still
88
+ # carries the original marker text (it is bounded to 300 chars).
89
+ if getattr(exc, "reason", None) == "target-closed":
90
+ return True
81
91
  msg = str(exc).lower()
82
92
  return any(marker in msg for marker in _TARGET_CLOSED_MARKERS)
83
93
 
@@ -244,6 +254,16 @@ class _Worker:
244
254
  self._page_target_id: str | None = None
245
255
  # Re-entrancy guard for target-changed rebinds (issue #21).
246
256
  self._rebinding_page = False
257
+ # Issue #86: how many dead-tab rebinds this call has already spent.
258
+ # The bound is ONE. A second dead tab inside a single call is not a
259
+ # tab that needs rebinding, it is a browser that cannot hold one, and
260
+ # answering it with another rebind is how a bug turns into a loop.
261
+ self._rebinds_this_call = 0
262
+ # Issue #86: set when a call failed with a target-closed-family error,
263
+ # so the next call re-enters the bind discipline even if Playwright has
264
+ # not yet processed the target's destruction (its `pages` cache is fed
265
+ # by channel events that only drain while a sync call is in flight).
266
+ self._page_maybe_dead = False
247
267
  # The globals dict of the CURRENTLY executing heredoc, so a mid-call
248
268
  # rebind can swap its ``page`` name (issue #21 same-call visibility).
249
269
  self._active_globals: dict[str, Any] | None = None
@@ -506,7 +526,7 @@ class _Worker:
506
526
  finally:
507
527
  self._rebinding_page = False
508
528
 
509
- def _rebind_page(self, sess=None) -> None:
529
+ def _rebind_page(self, sess=None, *, strict: bool = False) -> None:
510
530
  """Rebind ``self._page`` (and the running heredoc's ``page`` name) to
511
531
  the session's current target, re-resolving through the same
512
532
  reuse/recover/adopt/open discipline the cold-start bind uses.
@@ -514,19 +534,29 @@ class _Worker:
514
534
  ``sess`` is the session whose binding changed (the hook passes the
515
535
  one ``bind_target`` / ``open_session_tab`` / ``close_session_tab``
516
536
  received); falls back to ``current_session()`` for the pre-execute
517
- reconcile path."""
537
+ reconcile path.
538
+
539
+ ``strict=True`` is the issue #86 dead-tab path: RAISE
540
+ ``TabRebindFailed`` instead of warning and keeping the old page. The
541
+ lenient behaviour is right when the old page is still usable (a
542
+ ``switch_tab`` whose destination could not be resolved); it is exactly
543
+ wrong when the old page IS the corpse being replaced, because "keep the
544
+ old page and warn" is how this failure became permanent."""
518
545
  from ..repl import playwright_handle as ph
519
546
  from ..repl.snapshot import make_snapshot
520
547
 
521
548
  if sess is None:
522
549
  from ..session import current_session
523
550
  sess = current_session()
524
- try:
525
- page = ph.bind_current_page(self._context, sess)
526
- except Exception as e: # noqa: BLE001 - keep the old page + warn
527
- self._call_warnings.append(
528
- f"page rebind to the session's current tab failed: {e}")
529
- return
551
+ if strict:
552
+ page = ph.rebind_dead_page(self._context, sess)
553
+ else:
554
+ try:
555
+ page = ph.bind_current_page(self._context, sess)
556
+ except Exception as e: # noqa: BLE001 - keep the old page + warn
557
+ self._call_warnings.append(
558
+ f"page rebind to the session's current tab failed: {e}")
559
+ return
530
560
  self._page = page
531
561
  self._page_target_id = sess.current_target_id
532
562
  self._live_page_holder.page = page
@@ -542,15 +572,82 @@ class _Worker:
542
572
  if self._active_globals is not None:
543
573
  self._active_globals["page"] = page
544
574
 
575
+ # ---- issue #86: a session whose tab died must rebind, not fail forever --
576
+
577
+ def _live_page_is_dead(self) -> bool:
578
+ """Is the held ``page`` a handle to a tab that no longer exists?
579
+
580
+ Drains queued channel events first: the executor sits in ``queue.get``
581
+ between calls, so a tab that died while it was idle has not been
582
+ reported to ``page.is_closed()`` yet. Without the drain the probe is
583
+ blind to exactly the case issue #86 is about.
584
+ """
585
+ from ..repl.playwright_handle import drain_page_events, page_is_dead
586
+
587
+ if self._context is not None:
588
+ drain_page_events(self._context)
589
+ return page_is_dead(self._page)
590
+
591
+ def _rebind_dead_page(self) -> bool:
592
+ """Issue #86: re-enter the bind discipline for a dead tab. ONE attempt.
593
+
594
+ The executor holds ``self._page`` for its whole life, so a tab that
595
+ dies under it used to make EVERY later call in that session fail in
596
+ 1-4ms with ``TargetClosedError`` — for good. Nothing was wrong with the
597
+ session, the browser, or the URL; the binding was simply never
598
+ revisited.
599
+
600
+ Recovery goes through ``bind_current_page`` →
601
+ ``resolve_current_target``, never ``context.new_page()``: only the
602
+ agent path opens the replacement tab INSIDE this session's tab group,
603
+ which is what keeps the ledger and the Playwright view on one tab (see
604
+ ``bind_current_page``'s docstring — an un-grouped tab is ledger drift
605
+ and tab explosion).
606
+
607
+ Returns True when a live page is bound, False when this call has
608
+ already spent its one attempt (or a rebind is already in flight).
609
+ Raises ``TabRebindFailed`` (a DIFFERENT error from the dead-tab
610
+ condition, on purpose) when the rebind itself fails — the caller turns
611
+ either outcome into a terminal response, so a browser that genuinely
612
+ cannot hold a tab open never turns into a rebind loop.
613
+ """
614
+ from ..session import current_session
615
+
616
+ # Armed BEFORE the attempt and cleared only on success, so a rebind
617
+ # that could not even run (re-entrancy, budget spent) still leaves the
618
+ # next call knowing the binding is suspect.
619
+ self._page_maybe_dead = True
620
+ if self._rebinding_page or self._rebinds_this_call >= 1:
621
+ return False
622
+ self._rebinds_this_call += 1
623
+ self._rebinding_page = True
624
+ try:
625
+ self._rebind_page(current_session(), strict=True)
626
+ finally:
627
+ self._rebinding_page = False
628
+ self._page_maybe_dead = False
629
+ return True
630
+
545
631
  def _reconcile_page_binding(self) -> None:
546
632
  """Before each execute, rebind the live page if the session's DURABLE
547
633
  current target (ledger) moved — e.g. an in-process CLI heredoc called
548
634
  ``bind_target`` / ``close_session_tab`` without touching the page
549
- surface, or a previous call left the ledger ahead of this executor."""
635
+ surface, or a previous call left the ledger ahead of this executor.
636
+
637
+ Issue #86 rides on the same pre-call hook: a page whose tab is GONE is
638
+ rebound here too. The ledger comparison below cannot see that case —
639
+ when a tab dies without anyone telling browserwright, the ledger still
640
+ names the dead target, so it matches ``_page_target_id`` and the
641
+ reconcile returned early. Checking liveness is what makes the dead
642
+ binding recoverable instead of permanent, and it is cheap: both probes
643
+ in ``page_is_dead`` are local, with no CDP round-trip."""
550
644
  if not self._connected or self._context is None or self._page is None:
551
645
  return
552
646
  if self._rebinding_page:
553
647
  return
648
+ if self._page_maybe_dead or self._live_page_is_dead():
649
+ self._rebind_dead_page()
650
+ return
554
651
  try:
555
652
  from .. import session_registry as reg
556
653
  rec = reg.get(self._session_id)
@@ -629,6 +726,7 @@ class _Worker:
629
726
  the facade is up); `_connected` stays False so the next execute retries."""
630
727
  self._call_warnings = []
631
728
  self._call_screenshots = []
729
+ self._rebinds_this_call = 0
632
730
  try:
633
731
  self._ensure_cold_started()
634
732
  except BrowserwrightError as e:
@@ -651,8 +749,19 @@ class _Worker:
651
749
  # running heredoc's `page` name too, not just the next call's.
652
750
  # Also reconcile the live page against the session's DURABLE current
653
751
  # target before running this call (a separate process may have moved
654
- # the ledger binding without this executor noticing).
655
- self._reconcile_page_binding()
752
+ # the ledger binding without this executor noticing) — and, issue #86,
753
+ # rebind if the tab the live page points at is gone.
754
+ try:
755
+ self._reconcile_page_binding()
756
+ except TabRebindFailed as e:
757
+ # The tab is gone AND opening a replacement failed. This is the
758
+ # bound on issue #86's recovery: escalate to the pre-existing
759
+ # cold-restart (terminal recycle) instead of attempting another
760
+ # rebind, and answer with the DISTINCT error so the caller can tell
761
+ # "the tab died" from "the browser cannot give me a tab at all".
762
+ return self._finish(
763
+ io.StringIO(), error=serialize(e), exit_code=e.exit_code,
764
+ terminal_reason=TERMINAL_TARGET_CLOSED)
656
765
  self._active_globals = globals_ = self._build_globals()
657
766
  buf = io.StringIO()
658
767
  return_value: str | None = None
@@ -689,6 +798,16 @@ class _Worker:
689
798
  terminal_reason=TERMINAL_RESET_REQUESTED,
690
799
  )
691
800
  except BrowserwrightError as e:
801
+ # Issue #86: `page.goto` failures arrive here, not in the
802
+ # raw-exception branch below — `repl._smart_goto` has
803
+ # already translated the underlying TargetClosedError into
804
+ # `PageLoadFailed(reason="target-closed")`. That is why the
805
+ # target-closed recovery under this branch's sibling was
806
+ # unreachable for the most common way a session meets a
807
+ # dead tab, and why a dead tab stayed dead forever.
808
+ if _is_target_closed_family(e):
809
+ return self._target_closed_response(
810
+ buf, serialize(e), e.exit_code)
692
811
  return self._finish(
693
812
  buf, error=serialize(e), exit_code=e.exit_code)
694
813
  except SystemExit as e:
@@ -708,35 +827,9 @@ class _Worker:
708
827
  fix = playwright_error_fix(e)
709
828
  if fix:
710
829
  error["fix"] = fix
711
- terminal_reason = None
712
830
  if _is_target_closed_family(e):
713
- # B (target-closed self-heal): the session's tab
714
- # binding died under us (extension SW reload/update,
715
- # daemon restart, user closed the tab). The page is a
716
- # zombie and the facade connection still holds the
717
- # daemon's single-attacher slot, which deadlocks every
718
- # recovery path until this executor is reaped. So flush
719
- # this (hint-carrying) response and exit: the next
720
- # command cold-starts, re-attaches the session's tab
721
- # group (title-keyed recoverSession) and releases the
722
- # slot. The hint must NOT teach `session new` -- the
723
- # session itself is fine; only the executor is not.
724
- sid = self._session_id or "<id>"
725
- error["fix"] = (
726
- "the session's tab binding is gone (extension "
727
- "reloaded/updated, daemon restarted, or the tab was "
728
- "closed). The executor recycled itself; the NEXT "
729
- "command re-attaches the session automatically. If "
730
- "the tab is gone for good, run `browserwright "
731
- f"session reset -s {sid}` first, or `browserwright "
732
- f"session attach-active -s {sid}` to adopt the tab "
733
- "you are looking at, then retry."
734
- )
735
- terminal_reason = TERMINAL_TARGET_CLOSED
736
- return self._finish(
737
- buf, error=error, exit_code=3,
738
- terminal_reason=terminal_reason,
739
- )
831
+ return self._target_closed_response(buf, error, 3)
832
+ return self._finish(buf, error=error, exit_code=3)
740
833
  finally:
741
834
  self._active_globals = None
742
835
  return self._finish(
@@ -746,6 +839,57 @@ class _Worker:
746
839
  exit_code=0,
747
840
  )
748
841
 
842
+ def _target_closed_response(
843
+ self, buf: io.StringIO, error: dict[str, Any], exit_code: int,
844
+ ) -> ExecuteResponse:
845
+ """Answer a call that died because the session's tab is gone.
846
+
847
+ Issue #86 changed what "answer" means here. The old behaviour was
848
+ terminal-only: flush the error and exit the executor so the NEXT
849
+ command cold-starts and re-attaches. That was correct but never
850
+ reached for `page.goto` (see the BrowserwrightError branch), and even
851
+ where it was reached it threw away the executor's `state`.
852
+
853
+ Now: try ONE in-place rebind first. It goes through
854
+ ``resolve_current_target``, so the replacement tab lands in this
855
+ session's tab group and the ledger stays in step. If it works, the
856
+ session is usable again on the very next statement and the error says
857
+ so — retry, do NOT create a new session. If it does not, fall back to
858
+ exactly the old terminal recycle. One attempt, two clearly different
859
+ outcomes, no loop.
860
+ """
861
+ sid = self._session_id or "<id>"
862
+ failure: str | None = None
863
+ try:
864
+ if not self._rebind_dead_page():
865
+ failure = (
866
+ "the tab died again after this call already rebound once")
867
+ except BaseException as rebind_exc: # noqa: BLE001 - fall back, never raise
868
+ failure = str(rebind_exc)
869
+ if failure is not None:
870
+ error["fix"] = (
871
+ "the session's tab binding is gone (extension "
872
+ "reloaded/updated, daemon restarted, or the tab was closed) "
873
+ f"and re-binding a fresh tab failed ({failure}). The "
874
+ "executor recycled itself; the NEXT command re-attaches the "
875
+ "session automatically. If the tab is gone for good, run "
876
+ f"`browserwright session reset -s {sid}` first, or "
877
+ f"`browserwright session attach-active -s {sid}` to adopt the "
878
+ "tab you are looking at, then retry."
879
+ )
880
+ return self._finish(
881
+ buf, error=error, exit_code=exit_code,
882
+ terminal_reason=TERMINAL_TARGET_CLOSED,
883
+ )
884
+ error["fix"] = (
885
+ "the tab this session was bound to is gone (closed by the page, "
886
+ "by the user, or by a renderer crash). A fresh tab has been opened "
887
+ "in THIS session's tab group and `page` is bound to it — RETRY the "
888
+ "call. The session does not need to be recreated; if this keeps "
889
+ "happening on the same URL, that page is closing its own tab."
890
+ )
891
+ return self._finish(buf, error=error, exit_code=exit_code)
892
+
749
893
  @staticmethod
750
894
  def _exec_with_return(code: str, globals_: dict[str, Any]) -> str | None:
751
895
  """Exec ``code``; if the LAST statement is a bare expression, return its
@@ -33,9 +33,22 @@ class PageLoadFailed(BrowserwrightError):
33
33
  "check the URL and network with http_get(url)"
34
34
  )
35
35
 
36
- def __init__(self, url: str = "", reason: str = "", fix: str = ""):
37
- self.url, self.reason = url, reason
38
- super().__init__(f"page load failed: {url} ({reason})", fix=fix)
36
+ def __init__(
37
+ self,
38
+ url: str = "",
39
+ reason: str = "",
40
+ fix: str = "",
41
+ detail: str = "",
42
+ ):
43
+ # `detail` carries the ORIGINAL exception type + message. Without it a
44
+ # caller only ever sees our own coarse `reason` bucket, which is how a
45
+ # whole batch of CDP/relay failures once got reported as "network" with
46
+ # no way left to tell what actually broke. Never drop it.
47
+ self.url, self.reason, self.detail = url, reason, detail
48
+ message = f"page load failed: {url} ({reason})"
49
+ if detail:
50
+ message = f"{message}: {detail}"
51
+ super().__init__(message, fix=fix)
39
52
 
40
53
 
41
54
  class PageBindTimeout(BrowserwrightError):
@@ -73,6 +86,39 @@ class PageBindTimeout(BrowserwrightError):
73
86
  )
74
87
 
75
88
 
89
+ class TabRebindFailed(BrowserwrightError):
90
+ """The session's bound tab died AND re-binding a replacement failed.
91
+
92
+ Issue #86: a session whose tab dies used to answer every later call in
93
+ 1-4ms with ``TargetClosedError``, forever, because the bind was performed
94
+ once and never revisited. The bind is now re-entered when the bound page
95
+ is dead — but that recovery has to be *bounded*: exactly one attempt, and
96
+ a failure that is TELLABLE APART from the dead-tab condition it was trying
97
+ to repair. Without a distinct error a genuinely unusable browser would
98
+ look like a tab that is merely closed, and the caller would keep asking
99
+ for a rebind that cannot ever succeed.
100
+
101
+ So: ``TargetClosedError`` (or ``PageLoadFailed(reason="target-closed")``)
102
+ means "the tab is gone, recovery is being attempted"; THIS error means
103
+ "the tab is gone and recovery itself failed" — a different next action.
104
+ """
105
+
106
+ exit_code = 3
107
+ default_fix = (
108
+ "the session's tab is gone and re-opening one failed; the browser or "
109
+ "the extension relay is likely unusable. Check `browserwright doctor` "
110
+ "and the daemon log, then `browserwright session reset <id>` (or start "
111
+ "a new session) — retrying the same call will NOT help"
112
+ )
113
+
114
+ def __init__(self, reason: str = "", fix: str = ""):
115
+ self.reason = reason
116
+ message = "the session's tab is gone and re-binding a new one failed"
117
+ if reason:
118
+ message = f"{message}: {reason}"
119
+ super().__init__(message, fix=fix)
120
+
121
+
76
122
  class ElementNotFound(BrowserwrightError):
77
123
  exit_code = 3
78
124
  default_fix = (
@@ -162,29 +162,136 @@ def _bounded_timeout(total_ms: int, cap_ms: int) -> int:
162
162
  return max(1, min(int(total_ms), cap_ms))
163
163
 
164
164
 
165
- def _page_load_failed(url: str, phase: str, exc: BaseException) -> PageLoadFailed:
166
- msg = str(exc)
167
- exc_type = type(exc).__name__
168
- lower = msg.lower()
169
- if "timeout" in lower or exc_type == "TimeoutError":
170
- return PageLoadFailed(
171
- url,
172
- phase,
173
- fix="site did not respond at commit; verify it with http_get(url) or retry",
174
- )
175
- if "net::" in msg or "ssl" in lower or "name_not_resolved" in lower:
176
- return PageLoadFailed(
177
- url,
178
- "network",
179
- fix="check the URL and network; use http_get(url) to verify the site is reachable",
165
+ # Navigation failure buckets. These are the `reason` field of PageLoadFailed —
166
+ # an agent reads it to decide what to do next, so every bucket must point at a
167
+ # DIFFERENT next action. Two buckets that share a fix are a bug: this table
168
+ # used to collapse everything non-timeout into "network", which told users to
169
+ # check their connection while the real failure was in the CDP/relay layer.
170
+ #
171
+ # The concrete accident this replaces: the extension caps every
172
+ # `chrome.debugger.sendCommand` at 9000ms (`DEBUGGER_COMMAND_TIMEOUT_MS` in
173
+ # chrome-extension/background.js) and reports the breach as "... timed out
174
+ # after 9000ms ...". That string contains "timed out" but NOT "timeout", so it
175
+ # missed the timeout check and fell into the second, identical branch —
176
+ # `network`. A whole crawl's worth of "the extension's navigate budget expired
177
+ # on a slow page" was reported to the operator as "check your network".
178
+ _REASON_TIMEOUT = "timeout"
179
+ _REASON_NETWORK = "network"
180
+ _REASON_EXT_BUDGET = "extension-budget"
181
+ _REASON_NAV_INTERRUPTED = "navigation-interrupted"
182
+ _REASON_TARGET_CLOSED = "target-closed"
183
+ _REASON_FRAME_DETACHED = "frame-detached"
184
+ _REASON_CDP = "cdp-transport"
185
+ _REASON_UNKNOWN = "unknown"
186
+
187
+ _DETAIL_MAX = 300
188
+
189
+ # Ordered most-specific-first; the first entry whose needle appears in the
190
+ # lowercased message wins. Transport buckets deliberately sit ABOVE the generic
191
+ # timeout bucket: a relay/extension budget that expires is NOT the site failing
192
+ # to respond, and saying so sends the operator to the wrong layer.
193
+ _CLASSIFIERS: tuple[tuple[str, str, tuple[str, ...]], ...] = (
194
+ (
195
+ _REASON_EXT_BUDGET,
196
+ "NOT a network problem: the browserwright extension caps every "
197
+ "chrome.debugger command at 9s (DEBUGGER_COMMAND_TIMEOUT_MS in "
198
+ "chrome-extension/background.js — a constant, not an env var), and "
199
+ "this navigation took longer to commit. The navigation may still be "
200
+ "completing in Chrome. Heavy SPAs routinely exceed it; navigate from a "
201
+ "fresh tab (context.new_page()) instead of reusing one parked on a "
202
+ "heavy page, and retry",
203
+ ("chrome.debugger.sendcommand timed out", "-32001",
204
+ "chrome.debugger.attach timed out", "chrome.debugger.detach timed out"),
205
+ ),
206
+ (
207
+ # net::ERR_ABORTED is NOT a network condition: Chrome emits it when a
208
+ # navigation is cancelled — superseded by another goto, turned into a
209
+ # download, or killed by the page itself. Bucketing it as "network"
210
+ # sends the user to check their connection for a race they own.
211
+ _REASON_NAV_INTERRUPTED,
212
+ "navigation was cancelled or superseded (download, redirect, or a "
213
+ "competing goto on the same page); ensure only one navigation runs per "
214
+ "page at a time, then retry page.goto(url)",
215
+ ("net::err_aborted", "interrupted by another navigation",
216
+ "navigation was cancel"),
217
+ ),
218
+ (
219
+ _REASON_NETWORK,
220
+ "check the URL and network; use http_get(url) to verify the site is "
221
+ "reachable",
222
+ ("net::", "ssl", "name_not_resolved", "err_internet_disconnected"),
223
+ ),
224
+ (
225
+ _REASON_TARGET_CLOSED,
226
+ "the tab/context backing this session went away mid-navigation; "
227
+ "re-acquire the page (page() / `session reset <id>`) before retrying, "
228
+ "and check whether the tab was closed by hand or by another session",
229
+ ("target closed", "target page, context or browser has been closed",
230
+ "browser has been closed", "page has been closed", "page was closed",
231
+ "session closed", "has been closed"),
232
+ ),
233
+ (
234
+ _REASON_FRAME_DETACHED,
235
+ "the frame was detached mid-navigation (usually a same-page rewrite or "
236
+ "an iframe teardown); retry page.goto(url) on a freshly acquired frame",
237
+ ("frame was detached", "frame has been detached", "detached frame",
238
+ "execution context was destroyed"),
239
+ ),
240
+ (
241
+ _REASON_CDP,
242
+ "the CDP path between the daemon, the extension relay and Chrome "
243
+ "failed — a transport fault, NOT the site. Check `browserwright "
244
+ "doctor` and the daemon log; if it repeats, recycle the session",
245
+ ("relay send failed", "extension relay", "extension reconnected",
246
+ "protocol error", "websocket", "ws closed", "no close frame",
247
+ "connection closed", "chrome.debugger", "debugger is not attached"),
248
+ ),
249
+ )
250
+
251
+
252
+ def _detail_for(exc: BaseException) -> str:
253
+ """Original exception type + first message line, bounded.
254
+
255
+ Playwright appends a multi-line call log to most errors; the first line is
256
+ the part that identifies the failure. Everything below it is noise, but the
257
+ type name never is — a bare message loses the difference between a
258
+ TimeoutError and a transport error that happens to mention a timeout.
259
+ """
260
+ lines = str(exc).strip().splitlines()
261
+ head = lines[0].strip() if lines else ""
262
+ if len(head) > _DETAIL_MAX:
263
+ head = head[: _DETAIL_MAX - 1] + "\u2026"
264
+ return f"{type(exc).__name__}: {head}" if head else type(exc).__name__
265
+
266
+
267
+ def _classify(exc: BaseException) -> tuple[str, str]:
268
+ """Map a navigation exception to (reason, fix)."""
269
+ lower = str(exc).lower()
270
+ for reason, fix, needles in _CLASSIFIERS:
271
+ if any(needle in lower for needle in needles):
272
+ return reason, fix
273
+ if "timeout" in lower or "timed out" in lower or type(exc).__name__ == "TimeoutError":
274
+ return (
275
+ _REASON_TIMEOUT,
276
+ "site did not respond at commit; verify it with http_get(url) or retry",
180
277
  )
181
- return PageLoadFailed(
182
- url,
183
- "network",
184
- fix="check the URL and network; use http_get(url) to verify the site is reachable",
278
+ return (
279
+ _REASON_UNKNOWN,
280
+ "unrecognised navigation failure — do NOT assume it is the network; "
281
+ "read the exception detail in this message, then retry page.goto(url) "
282
+ "once and check the daemon log if it repeats",
185
283
  )
186
284
 
187
285
 
286
+ def _page_load_failed(url: str, phase: str, exc: BaseException) -> PageLoadFailed:
287
+ reason, fix = _classify(exc)
288
+ # `phase` ("commit") is only meaningful for the timeout bucket, where it
289
+ # says how far the navigation got. Every other bucket names its own cause.
290
+ if reason == _REASON_TIMEOUT:
291
+ reason = phase or _REASON_TIMEOUT
292
+ return PageLoadFailed(url, reason, fix=fix, detail=_detail_for(exc))
293
+
294
+
188
295
  class _NetworkMonitor:
189
296
  def __init__(self, page: Any) -> None:
190
297
  self.page = page
@@ -37,7 +37,7 @@ from typing import Any
37
37
  from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
38
38
  from uuid import uuid4
39
39
 
40
- from ..errors import BrowserwrightError, PageBindTimeout
40
+ from ..errors import BrowserwrightError, PageBindTimeout, TabRebindFailed
41
41
 
42
42
  logger = logging.getLogger(__name__)
43
43
 
@@ -320,6 +320,119 @@ def bind_current_page(context: Any, sess: Any) -> Any:
320
320
  )
321
321
 
322
322
 
323
+ def page_is_dead(page: Any) -> bool:
324
+ """Is this bound ``Page`` a handle to a tab that no longer exists?
325
+
326
+ Issue #86. The bind used to be a one-shot: once a handle had a ``page``, it
327
+ never asked again, so a tab that died mid-session left every later call
328
+ failing in 1-4ms with ``TargetClosedError`` — forever, for the life of that
329
+ session. Detecting the dead handle is what lets the (already correct)
330
+ ``resolve_current_target`` recovery path be re-entered.
331
+
332
+ The probe is ``page.is_closed()`` — the flag Playwright sets when it
333
+ processes the target's destruction. It is LOCAL (no CDP round-trip), so it
334
+ is cheap enough to run before every call, and it is authoritative: it does
335
+ not fire for a page that is merely slow, busy, or in another context.
336
+
337
+ It is deliberately the ONLY probe. Inspecting ``context.pages`` instead
338
+ (empty, or missing this page) reads on the same condition — the field repro
339
+ left ``context.pages == []`` behind — but both spellings have false
340
+ positives a bare flag does not: a page can legitimately belong to a second
341
+ context, and a context can legitimately be observed before its pages
342
+ materialize. A false "dead" is expensive (it opens a replacement tab in the
343
+ user's browser and abandons a working one); a false "alive" costs nothing,
344
+ because the call then fails with the real ``TargetClosedError`` and the
345
+ reactive path rebinds from there. So anything unrecognised — a unit-test
346
+ double without the method, a probe that raises differently — is reported
347
+ ALIVE, and the reactive path is the backstop.
348
+ """
349
+ if page is None:
350
+ return True
351
+ is_closed = getattr(page, "is_closed", None)
352
+ if not callable(is_closed):
353
+ return False
354
+ try:
355
+ return bool(is_closed())
356
+ except Exception: # noqa: BLE001 - a probe that raises means gone
357
+ return True
358
+
359
+
360
+ def drain_page_events(context: Any, *, timeout: float = 0.001) -> None:
361
+ """Let Playwright process target events that queued while we were idle.
362
+
363
+ Issue #86's second half. ``page.is_closed()`` is fed by a channel event,
364
+ and the sync API only dispatches channel events while a sync call is in
365
+ flight — between two ``browserwright -s <id> -e ...`` invocations the
366
+ resident executor is parked in ``queue.get`` and nothing pumps. So a tab
367
+ that died while the session was idle leaves ``is_closed()`` reading False,
368
+ the pre-call probe sees a healthy page, and the caller eats one
369
+ ``TargetClosedError`` before the reactive path can rebind.
370
+
371
+ Draining first turns that lost call into a clean one. The budget is
372
+ deliberately ~1ms: this runs before EVERY executed call, and the queued
373
+ events are already sitting in the driver — the wait is only there to yield
374
+ to the dispatcher, not to wait for anything to arrive. Best-effort by
375
+ construction (``_pump_page_events`` swallows the timeout it is guaranteed
376
+ to hit on a quiet session).
377
+ """
378
+ _pump_page_events(context, timeout=timeout)
379
+
380
+
381
+ def rebind_dead_page(context: Any, sess: Any) -> Any:
382
+ """Re-enter the bind discipline for a session whose tab died. ONE attempt.
383
+
384
+ Goes through :func:`bind_current_page` — i.e. ``resolve_current_target`` —
385
+ and NOT ``context.new_page()``, for exactly the reason the first bind does:
386
+ ``new_page`` opens a tab outside the session's tab group, which the agent
387
+ path cannot track (ledger drift → tab explosion). See
388
+ :func:`bind_current_page`'s docstring.
389
+
390
+ Raises :class:`~browserwright.errors.TabRebindFailed` when the rebind
391
+ itself fails, or when it hands back a page that is *also* already dead.
392
+ That second check is the loop bound: a browser that cannot hold a tab open
393
+ reports a distinct, terminal error instead of inviting another rebind.
394
+
395
+ The dead binding is DROPPED first — in memory and in the ledger — because
396
+ ``resolve_current_target`` would otherwise hand it straight back. Its step 2
397
+ (``ensure_session_target``'s ledger fast path) trusts ``cdp.attach(tid)`` to
398
+ fail for a closed tab; over the extension backend it does not, so recovery
399
+ returned the very target we are here to replace and the bind then timed out
400
+ against a tab that no longer exists. Measured, not reasoned: the e2e repro
401
+ resolved the same ``ext-tab-<id>`` that had just been removed. We know this
402
+ target is gone — that is the precondition of this function — so forgetting
403
+ it is not a guess. Everything below step 2 then does the right thing:
404
+ another live tab of the session if there is one, else a fresh tab opened in
405
+ the session's own group.
406
+ """
407
+ _forget_dead_binding(sess)
408
+ try:
409
+ page = bind_current_page(context, sess)
410
+ except Exception as e: # noqa: BLE001 - re-raised as the distinct error
411
+ raise TabRebindFailed(f"{type(e).__name__}: {e}") from e
412
+ if page_is_dead(page):
413
+ raise TabRebindFailed(
414
+ "the replacement tab was already gone when it was bound")
415
+ return page
416
+
417
+
418
+ def _forget_dead_binding(sess: Any) -> None:
419
+ """Drop the session's current-tab binding, in memory and in the ledger.
420
+
421
+ Best-effort by design: this runs on a recovery path, and a ledger write
422
+ that fails must not replace the rebind's own outcome with a bookkeeping
423
+ error. The worst case of a failed clear is the pre-existing behaviour —
424
+ recovery re-proposes the dead target and the bind times out.
425
+ """
426
+ try:
427
+ from ..session_runtime import persist_target
428
+
429
+ sess.current_target_id = None
430
+ persist_target(None, sess=sess)
431
+ except Exception: # noqa: BLE001 - see docstring
432
+ logger.warning("could not clear the dead tab binding before rebinding",
433
+ exc_info=True)
434
+
435
+
323
436
  def _discard_unbindable_tab(sess: Any, target_id: str) -> None:
324
437
  """Close a tab we opened but could not bind, and clear its ledger binding.
325
438
 
@@ -568,11 +681,15 @@ class PlaywrightHandle:
568
681
  self._context: Any = None # bound BrowserContext
569
682
  self._page: Any = None # bound Page
570
683
  self._connected = False
684
+ # Issue #86 re-entrancy guard: `rebind_dead_page` resolves the tab
685
+ # through the agent path, which can touch `page`/`context` again.
686
+ self._rebinding = False
571
687
 
572
688
  # ---- lazy connect + bind --------------------------------------------
573
689
 
574
690
  def _ensure_connected(self) -> None:
575
691
  if self._connected:
692
+ self._rebind_if_dead()
576
693
  return
577
694
  try:
578
695
  from playwright.sync_api import sync_playwright
@@ -605,6 +722,32 @@ class PlaywrightHandle:
605
722
  self._page = bind_current_page(self._context, sess)
606
723
  self._connected = True
607
724
 
725
+ def _rebind_if_dead(self) -> None:
726
+ """Issue #86: revisit the bind when the bound tab has died.
727
+
728
+ ``_connected`` used to be set once and never cleared, so ``_page`` was
729
+ never re-resolved: a tab that went away left this handle answering
730
+ every call with ``TargetClosedError`` in 1-4ms for the rest of its
731
+ life. The recovery already existed one layer down
732
+ (``resolve_current_target`` reuses / recovers via the tab group / opens
733
+ a fresh tab in THIS session's group) — it was simply never re-entered.
734
+
735
+ Bounded by construction rather than by a counter: a rebind only runs
736
+ while the current page reads as dead, and `rebind_dead_page` refuses to
737
+ hand back a page that is dead on arrival. So a successful rebind ends
738
+ the condition, and an unsuccessful one raises ``TabRebindFailed``
739
+ instead of arming another attempt.
740
+ """
741
+ if self._rebinding or not page_is_dead(self._page):
742
+ return
743
+ from ..session import current_session
744
+
745
+ self._rebinding = True
746
+ try:
747
+ self._page = rebind_dead_page(self._context, current_session())
748
+ finally:
749
+ self._rebinding = False
750
+
608
751
  # ---- accessors (trigger the lazy connect) ---------------------------
609
752
 
610
753
  @property
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: browserwright
3
- Version: 0.17.2
3
+ Version: 0.17.4
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
  License-Expression: AGPL-3.0-only
6
6
  Requires-Python: >=3.11
File without changes
File without changes
File without changes