browserwright 0.6.8__tar.gz → 0.6.11__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.6.8 → browserwright-0.6.11}/PKG-INFO +1 -1
- {browserwright-0.6.8 → browserwright-0.6.11}/README.md +36 -54
- {browserwright-0.6.8 → browserwright-0.6.11}/pyproject.toml +1 -1
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/_executor/client.py +7 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/config.py +13 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/server/extension_upstream.py +7 -1
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/server/facade_extension.py +2 -1
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/server/listener.py +91 -6
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/server/proxy.py +3 -1
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/server/relay.py +191 -5
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/primitives/page.py +11 -3
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/session_create.py +9 -2
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/session_registry.py +14 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright.egg-info/PKG-INFO +1 -1
- {browserwright-0.6.8 → browserwright-0.6.11}/setup.cfg +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/__init__.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/__main__.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/_executor/__init__.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/_executor/__main__.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/_executor/process.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/_executor/protocol.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/api.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/cdp.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/cli.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/__init__.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/_ipc.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/active_tab.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/auth.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/backends/__init__.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/backends/base.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/backends/cloud.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/backends/env.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/backends/extension.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/backends/rdp.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/cli.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/doctor.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/errors.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/launch_chrome.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/observability.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/platforms.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/resolver.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/server/__init__.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/server/daemon.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/server/executor_registry.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/server/facade.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/server/state.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/server/upstream.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/userscripts.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/discovery.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/errors.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/health.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/install.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/memory/__init__.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/memory/_md.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/memory/_yaml.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/memory/global_mem.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/memory/repl_mem.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/memory/session_decisions.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/memory/site_mem.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/mode_b_client.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/multitask.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/output_schema.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/primitives/__init__.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/primitives/discovery_api.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/primitives/http.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/primitives/inspect.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/primitives/interact.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/primitives/site.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/release_install.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/repl/__init__.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/repl/_namespace.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/repl/_smart_goto.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/repl/inline.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/repl/playwright_handle.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/repl/snapshot.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/session.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/session_ctx.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/session_runtime.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/site_skills_starter/github.com/SKILL.md +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/site_skills_starter/github.com/memory.md +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/site_skills_starter/github.com/tasks/list_issues.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/site_skills_starter/google.com/SKILL.md +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/site_skills_starter/google.com/memory.md +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/site_skills_starter/google.com/tasks/search.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/site_skills_starter/producthunt.com/SKILL.md +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/site_skills_starter/producthunt.com/memory.md +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/site_skills_starter/producthunt.com/tasks/today.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/site_skills_starter/wikipedia.org/SKILL.md +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/site_skills_starter/wikipedia.org/memory.md +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/site_skills_starter/wikipedia.org/tasks/lookup.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/site_skills_starter/ycombinator.com/SKILL.md +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/site_skills_starter/ycombinator.com/memory.md +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/site_skills_starter/ycombinator.com/tasks/front_page.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/skill_doc.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/skill_runtime.md +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/subscriptions.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/task_runner.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/version.py +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright.egg-info/SOURCES.txt +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright.egg-info/dependency_links.txt +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright.egg-info/entry_points.txt +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright.egg-info/requires.txt +0 -0
- {browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: browserwright
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.11
|
|
4
4
|
Summary: Browserwright — let AI/code agents drive a real or isolated browser and author userscripts. Single package: the agent-facing REPL/site-skills/memory layer plus the bundled browser-resolving daemon (CDP proxy + extension/cloud backends).
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Requires-Dist: cdp-use==1.4.5
|
|
@@ -61,13 +61,12 @@ from your own systemd-user unit or process supervisor.
|
|
|
61
61
|
|
|
62
62
|
The browser extension is intentionally not part of the PyPI packaging contract.
|
|
63
63
|
The long-term user path is to publish/install it through the Chrome Web Store.
|
|
64
|
-
Until then, local development
|
|
65
|
-
|
|
64
|
+
Until then, local development can use the repo's `chrome-extension/` directory,
|
|
65
|
+
and global installs should use the GitHub Release extension artifact described
|
|
66
66
|
below.
|
|
67
67
|
|
|
68
|
-
The agent skill bundle remains a thin shell around the installed CLI. For
|
|
69
|
-
|
|
70
|
-
Code, Codex, and Pi. For PyPI installs, the intended stable contract is:
|
|
68
|
+
The agent skill bundle remains a thin shell around the installed CLI. For PyPI
|
|
69
|
+
installs, the intended stable contract is:
|
|
71
70
|
|
|
72
71
|
```bash
|
|
73
72
|
browserwright --print-skill
|
|
@@ -115,60 +114,39 @@ git push origin v0.6.3
|
|
|
115
114
|
The GitHub Action only runs for `v*` tags. A tag like `v0.6.3` publishes package
|
|
116
115
|
version `0.6.3`.
|
|
117
116
|
|
|
118
|
-
|
|
117
|
+
Each `v*` GitHub Release also includes a
|
|
118
|
+
`browserwright-extension-<version>.zip` asset. Use that zip as the official
|
|
119
|
+
extension download path when installing outside PyPI or before the Chrome Web
|
|
120
|
+
Store path is available.
|
|
119
121
|
|
|
120
|
-
|
|
122
|
+
### Local development link
|
|
121
123
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
This builds a wheel, installs it into:
|
|
127
|
-
|
|
128
|
-
```bash
|
|
129
|
-
~/.local/share/browserwright/releases/<version>/
|
|
130
|
-
```
|
|
131
|
-
|
|
132
|
-
and points global entry points at that release copy:
|
|
124
|
+
For development only, `mise run dev-link` links the current checkout into
|
|
125
|
+
global PATH and skill dirs. That is convenient for debugging but can break
|
|
126
|
+
global agents if the checkout is broken.
|
|
133
127
|
|
|
134
128
|
```bash
|
|
135
|
-
|
|
136
|
-
~/.local/bin/browserwright-daemon -> releases/<version>/.venv/bin/browserwright-daemon
|
|
137
|
-
~/.claude/skills/browserwright -> releases/<version>/skill
|
|
138
|
-
~/.codex/skills/browserwright -> releases/<version>/skill
|
|
139
|
-
~/.pi/agent/skills/browserwright -> releases/<version>/skill
|
|
129
|
+
mise run dev-link
|
|
140
130
|
```
|
|
141
131
|
|
|
142
|
-
The
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
```bash
|
|
147
|
-
browserwright release status
|
|
148
|
-
browserwright release list
|
|
149
|
-
browserwright release activate <version>
|
|
150
|
-
mise run version-check
|
|
151
|
-
```
|
|
152
|
-
|
|
153
|
-
The skill bundle is intentionally a thin shell. The authoritative agent instructions come from `browserwright --print-skill`, so installed agents read docs generated by the installed CLI in the active release.
|
|
154
|
-
|
|
155
|
-
For development only, `mise run dev-link` links the current checkout into global PATH and skill dirs. That is convenient for debugging but can break global agents if the checkout is broken.
|
|
132
|
+
The skill bundle is intentionally a thin shell. The authoritative agent
|
|
133
|
+
instructions come from `browserwright --print-skill`, so installed agents read
|
|
134
|
+
docs generated by the installed CLI.
|
|
156
135
|
|
|
157
136
|
## Update the local global install
|
|
158
137
|
|
|
159
|
-
Use this flow when you want the global Code Agent install on this machine to
|
|
138
|
+
Use this flow when you want the global Code Agent install on this machine to
|
|
139
|
+
pick up the latest published release:
|
|
160
140
|
|
|
161
141
|
```bash
|
|
162
|
-
# 1.
|
|
163
|
-
|
|
142
|
+
# 1. Install/upgrade browserwright from PyPI.
|
|
143
|
+
# 2. Download the matching GitHub Release extension artifact.
|
|
144
|
+
# 3. Restart the daemon and verify versions.
|
|
145
|
+
mise run upgrade-global
|
|
164
146
|
|
|
165
|
-
#
|
|
166
|
-
browserwright release status
|
|
147
|
+
# 4. Optional explicit verification.
|
|
167
148
|
browserwright version check
|
|
168
149
|
browserwright-daemon version check
|
|
169
|
-
|
|
170
|
-
# 3. Restart the daemon if release status says the running daemon is stale.
|
|
171
|
-
browserwright-daemon restart
|
|
172
150
|
```
|
|
173
151
|
|
|
174
152
|
If the daemon is running manually instead of as the macOS LaunchAgent, restart it manually:
|
|
@@ -178,23 +156,25 @@ browserwright-daemon stop
|
|
|
178
156
|
browserwright-daemon serve
|
|
179
157
|
```
|
|
180
158
|
|
|
181
|
-
|
|
159
|
+
`upgrade-global` unpacks the GitHub Release asset into the stable local load
|
|
160
|
+
path:
|
|
182
161
|
|
|
183
162
|
```bash
|
|
184
163
|
/Users/metajs/Library/Mobile Documents/com~apple~CloudDocs/etc/chrome-extension/browserwright
|
|
185
164
|
```
|
|
186
165
|
|
|
187
|
-
If
|
|
166
|
+
If the task reports that the extension changed, open `chrome://extensions/` and
|
|
167
|
+
reload the `browserwright` unpacked extension from that stable path. The path
|
|
168
|
+
does not change across releases; the task overwrites its contents.
|
|
188
169
|
Reload any existing tab that already shows a duplicated `👀` attach marker so
|
|
189
170
|
the extension can normalize the title marker after the upgrade.
|
|
190
171
|
|
|
191
|
-
##
|
|
172
|
+
## Release discipline
|
|
192
173
|
|
|
193
|
-
For
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
checks all read or compare against the built package version.
|
|
174
|
+
For PyPI releases, the git tag is the source of truth and the publish workflow
|
|
175
|
+
writes that version into `pyproject.toml` before building. The release workflow
|
|
176
|
+
also writes the same tag version into `chrome-extension/manifest.json` before
|
|
177
|
+
uploading `browserwright-extension-<version>.zip` to the GitHub Release.
|
|
198
178
|
|
|
199
179
|
After installing a release:
|
|
200
180
|
|
|
@@ -204,7 +184,9 @@ browserwright-daemon version check
|
|
|
204
184
|
browserwright-daemon restart # when installed as the macOS LaunchAgent
|
|
205
185
|
```
|
|
206
186
|
|
|
207
|
-
|
|
187
|
+
Chrome does not allow this repo to refresh an unpacked extension automatically.
|
|
188
|
+
If `mise run upgrade-global` reports that the extension changed, reload it in
|
|
189
|
+
Chrome.
|
|
208
190
|
|
|
209
191
|
## Smoke test
|
|
210
192
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "browserwright"
|
|
7
|
-
version = "0.6.
|
|
7
|
+
version = "0.6.11"
|
|
8
8
|
description = "Browserwright — let AI/code agents drive a real or isolated browser and author userscripts. Single package: the agent-facing REPL/site-skills/memory layer plus the bundled browser-resolving daemon (CDP proxy + extension/cloud backends)."
|
|
9
9
|
requires-python = ">=3.11"
|
|
10
10
|
dependencies = [
|
|
@@ -17,6 +17,7 @@ import socket
|
|
|
17
17
|
import time
|
|
18
18
|
|
|
19
19
|
from ..errors import BrowserwrightError
|
|
20
|
+
from .. import session_registry as reg
|
|
20
21
|
from .protocol import (
|
|
21
22
|
DEFAULT_TIMEOUT_MS,
|
|
22
23
|
ExecuteRequest,
|
|
@@ -81,6 +82,12 @@ def run_on_executor(sess, code: str, *,
|
|
|
81
82
|
connect_over_cdp + bind (moved off the control plane), which can add up to
|
|
82
83
|
~35s. The executor itself bounds the worker per-call timeout; this slack
|
|
83
84
|
only prevents the CLIENT recv from giving up before the executor replies."""
|
|
85
|
+
sid = _session_id(sess)
|
|
86
|
+
# Session idle is "time since the last user/agent instruction", not
|
|
87
|
+
# executor process liveness. Touch before contacting the executor so a
|
|
88
|
+
# wedged or long-running executor cannot prevent the durable idle clock
|
|
89
|
+
# from reflecting that a new instruction arrived.
|
|
90
|
+
reg.touch(sid)
|
|
84
91
|
sock_path = ensure_executor(sess)
|
|
85
92
|
recv_timeout = max(timeout_ms, 1) / 1000.0 + _COLD_START_RECV_SLACK_S
|
|
86
93
|
conn = _connect(sock_path, timeout=recv_timeout)
|
|
@@ -131,6 +131,7 @@ class Config:
|
|
|
131
131
|
cdp_url: str | None = None # BD_CDP_URL / BU_CDP_URL — env backend uses this
|
|
132
132
|
chrome_binary: str | None = None # BD_CHROME_BINARY — launch-chrome
|
|
133
133
|
idle_close_after: float | None = None # seconds; None = never (default)
|
|
134
|
+
session_idle_prune: float | None = 24 * 3600 # ledger prune threshold
|
|
134
135
|
# Playwright-facing CDP facade. `serve` binds an additional TCP ws+HTTP
|
|
135
136
|
# endpoint that a real Playwright client can `connect_over_cdp` to.
|
|
136
137
|
#
|
|
@@ -246,6 +247,9 @@ def load(
|
|
|
246
247
|
cfg.backends.cloud.auth = dict(auth_subtables[cfg.backends.cloud.auth_kind])
|
|
247
248
|
if "idle_close_after" in toml and isinstance(toml["idle_close_after"], (int, float)):
|
|
248
249
|
cfg.idle_close_after = float(toml["idle_close_after"])
|
|
250
|
+
if "session_idle_prune" in toml and isinstance(toml["session_idle_prune"], (int, float)):
|
|
251
|
+
v = float(toml["session_idle_prune"])
|
|
252
|
+
cfg.session_idle_prune = v if v > 0 else None
|
|
249
253
|
# Playwright facade port (phase A1). toml key `facade_port`.
|
|
250
254
|
if "facade_port" in toml and isinstance(toml["facade_port"], int):
|
|
251
255
|
cfg.facade_port = toml["facade_port"]
|
|
@@ -268,6 +272,15 @@ def load(
|
|
|
268
272
|
from .errors import UserError
|
|
269
273
|
raise UserError(
|
|
270
274
|
f"BD_IDLE_CLOSE_AFTER must be a number, got {e['BD_IDLE_CLOSE_AFTER']!r}")
|
|
275
|
+
if "BD_SESSION_IDLE_PRUNE" in e:
|
|
276
|
+
try:
|
|
277
|
+
v = float(e["BD_SESSION_IDLE_PRUNE"])
|
|
278
|
+
cfg.session_idle_prune = v if v > 0 else None
|
|
279
|
+
except ValueError:
|
|
280
|
+
from .errors import UserError
|
|
281
|
+
raise UserError(
|
|
282
|
+
f"BD_SESSION_IDLE_PRUNE must be a number, got "
|
|
283
|
+
f"{e['BD_SESSION_IDLE_PRUNE']!r}")
|
|
271
284
|
if "BD_CDP_WS" in e:
|
|
272
285
|
cfg.cdp_ws = e["BD_CDP_WS"]; cfg.cdp_ws_source = "BD_CDP_WS"
|
|
273
286
|
elif "BU_CDP_WS" in e:
|
{browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/server/extension_upstream.py
RENAMED
|
@@ -500,6 +500,7 @@ class ExtensionUpstream:
|
|
|
500
500
|
group_name: str | None = "Agent",
|
|
501
501
|
session_id: str | None = None,
|
|
502
502
|
background: bool = True,
|
|
503
|
+
skip_post_attach_commands: bool = False,
|
|
503
504
|
) -> dict:
|
|
504
505
|
"""Open a background tab in the session's tab group via the relay,
|
|
505
506
|
fabricate a sessionId, and return
|
|
@@ -514,7 +515,12 @@ class ExtensionUpstream:
|
|
|
514
515
|
gid = self._relay.session_group(session_id)
|
|
515
516
|
self.reset_session_announce(session_id)
|
|
516
517
|
gt = await self._relay.create_background_tab(
|
|
517
|
-
url,
|
|
518
|
+
url,
|
|
519
|
+
group_name=group_name,
|
|
520
|
+
group_id=gid,
|
|
521
|
+
background=background,
|
|
522
|
+
skip_post_attach_commands=skip_post_attach_commands,
|
|
523
|
+
)
|
|
518
524
|
group_id = getattr(gt, "group_id", -1)
|
|
519
525
|
group_id = int(group_id) if isinstance(group_id, int) else -1
|
|
520
526
|
if self._group_required(
|
{browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright/daemon/server/facade_extension.py
RENAMED
|
@@ -511,7 +511,8 @@ class ExtensionFacadeBridge:
|
|
|
511
511
|
gt = await self._ext.open_background_tab(
|
|
512
512
|
url, group_name=group_name,
|
|
513
513
|
session_id=self._session_id,
|
|
514
|
-
background=True
|
|
514
|
+
background=True,
|
|
515
|
+
skip_post_attach_commands=True)
|
|
515
516
|
tab_id = int(gt["tabId"])
|
|
516
517
|
created_group = gt.get("groupId")
|
|
517
518
|
if isinstance(created_group, int) and created_group >= 0:
|
|
@@ -46,6 +46,8 @@ from .facade import PlaywrightFacade
|
|
|
46
46
|
|
|
47
47
|
logger = logging.getLogger(__name__)
|
|
48
48
|
|
|
49
|
+
_SESSION_PRUNE_INTERVAL_S = 3600.0
|
|
50
|
+
|
|
49
51
|
|
|
50
52
|
# ---- per-upstream context factory ------------------------------------------
|
|
51
53
|
|
|
@@ -224,8 +226,10 @@ async def run_serve(cfg: Config) -> int:
|
|
|
224
226
|
# (Fork 4 self-exit / segfault) so the registry never accumulates corpses.
|
|
225
227
|
# Upstream idle-close + executor idle-reap are gated on cfg.idle_close_after
|
|
226
228
|
# inside the loop.
|
|
229
|
+
await _auto_prune_sessions(daemon, reason="startup")
|
|
227
230
|
idle_task: asyncio.Task | None = asyncio.create_task(
|
|
228
|
-
_idle_watchdog(daemon, cfg.idle_close_after
|
|
231
|
+
_idle_watchdog(daemon, cfg.idle_close_after,
|
|
232
|
+
session_idle_prune=cfg.session_idle_prune))
|
|
229
233
|
try:
|
|
230
234
|
await stop.wait()
|
|
231
235
|
logger.info("browserwright-daemon shutdown requested")
|
|
@@ -979,7 +983,76 @@ class _UpstreamHolder:
|
|
|
979
983
|
# ---- graceful shutdown -----------------------------------------------------
|
|
980
984
|
|
|
981
985
|
|
|
982
|
-
async def
|
|
986
|
+
async def _auto_prune_sessions(daemon: "Daemon", *, reason: str) -> list[dict]:
|
|
987
|
+
"""Best-effort durable ledger prune.
|
|
988
|
+
|
|
989
|
+
The session idle clock is `ledger.last_seen`, updated when a new
|
|
990
|
+
user/agent instruction arrives. Executor liveness is deliberately ignored:
|
|
991
|
+
a stuck executor can stay alive forever and must not keep a session from
|
|
992
|
+
being cleaned after the instruction-idle threshold.
|
|
993
|
+
"""
|
|
994
|
+
idle_seconds = daemon.cfg.session_idle_prune
|
|
995
|
+
if not idle_seconds:
|
|
996
|
+
return []
|
|
997
|
+
try:
|
|
998
|
+
from ... import session_registry
|
|
999
|
+
stale = session_registry.stale(idle_seconds=idle_seconds)
|
|
1000
|
+
except Exception as e: # noqa: BLE001 - cleanup must not kill the daemon
|
|
1001
|
+
logger.warning("auto session-prune failed (%s): %r", reason, e)
|
|
1002
|
+
return []
|
|
1003
|
+
pruned: list[dict] = []
|
|
1004
|
+
for rec in stale:
|
|
1005
|
+
sid = str(rec.get("id") or "")
|
|
1006
|
+
if not sid:
|
|
1007
|
+
continue
|
|
1008
|
+
try:
|
|
1009
|
+
daemon.executors.kill(sid)
|
|
1010
|
+
except Exception as e: # noqa: BLE001
|
|
1011
|
+
logger.warning("auto session-prune executor kill failed "
|
|
1012
|
+
"(session=%s): %r", sid, e)
|
|
1013
|
+
if rec.get("backend") == "rdp" and rec.get("owner") == "create":
|
|
1014
|
+
try:
|
|
1015
|
+
await daemon.teardown_rdp_context(sid)
|
|
1016
|
+
except Exception as e: # noqa: BLE001
|
|
1017
|
+
logger.warning("auto session-prune rdp teardown failed "
|
|
1018
|
+
"(session=%s): %r", sid, e)
|
|
1019
|
+
elif rec.get("backend") == "extension":
|
|
1020
|
+
try:
|
|
1021
|
+
runtime = rec.get("runtime") or {}
|
|
1022
|
+
group_id = runtime.get("group_id")
|
|
1023
|
+
group_id = (
|
|
1024
|
+
group_id
|
|
1025
|
+
if isinstance(group_id, int) and group_id >= 0
|
|
1026
|
+
else None
|
|
1027
|
+
)
|
|
1028
|
+
holder = daemon.shared_context.holder
|
|
1029
|
+
upstream = holder.upstream
|
|
1030
|
+
if hasattr(upstream, "end_session"):
|
|
1031
|
+
await upstream.end_session(sid, group_id) # type: ignore[attr-defined]
|
|
1032
|
+
except Exception as e: # noqa: BLE001
|
|
1033
|
+
logger.warning("auto session-prune extension teardown failed "
|
|
1034
|
+
"(session=%s): %r", sid, e)
|
|
1035
|
+
try:
|
|
1036
|
+
removed = session_registry.remove(sid)
|
|
1037
|
+
except Exception as e: # noqa: BLE001
|
|
1038
|
+
logger.warning("auto session-prune ledger remove failed "
|
|
1039
|
+
"(session=%s): %r", sid, e)
|
|
1040
|
+
removed = None
|
|
1041
|
+
if removed is not None:
|
|
1042
|
+
pruned.append(removed)
|
|
1043
|
+
if pruned:
|
|
1044
|
+
logger.info("auto-pruned %d idle session(s) on %s: %s",
|
|
1045
|
+
len(pruned), reason,
|
|
1046
|
+
[str(rec.get("id")) for rec in pruned])
|
|
1047
|
+
return pruned
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
async def _idle_watchdog(
|
|
1051
|
+
daemon: "Daemon",
|
|
1052
|
+
idle_after: float | None,
|
|
1053
|
+
*,
|
|
1054
|
+
session_idle_prune: float | None = None,
|
|
1055
|
+
) -> None:
|
|
983
1056
|
"""Spec §6.5/§6.6: when configured, close each upstream after `idle_after`
|
|
984
1057
|
seconds with no activity. The next client command lazy-opens it again.
|
|
985
1058
|
|
|
@@ -995,13 +1068,21 @@ async def _idle_watchdog(daemon: "Daemon", idle_after: float | None) -> None:
|
|
|
995
1068
|
leak a subprocess.
|
|
996
1069
|
|
|
997
1070
|
Runs unconditionally; idle-close + idle-reap are no-ops when `idle_after`
|
|
998
|
-
is None.
|
|
999
|
-
|
|
1071
|
+
is None. Durable ledger prune is controlled independently by
|
|
1072
|
+
`session_idle_prune`. We poll at the smallest active supervision cadence,
|
|
1073
|
+
or every 5s when only crash-reap is active.
|
|
1000
1074
|
"""
|
|
1001
|
-
|
|
1075
|
+
cadences = [5.0]
|
|
1076
|
+
if idle_after:
|
|
1077
|
+
cadences.append(max(1.0, idle_after / 2.0))
|
|
1078
|
+
if session_idle_prune:
|
|
1079
|
+
cadences.append(_SESSION_PRUNE_INTERVAL_S)
|
|
1080
|
+
poll = min(cadences)
|
|
1081
|
+
last_session_prune_at = time.time()
|
|
1002
1082
|
try:
|
|
1003
1083
|
while True:
|
|
1004
1084
|
await asyncio.sleep(poll)
|
|
1085
|
+
now = time.time()
|
|
1005
1086
|
# --- executor supervision (Phase B PR2) ---
|
|
1006
1087
|
try:
|
|
1007
1088
|
daemon.executors.reap_dead()
|
|
@@ -1010,12 +1091,16 @@ async def _idle_watchdog(daemon: "Daemon", idle_after: float | None) -> None:
|
|
|
1010
1091
|
except Exception as e: # noqa: BLE001 - never let reap break the loop
|
|
1011
1092
|
logger.warning("executor reap failed: %r", e)
|
|
1012
1093
|
# --- upstream idle-close (gated) ---
|
|
1094
|
+
if session_idle_prune and (
|
|
1095
|
+
now - last_session_prune_at >= _SESSION_PRUNE_INTERVAL_S):
|
|
1096
|
+
await _auto_prune_sessions(daemon, reason="watchdog")
|
|
1097
|
+
last_session_prune_at = now
|
|
1013
1098
|
if not idle_after:
|
|
1014
1099
|
continue
|
|
1015
1100
|
for ctx in daemon.all_contexts():
|
|
1016
1101
|
if ctx.state.upstream_phase != UpstreamPhase.CONNECTED:
|
|
1017
1102
|
continue
|
|
1018
|
-
idle_for =
|
|
1103
|
+
idle_for = now - ctx.state.last_activity_at
|
|
1019
1104
|
if idle_for >= idle_after:
|
|
1020
1105
|
logger.info("idle-watchdog: closing %s upstream after %.1fs",
|
|
1021
1106
|
ctx.backend, idle_for)
|
|
@@ -1171,10 +1171,12 @@ class Router:
|
|
|
1171
1171
|
# extension backend; background=False opens the tab in the foreground.
|
|
1172
1172
|
background = params.get("background")
|
|
1173
1173
|
background = background if isinstance(background, bool) else True
|
|
1174
|
+
skip_post_attach_commands = params.get("skipPostAttachCommands") is True
|
|
1174
1175
|
try:
|
|
1175
1176
|
result = await self._open_background_tab(
|
|
1176
1177
|
url, group_name=group_name, session_id=session,
|
|
1177
|
-
background=background
|
|
1178
|
+
background=background,
|
|
1179
|
+
skip_post_attach_commands=skip_post_attach_commands)
|
|
1178
1180
|
except Exception as e:
|
|
1179
1181
|
await self._send_to_client(client.client_id, _error_response(
|
|
1180
1182
|
req_id, -32603, f"openBackgroundTab failed: {e!r}"))
|
|
@@ -78,6 +78,9 @@ DEFAULT_RELAY_PORT = 19989
|
|
|
78
78
|
# with the playwriter / OpenCLI experience.
|
|
79
79
|
ATTACH_RETRY_LIMIT = 3
|
|
80
80
|
ATTACH_RETRY_BACKOFF = (0.1, 0.3, 0.8) # seconds; len must equal ATTACH_RETRY_LIMIT
|
|
81
|
+
APP_PING_INTERVAL = 5.0
|
|
82
|
+
STALE_FRAME_AFTER = 30.0
|
|
83
|
+
RECONNECT_WAIT_TIMEOUT = 35.0
|
|
81
84
|
|
|
82
85
|
|
|
83
86
|
@dataclass
|
|
@@ -110,6 +113,8 @@ class _ExtensionConn:
|
|
|
110
113
|
hello_received: asyncio.Event = field(default_factory=asyncio.Event)
|
|
111
114
|
pending: dict[int, asyncio.Future] = field(default_factory=dict)
|
|
112
115
|
tabs: dict[int, GhostTarget] = field(default_factory=dict)
|
|
116
|
+
last_frame_ts: float = field(default_factory=time.monotonic)
|
|
117
|
+
app_ping_task: asyncio.Task | None = None
|
|
113
118
|
|
|
114
119
|
|
|
115
120
|
class RelayServer:
|
|
@@ -273,6 +278,9 @@ class RelayServer:
|
|
|
273
278
|
heuristic-recent-activate table.
|
|
274
279
|
"""
|
|
275
280
|
ext = self._pick_active_extension()
|
|
281
|
+
if ext is None:
|
|
282
|
+
return None
|
|
283
|
+
ext = await self._ensure_extension_fresh(ext)
|
|
276
284
|
if ext is None:
|
|
277
285
|
return None
|
|
278
286
|
return await self._request(ext, {"type": "queryActiveTab"},
|
|
@@ -280,7 +288,7 @@ class RelayServer:
|
|
|
280
288
|
|
|
281
289
|
async def query_group_tabs(self, group_name: str | None = None, *,
|
|
282
290
|
group_id: int | None = None,
|
|
283
|
-
timeout: float =
|
|
291
|
+
timeout: float = 15.0) -> dict | None:
|
|
284
292
|
"""Live membership query: ask the extension for the tabs of the
|
|
285
293
|
session's tab group. ``group_id`` is the durable primary key (the
|
|
286
294
|
numeric Chrome groupId); ``group_name`` is accepted for older callers
|
|
@@ -291,6 +299,9 @@ class RelayServer:
|
|
|
291
299
|
Returns None when no extension is connected (mirrors
|
|
292
300
|
query_active_tab's caller-falls-back contract)."""
|
|
293
301
|
ext = self._pick_active_extension()
|
|
302
|
+
if ext is None:
|
|
303
|
+
return None
|
|
304
|
+
ext = await self._ensure_extension_fresh(ext)
|
|
294
305
|
if ext is None:
|
|
295
306
|
return None
|
|
296
307
|
body: dict = {"type": "queryGroup"}
|
|
@@ -322,6 +333,7 @@ class RelayServer:
|
|
|
322
333
|
ext = self._pick_active_extension()
|
|
323
334
|
if ext is None:
|
|
324
335
|
raise RuntimeError("no extension connected")
|
|
336
|
+
ext = await self._ensure_extension_fresh_or_raise(ext)
|
|
325
337
|
last_err: Exception | None = None
|
|
326
338
|
body: dict = {"type": "attachActive"}
|
|
327
339
|
if group_name:
|
|
@@ -367,6 +379,7 @@ class RelayServer:
|
|
|
367
379
|
ext = self._pick_active_extension()
|
|
368
380
|
if ext is None:
|
|
369
381
|
raise RuntimeError("no extension connected")
|
|
382
|
+
ext = await self._ensure_extension_fresh_or_raise(ext)
|
|
370
383
|
# Idempotency: extension may already hold chrome.debugger.attach on
|
|
371
384
|
# this tab (popup click, prior daemon lifecycle — the SW survives
|
|
372
385
|
# daemon restarts and re-announces attached tabs on reconnect, so
|
|
@@ -403,6 +416,9 @@ class RelayServer:
|
|
|
403
416
|
async def detach_tab(self, tab_id: int, *,
|
|
404
417
|
timeout: float = 5.0) -> None:
|
|
405
418
|
ext = self._extension_for_tab(tab_id)
|
|
419
|
+
if ext is None:
|
|
420
|
+
return
|
|
421
|
+
ext = await self._ensure_extension_fresh(ext)
|
|
406
422
|
if ext is None:
|
|
407
423
|
return
|
|
408
424
|
try:
|
|
@@ -419,6 +435,7 @@ class RelayServer:
|
|
|
419
435
|
group_name: str | None = "Agent",
|
|
420
436
|
group_id: int | None = None,
|
|
421
437
|
background: bool = True,
|
|
438
|
+
skip_post_attach_commands: bool = False,
|
|
422
439
|
timeout: float = 10.0,
|
|
423
440
|
) -> GhostTarget:
|
|
424
441
|
"""Spec Phase B Feature 1: open a tab in the background (active=false)
|
|
@@ -440,6 +457,7 @@ class RelayServer:
|
|
|
440
457
|
ext = self._pick_active_extension()
|
|
441
458
|
if ext is None:
|
|
442
459
|
raise RuntimeError("no extension connected")
|
|
460
|
+
ext = await self._ensure_extension_fresh_or_raise(ext)
|
|
443
461
|
body: dict = {"type": "createTab", "url": url}
|
|
444
462
|
if group_name:
|
|
445
463
|
body["groupName"] = group_name
|
|
@@ -450,6 +468,8 @@ class RelayServer:
|
|
|
450
468
|
# is requested so existing extensions default to background.
|
|
451
469
|
if not background:
|
|
452
470
|
body["background"] = False
|
|
471
|
+
if skip_post_attach_commands:
|
|
472
|
+
body["skipPostAttachCommands"] = True
|
|
453
473
|
result = await self._request(ext, body, timeout=timeout) or {}
|
|
454
474
|
tab_id = int(result.get("tabId", -1))
|
|
455
475
|
if tab_id < 0:
|
|
@@ -489,6 +509,7 @@ class RelayServer:
|
|
|
489
509
|
ext = self._extension_for_tab(tab_id)
|
|
490
510
|
if ext is None:
|
|
491
511
|
raise RuntimeError(f"no extension knows tab {tab_id}")
|
|
512
|
+
ext = await self._ensure_extension_fresh_or_raise(ext)
|
|
492
513
|
try:
|
|
493
514
|
await self._request(
|
|
494
515
|
ext, {"type": "closeTab", "tabId": tab_id}, timeout=timeout)
|
|
@@ -504,6 +525,7 @@ class RelayServer:
|
|
|
504
525
|
ext = self._extension_for_tab(tab_id)
|
|
505
526
|
if ext is None:
|
|
506
527
|
raise RuntimeError(f"no extension owns tab {tab_id}")
|
|
528
|
+
ext = await self._ensure_extension_fresh_or_raise(ext)
|
|
507
529
|
return await self._request(ext, {
|
|
508
530
|
"type": "command",
|
|
509
531
|
"tabId": tab_id,
|
|
@@ -522,6 +544,7 @@ class RelayServer:
|
|
|
522
544
|
ext = self._pick_active_extension()
|
|
523
545
|
if ext is None:
|
|
524
546
|
raise RuntimeError("no extension connected")
|
|
547
|
+
ext = await self._ensure_extension_fresh_or_raise(ext)
|
|
525
548
|
return await self._request(
|
|
526
549
|
ext, {"type": f"userscript.{verb}", **payload}, timeout=timeout)
|
|
527
550
|
|
|
@@ -546,6 +569,106 @@ class RelayServer:
|
|
|
546
569
|
self._next_cmd_id += 1
|
|
547
570
|
return v
|
|
548
571
|
|
|
572
|
+
def _extension_is_stale(self, ext: _ExtensionConn) -> bool:
|
|
573
|
+
if not ext.hello_received.is_set():
|
|
574
|
+
return False
|
|
575
|
+
return (time.monotonic() - ext.last_frame_ts) > STALE_FRAME_AFTER
|
|
576
|
+
|
|
577
|
+
async def _ensure_extension_fresh(
|
|
578
|
+
self, ext: _ExtensionConn,
|
|
579
|
+
) -> _ExtensionConn | None:
|
|
580
|
+
"""Return a live extension connection, force-closing ghost sockets.
|
|
581
|
+
|
|
582
|
+
MV3 can suspend the SW while Chrome's network process keeps the TCP
|
|
583
|
+
websocket ESTABLISHED. Protocol pings still succeed there, but no app
|
|
584
|
+
frames arrive. The daemon treats missing app frames as authoritative
|
|
585
|
+
and tears down the ghost before sending a user command.
|
|
586
|
+
"""
|
|
587
|
+
if not self._extension_is_stale(ext):
|
|
588
|
+
return ext
|
|
589
|
+
await self._force_close_extension(ext, reason="stale app-level heartbeat")
|
|
590
|
+
return await self._wait_for_replacement(ext, timeout=RECONNECT_WAIT_TIMEOUT)
|
|
591
|
+
|
|
592
|
+
async def _ensure_extension_fresh_or_raise(
|
|
593
|
+
self, ext: _ExtensionConn,
|
|
594
|
+
) -> _ExtensionConn:
|
|
595
|
+
fresh = await self._ensure_extension_fresh(ext)
|
|
596
|
+
if fresh is None:
|
|
597
|
+
raise RuntimeError(
|
|
598
|
+
"extension relay connection appears stale and did not reconnect "
|
|
599
|
+
f"within {RECONNECT_WAIT_TIMEOUT:.0f}s")
|
|
600
|
+
return fresh
|
|
601
|
+
|
|
602
|
+
async def _force_close_extension(self, ext: _ExtensionConn, *, reason: str) -> None:
|
|
603
|
+
logger.warning(
|
|
604
|
+
"force-closing stale extension relay connection: install_id=%s reason=%s",
|
|
605
|
+
ext.install_id or "(pending)",
|
|
606
|
+
reason,
|
|
607
|
+
)
|
|
608
|
+
for fut in list(ext.pending.values()):
|
|
609
|
+
if not fut.done():
|
|
610
|
+
fut.set_exception(ConnectionError(f"extension relay closed: {reason}"))
|
|
611
|
+
if fut.cancelled():
|
|
612
|
+
continue
|
|
613
|
+
with contextlib.suppress(BaseException):
|
|
614
|
+
fut.exception()
|
|
615
|
+
with contextlib.suppress(Exception):
|
|
616
|
+
await asyncio.wait_for(
|
|
617
|
+
ext.conn.close(code=1011, reason=reason),
|
|
618
|
+
timeout=1.0,
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
async def _wait_for_replacement(
|
|
622
|
+
self, old_ext: _ExtensionConn, *, timeout: float,
|
|
623
|
+
allow_any_install: bool = False,
|
|
624
|
+
) -> _ExtensionConn | None:
|
|
625
|
+
deadline = time.monotonic() + max(0.0, timeout)
|
|
626
|
+
install_id = old_ext.install_id
|
|
627
|
+
while time.monotonic() < deadline:
|
|
628
|
+
candidates = [
|
|
629
|
+
ext for ext in self._extensions.values()
|
|
630
|
+
if ext is not old_ext and ext.hello_received.is_set()
|
|
631
|
+
]
|
|
632
|
+
if install_id:
|
|
633
|
+
for ext in candidates:
|
|
634
|
+
if ext.install_id == install_id:
|
|
635
|
+
return ext
|
|
636
|
+
if allow_any_install and candidates:
|
|
637
|
+
return candidates[0]
|
|
638
|
+
await asyncio.sleep(0.1)
|
|
639
|
+
if install_id:
|
|
640
|
+
return None
|
|
641
|
+
if candidates := [
|
|
642
|
+
ext for ext in self._extensions.values()
|
|
643
|
+
if ext is not old_ext and ext.hello_received.is_set()
|
|
644
|
+
]:
|
|
645
|
+
return candidates[0]
|
|
646
|
+
return None
|
|
647
|
+
|
|
648
|
+
async def _retry_request_on_replacement(
|
|
649
|
+
self,
|
|
650
|
+
ext: _ExtensionConn,
|
|
651
|
+
body: dict,
|
|
652
|
+
*,
|
|
653
|
+
timeout: float,
|
|
654
|
+
loop: asyncio.AbstractEventLoop,
|
|
655
|
+
) -> dict | None:
|
|
656
|
+
await self._force_close_extension(ext, reason=f"{body.get('type')} request failed")
|
|
657
|
+
replacement = await self._wait_for_replacement(
|
|
658
|
+
ext, timeout=RECONNECT_WAIT_TIMEOUT)
|
|
659
|
+
if replacement is None:
|
|
660
|
+
raise ConnectionError(
|
|
661
|
+
"extension relay did not reconnect after request failure")
|
|
662
|
+
retry_id = self._alloc_id()
|
|
663
|
+
retry_body = {**{k: v for k, v in body.items() if k != "id"}, "id": retry_id}
|
|
664
|
+
retry_fut: asyncio.Future = loop.create_future()
|
|
665
|
+
replacement.pending[retry_id] = retry_fut
|
|
666
|
+
try:
|
|
667
|
+
await replacement.conn.send(json.dumps(retry_body))
|
|
668
|
+
return await asyncio.wait_for(retry_fut, timeout=timeout)
|
|
669
|
+
finally:
|
|
670
|
+
replacement.pending.pop(retry_id, None)
|
|
671
|
+
|
|
549
672
|
async def _request(self, ext: _ExtensionConn, body: dict, *,
|
|
550
673
|
timeout: float) -> dict | None:
|
|
551
674
|
cmd_id = self._alloc_id()
|
|
@@ -556,7 +679,18 @@ class RelayServer:
|
|
|
556
679
|
try:
|
|
557
680
|
await ext.conn.send(json.dumps(body))
|
|
558
681
|
return await asyncio.wait_for(fut, timeout=timeout)
|
|
682
|
+
except asyncio.TimeoutError:
|
|
683
|
+
if not self._extension_is_stale(ext):
|
|
684
|
+
raise
|
|
685
|
+
return await self._retry_request_on_replacement(
|
|
686
|
+
ext, body, timeout=timeout, loop=loop)
|
|
687
|
+
except (ConnectionError, websockets.exceptions.ConnectionClosed):
|
|
688
|
+
return await self._retry_request_on_replacement(
|
|
689
|
+
ext, body, timeout=timeout, loop=loop)
|
|
559
690
|
finally:
|
|
691
|
+
if not fut.cancelled():
|
|
692
|
+
with contextlib.suppress(BaseException):
|
|
693
|
+
fut.exception()
|
|
560
694
|
ext.pending.pop(cmd_id, None)
|
|
561
695
|
|
|
562
696
|
# ---- ws handlers -----------------------------------------------------
|
|
@@ -633,6 +767,7 @@ class RelayServer:
|
|
|
633
767
|
self._extensions[temp_key] = ext
|
|
634
768
|
try:
|
|
635
769
|
async for raw in conn:
|
|
770
|
+
ext.last_frame_ts = time.monotonic()
|
|
636
771
|
if not isinstance(raw, (str, bytes)):
|
|
637
772
|
continue
|
|
638
773
|
text = raw if isinstance(raw, str) else raw.decode("utf-8", errors="replace")
|
|
@@ -650,8 +785,12 @@ class RelayServer:
|
|
|
650
785
|
logger.warning("extension handler crashed: %r", e)
|
|
651
786
|
finally:
|
|
652
787
|
key = ext.install_id or temp_key
|
|
653
|
-
self._extensions.
|
|
654
|
-
|
|
788
|
+
if self._extensions.get(key) is ext:
|
|
789
|
+
self._extensions.pop(key, None)
|
|
790
|
+
if self._extensions.get(temp_key) is ext:
|
|
791
|
+
self._extensions.pop(temp_key, None)
|
|
792
|
+
if ext.app_ping_task is not None:
|
|
793
|
+
ext.app_ping_task.cancel()
|
|
655
794
|
for fut in list(ext.pending.values()):
|
|
656
795
|
if not fut.done():
|
|
657
796
|
fut.set_exception(ConnectionError("extension disconnected"))
|
|
@@ -673,6 +812,8 @@ class RelayServer:
|
|
|
673
812
|
self._extensions.pop(temp_key, None)
|
|
674
813
|
self._extensions[ext.install_id or temp_key] = ext
|
|
675
814
|
ext.hello_received.set()
|
|
815
|
+
if ext.app_ping_task is None or ext.app_ping_task.done():
|
|
816
|
+
ext.app_ping_task = asyncio.create_task(self._app_ping_loop(ext))
|
|
676
817
|
self._first_ready.set()
|
|
677
818
|
if (
|
|
678
819
|
ext.extension_protocol_version
|
|
@@ -709,6 +850,9 @@ class RelayServer:
|
|
|
709
850
|
pass
|
|
710
851
|
return
|
|
711
852
|
|
|
853
|
+
if kind == "pong":
|
|
854
|
+
return
|
|
855
|
+
|
|
712
856
|
if kind == "attached":
|
|
713
857
|
tab_id = int(msg.get("tabId", -1))
|
|
714
858
|
if tab_id < 0:
|
|
@@ -725,7 +869,7 @@ class RelayServer:
|
|
|
725
869
|
# lifecycle so they can synthesize Target.targetCreated /
|
|
726
870
|
# attachedToTarget for a live `connect_over_cdp` client. The agent
|
|
727
871
|
# path ignores these (its `_on_event` only handles `event`).
|
|
728
|
-
|
|
872
|
+
self._schedule_fanout_listeners(msg)
|
|
729
873
|
return
|
|
730
874
|
|
|
731
875
|
if kind == "detached":
|
|
@@ -733,7 +877,7 @@ class RelayServer:
|
|
|
733
877
|
if tab_id < 0:
|
|
734
878
|
return
|
|
735
879
|
ext.tabs.pop(tab_id, None)
|
|
736
|
-
|
|
880
|
+
self._schedule_fanout_listeners(msg)
|
|
737
881
|
return
|
|
738
882
|
|
|
739
883
|
if kind == "response":
|
|
@@ -762,6 +906,26 @@ class RelayServer:
|
|
|
762
906
|
|
|
763
907
|
logger.debug("extension sent unknown type %r: %s", kind, str(msg)[:100])
|
|
764
908
|
|
|
909
|
+
async def _app_ping_loop(self, ext: _ExtensionConn) -> None:
|
|
910
|
+
try:
|
|
911
|
+
while True:
|
|
912
|
+
await asyncio.sleep(APP_PING_INTERVAL)
|
|
913
|
+
if not ext.hello_received.is_set():
|
|
914
|
+
continue
|
|
915
|
+
if self._extension_is_stale(ext):
|
|
916
|
+
await self._force_close_extension(
|
|
917
|
+
ext, reason="missing app-level frames")
|
|
918
|
+
return
|
|
919
|
+
try:
|
|
920
|
+
await ext.conn.send(json.dumps({
|
|
921
|
+
"type": "ping",
|
|
922
|
+
"ts": int(time.time() * 1000),
|
|
923
|
+
}))
|
|
924
|
+
except Exception:
|
|
925
|
+
return
|
|
926
|
+
except asyncio.CancelledError:
|
|
927
|
+
raise
|
|
928
|
+
|
|
765
929
|
async def _fanout_listeners(self, msg: dict) -> None:
|
|
766
930
|
"""Call every additional fan-out observer with the raw extension
|
|
767
931
|
message (PR2). Isolated from the primary `_on_event` so one observer
|
|
@@ -772,6 +936,28 @@ class RelayServer:
|
|
|
772
936
|
except Exception as e: # noqa: BLE001
|
|
773
937
|
logger.warning("relay fan-out listener raised: %r", e)
|
|
774
938
|
|
|
939
|
+
def _schedule_fanout_listeners(self, msg: dict) -> None:
|
|
940
|
+
"""Notify secondary observers without blocking the relay reader.
|
|
941
|
+
|
|
942
|
+
An extension ``attached`` frame is often followed immediately by the
|
|
943
|
+
response to the create/attach request. Facade listeners may issue their
|
|
944
|
+
own relay requests for scoped visibility checks, so awaiting them inline
|
|
945
|
+
can deadlock the single websocket reader before it consumes the pending
|
|
946
|
+
response. Schedule the fan-out instead and keep draining extension
|
|
947
|
+
frames.
|
|
948
|
+
"""
|
|
949
|
+
if not self._event_listeners:
|
|
950
|
+
return
|
|
951
|
+
task = asyncio.create_task(self._fanout_listeners(dict(msg)))
|
|
952
|
+
|
|
953
|
+
def _done(t: asyncio.Task) -> None:
|
|
954
|
+
try:
|
|
955
|
+
t.result()
|
|
956
|
+
except Exception as e: # noqa: BLE001
|
|
957
|
+
logger.warning("relay fan-out task raised: %r", e)
|
|
958
|
+
|
|
959
|
+
task.add_done_callback(_done)
|
|
960
|
+
|
|
775
961
|
|
|
776
962
|
class _CommandError(Exception):
|
|
777
963
|
"""Wrapped extension-side CDP error. Surfaced to the caller in
|
|
@@ -224,7 +224,12 @@ def switch_tab(target) -> dict:
|
|
|
224
224
|
return {"targetId": target_id}
|
|
225
225
|
|
|
226
226
|
|
|
227
|
-
def open(
|
|
227
|
+
def open(
|
|
228
|
+
url: str = "about:blank",
|
|
229
|
+
*,
|
|
230
|
+
background: bool = True,
|
|
231
|
+
skip_post_attach_commands: bool = False,
|
|
232
|
+
) -> dict:
|
|
228
233
|
"""Open a new working tab in this session's browser, attach, bind as
|
|
229
234
|
current. The unified tab-opening primitive (docs §Tier B) — replaces
|
|
230
235
|
both ``new_tab`` and ``open_background``.
|
|
@@ -246,7 +251,10 @@ def open(url: str = "about:blank", *, background: bool = True) -> dict:
|
|
|
246
251
|
try:
|
|
247
252
|
payload = sess.cdp.send(
|
|
248
253
|
"BrowserwrightDaemon.openBackgroundTab",
|
|
249
|
-
url=url,
|
|
254
|
+
url=url,
|
|
255
|
+
bsSession=sid,
|
|
256
|
+
background=background,
|
|
257
|
+
skipPostAttachCommands=skip_post_attach_commands,
|
|
250
258
|
)
|
|
251
259
|
except CDPError as e:
|
|
252
260
|
raise CDPError(
|
|
@@ -400,7 +408,7 @@ def current_page() -> dict:
|
|
|
400
408
|
return {"targetId": tabs[0]["targetId"], "url": tabs[0]["url"],
|
|
401
409
|
"title": tabs[0]["title"], "accuracy": "unknown"}
|
|
402
410
|
# 4. Empty session — open a fresh working tab (NOT adopt).
|
|
403
|
-
return open("about:blank") | {"accuracy": "unknown"}
|
|
411
|
+
return open("about:blank", skip_post_attach_commands=True) | {"accuracy": "unknown"}
|
|
404
412
|
|
|
405
413
|
|
|
406
414
|
def wait(seconds: float = 1.0) -> None:
|
|
@@ -125,10 +125,17 @@ def reset_executor(record: dict) -> str:
|
|
|
125
125
|
def reap(*, idle_seconds: float) -> list[dict]:
|
|
126
126
|
"""Prune idle sessions; for create-owned ones, also tear down the browser
|
|
127
127
|
the daemon launched. Returns the pruned records."""
|
|
128
|
-
|
|
129
|
-
|
|
128
|
+
stale = reg.stale(idle_seconds=idle_seconds)
|
|
129
|
+
pruned: list[dict] = []
|
|
130
|
+
for rec in stale:
|
|
131
|
+
_reap_executor(rec)
|
|
132
|
+
if rec.get("backend") == "extension":
|
|
133
|
+
_end_extension_workspace(rec)
|
|
130
134
|
if rec.get("owner") == "create":
|
|
131
135
|
_close_browser(rec)
|
|
136
|
+
removed = reg.remove(str(rec.get("id")))
|
|
137
|
+
if removed is not None:
|
|
138
|
+
pruned.append(removed)
|
|
132
139
|
return pruned
|
|
133
140
|
|
|
134
141
|
|
|
@@ -124,6 +124,20 @@ def list_all() -> list[dict]:
|
|
|
124
124
|
return [sessions[k] for k in sorted(sessions, key=int)]
|
|
125
125
|
|
|
126
126
|
|
|
127
|
+
def stale(*, idle_seconds: float) -> list[dict]:
|
|
128
|
+
"""Sessions idle longer than ``idle_seconds`` without removing them."""
|
|
129
|
+
now = time.time()
|
|
130
|
+
p = _ledger_path()
|
|
131
|
+
if not p.exists():
|
|
132
|
+
return []
|
|
133
|
+
sessions = json.loads(p.read_text())["sessions"]
|
|
134
|
+
records = [
|
|
135
|
+
e for e in sessions.values()
|
|
136
|
+
if now - e.get("last_seen", 0.0) >= idle_seconds
|
|
137
|
+
]
|
|
138
|
+
return sorted(records, key=lambda e: int(e.get("id", 0)))
|
|
139
|
+
|
|
140
|
+
|
|
127
141
|
def prune(*, idle_seconds: float) -> list[dict]:
|
|
128
142
|
"""Remove sessions idle longer than ``idle_seconds``; return removed records."""
|
|
129
143
|
now = time.time()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: browserwright
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.11
|
|
4
4
|
Summary: Browserwright — let AI/code agents drive a real or isolated browser and author userscripts. Single package: the agent-facing REPL/site-skills/memory layer plus the bundled browser-resolving daemon (CDP proxy + extension/cloud backends).
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Requires-Dist: cdp-use==1.4.5
|
|
File without changes
|
|
File without changes
|
|
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.6.8 → browserwright-0.6.11}/src/browserwright/daemon/server/executor_registry.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
|
{browserwright-0.6.8 → browserwright-0.6.11}/src/browserwright.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|