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.
- {browserwright-0.17.2/src/browserwright.egg-info → browserwright-0.17.4}/PKG-INFO +1 -1
- {browserwright-0.17.2 → browserwright-0.17.4}/pyproject.toml +2 -1
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/_executor/process.py +184 -40
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/errors.py +49 -3
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/repl/_smart_goto.py +126 -19
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/repl/playwright_handle.py +144 -1
- {browserwright-0.17.2 → browserwright-0.17.4/src/browserwright.egg-info}/PKG-INFO +1 -1
- {browserwright-0.17.2 → browserwright-0.17.4}/LICENSE +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/README.md +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/setup.cfg +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/__init__.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/__main__.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/_executor/__init__.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/_executor/__main__.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/_executor/client.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/_executor/protocol.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/_text.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/cdp.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/cli.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/__init__.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/_ipc.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/_net.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/_rpc.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/_stale.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/backends/__init__.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/backends/base.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/backends/cdp.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/backends/extension.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/cli.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/config.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/doctor.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/errors.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/launch_chrome.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/launchagent.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/observability.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/platforms.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/probe.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/relay_status.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/resolver.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/restart_guard.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/server/__init__.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/server/daemon.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/server/exec_relay.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/server/executor_registry.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/server/extension_upstream.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/server/facade.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/server/facade_extension.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/server/listener.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/server/proxy.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/server/relay.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/server/state.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/server/status.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/server/upstream.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/server/verbs.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/supervise.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/userscripts.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon_url.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/discovery.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/health.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/install.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/memory/__init__.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/memory/_lock.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/memory/_md.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/memory/_yaml.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/memory/global_mem.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/memory/site_mem.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/mode_b_client.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/output_schema.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/primitives/__init__.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/primitives/discovery_api.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/primitives/http.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/primitives/site.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/repl/__init__.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/repl/_md_convert.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/repl/_md_normalize.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/repl/_namespace.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/repl/inline.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/repl/markdown.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/repl/snapshot.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/repl/vendor/README.md +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/repl/vendor/readability.js +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/session.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/session_create.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/session_ctx.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/session_registry.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/session_runtime.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/site_skills_starter/github.com/SKILL.md +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/site_skills_starter/github.com/memory.md +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/site_skills_starter/github.com/tasks/list_issues.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/site_skills_starter/google.com/SKILL.md +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/site_skills_starter/google.com/memory.md +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/site_skills_starter/google.com/tasks/search.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/site_skills_starter/producthunt.com/SKILL.md +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/site_skills_starter/producthunt.com/memory.md +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/site_skills_starter/producthunt.com/tasks/today.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/site_skills_starter/wikipedia.org/SKILL.md +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/site_skills_starter/wikipedia.org/memory.md +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/site_skills_starter/wikipedia.org/tasks/lookup.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/site_skills_starter/ycombinator.com/SKILL.md +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/site_skills_starter/ycombinator.com/memory.md +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/site_skills_starter/ycombinator.com/tasks/front_page.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/skill_doc.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/skill_runtime.md +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/tab_surface.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/task_runner.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/version.py +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright.egg-info/SOURCES.txt +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright.egg-info/dependency_links.txt +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright.egg-info/entry_points.txt +0 -0
- {browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright.egg-info/requires.txt +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
525
|
-
page = ph.
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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
|
-
|
|
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
|
-
|
|
714
|
-
|
|
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__(
|
|
37
|
-
self
|
|
38
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
182
|
-
|
|
183
|
-
"network"
|
|
184
|
-
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/backends/extension.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/server/executor_registry.py
RENAMED
|
File without changes
|
{browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/server/extension_upstream.py
RENAMED
|
File without changes
|
|
File without changes
|
{browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright/daemon/server/facade_extension.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{browserwright-0.17.2 → browserwright-0.17.4}/src/browserwright.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|