browserwright 0.6.2__py3-none-any.whl
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/__init__.py +33 -0
- browserwright/__main__.py +6 -0
- browserwright/_executor/__init__.py +47 -0
- browserwright/_executor/__main__.py +9 -0
- browserwright/_executor/client.py +127 -0
- browserwright/_executor/process.py +652 -0
- browserwright/_executor/protocol.py +152 -0
- browserwright/api.py +66 -0
- browserwright/cdp.py +285 -0
- browserwright/cli.py +741 -0
- browserwright/daemon/__init__.py +8 -0
- browserwright/daemon/_ipc.py +444 -0
- browserwright/daemon/active_tab.py +183 -0
- browserwright/daemon/auth.py +395 -0
- browserwright/daemon/backends/__init__.py +59 -0
- browserwright/daemon/backends/base.py +120 -0
- browserwright/daemon/backends/cloud.py +222 -0
- browserwright/daemon/backends/env.py +119 -0
- browserwright/daemon/backends/extension.py +185 -0
- browserwright/daemon/backends/rdp.py +214 -0
- browserwright/daemon/cli.py +1437 -0
- browserwright/daemon/config.py +380 -0
- browserwright/daemon/doctor.py +179 -0
- browserwright/daemon/errors.py +34 -0
- browserwright/daemon/launch_chrome.py +353 -0
- browserwright/daemon/observability.py +181 -0
- browserwright/daemon/platforms.py +234 -0
- browserwright/daemon/resolver.py +72 -0
- browserwright/daemon/server/__init__.py +6 -0
- browserwright/daemon/server/daemon.py +229 -0
- browserwright/daemon/server/executor_registry.py +434 -0
- browserwright/daemon/server/extension_upstream.py +677 -0
- browserwright/daemon/server/facade.py +375 -0
- browserwright/daemon/server/facade_extension.py +969 -0
- browserwright/daemon/server/listener.py +1058 -0
- browserwright/daemon/server/proxy.py +1991 -0
- browserwright/daemon/server/relay.py +783 -0
- browserwright/daemon/server/state.py +432 -0
- browserwright/daemon/server/upstream.py +266 -0
- browserwright/daemon/userscripts.py +150 -0
- browserwright/discovery.py +213 -0
- browserwright/errors.py +177 -0
- browserwright/health.py +169 -0
- browserwright/install.py +628 -0
- browserwright/memory/__init__.py +15 -0
- browserwright/memory/_md.py +120 -0
- browserwright/memory/_yaml.py +217 -0
- browserwright/memory/global_mem.py +201 -0
- browserwright/memory/repl_mem.py +28 -0
- browserwright/memory/session_decisions.py +53 -0
- browserwright/memory/site_mem.py +381 -0
- browserwright/mode_b_client.py +590 -0
- browserwright/multitask.py +131 -0
- browserwright/output_schema.py +99 -0
- browserwright/primitives/__init__.py +67 -0
- browserwright/primitives/discovery_api.py +79 -0
- browserwright/primitives/http.py +42 -0
- browserwright/primitives/inspect.py +876 -0
- browserwright/primitives/interact.py +518 -0
- browserwright/primitives/page.py +556 -0
- browserwright/primitives/site.py +143 -0
- browserwright/release_install.py +466 -0
- browserwright/repl/__init__.py +6 -0
- browserwright/repl/_namespace.py +106 -0
- browserwright/repl/_smart_goto.py +236 -0
- browserwright/repl/inline.py +180 -0
- browserwright/repl/playwright_handle.py +449 -0
- browserwright/repl/snapshot.py +150 -0
- browserwright/session.py +229 -0
- browserwright/session_create.py +252 -0
- browserwright/session_ctx.py +24 -0
- browserwright/session_registry.py +133 -0
- browserwright/session_runtime.py +133 -0
- browserwright/site_skills_starter/github.com/SKILL.md +14 -0
- browserwright/site_skills_starter/github.com/memory.md +29 -0
- browserwright/site_skills_starter/github.com/tasks/list_issues.py +55 -0
- browserwright/site_skills_starter/google.com/SKILL.md +16 -0
- browserwright/site_skills_starter/google.com/memory.md +27 -0
- browserwright/site_skills_starter/google.com/tasks/search.py +53 -0
- browserwright/site_skills_starter/producthunt.com/SKILL.md +7 -0
- browserwright/site_skills_starter/producthunt.com/memory.md +26 -0
- browserwright/site_skills_starter/producthunt.com/tasks/today.py +64 -0
- browserwright/site_skills_starter/wikipedia.org/SKILL.md +7 -0
- browserwright/site_skills_starter/wikipedia.org/memory.md +22 -0
- browserwright/site_skills_starter/wikipedia.org/tasks/lookup.py +55 -0
- browserwright/site_skills_starter/ycombinator.com/SKILL.md +8 -0
- browserwright/site_skills_starter/ycombinator.com/memory.md +25 -0
- browserwright/site_skills_starter/ycombinator.com/tasks/front_page.py +63 -0
- browserwright/skill_doc.py +140 -0
- browserwright/skill_runtime.md +194 -0
- browserwright/subscriptions.py +213 -0
- browserwright/task_runner.py +125 -0
- browserwright/version.py +117 -0
- browserwright-0.6.2.dist-info/METADATA +12 -0
- browserwright-0.6.2.dist-info/RECORD +98 -0
- browserwright-0.6.2.dist-info/WHEEL +5 -0
- browserwright-0.6.2.dist-info/entry_points.txt +3 -0
- browserwright-0.6.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,652 @@
|
|
|
1
|
+
"""The resident per-session executor process.
|
|
2
|
+
|
|
3
|
+
Launched as ``python -m browserwright._executor --session <id>``. On cold start
|
|
4
|
+
it binds the session env, connects the facade once (``connect_over_cdp``), binds
|
|
5
|
+
the session's current tab, and then serves a per-session unix socket: each
|
|
6
|
+
inbound :class:`~browserwright._executor.protocol.ExecuteRequest` is enqueued
|
|
7
|
+
and a single dedicated worker thread runs the code FIFO in a namespace where
|
|
8
|
+
``page`` / ``context`` are the LIVE held objects and ``state`` is a persistent
|
|
9
|
+
dict injected by reference.
|
|
10
|
+
|
|
11
|
+
Thread model (Fork 3): sync Playwright is thread-affine — the thread that did
|
|
12
|
+
``connect_over_cdp`` MUST be the thread that touches the browser objects. So the
|
|
13
|
+
WORKER thread owns connect+bind+exec; the MAIN thread runs the accept loop and
|
|
14
|
+
hands work over a queue.
|
|
15
|
+
|
|
16
|
+
Startup ordering (control-plane decoupling): the socket is bound + the discovery
|
|
17
|
+
file is written + the accept loop starts IMMEDIATELY — BEFORE any
|
|
18
|
+
``connect_over_cdp``. The slow facade cold-start (which can take 10–35s on a
|
|
19
|
+
daemon-restart race) happens LAZILY on the worker thread, triggered by the FIRST
|
|
20
|
+
execute request, NOT during process startup. This keeps ``ensureExecutor``'s
|
|
21
|
+
control-plane RPC fast (it only waits for the socket to be listening, ~sub-second)
|
|
22
|
+
so it never trips the daemon websockets keepalive/timeout. The cold-start latency
|
|
23
|
+
is absorbed by the client's own blocking data-plane call, which has no keepalive
|
|
24
|
+
and a generous client-owned wait.
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import ast
|
|
29
|
+
import io
|
|
30
|
+
import os
|
|
31
|
+
import queue
|
|
32
|
+
import socket
|
|
33
|
+
import sys
|
|
34
|
+
import threading
|
|
35
|
+
import traceback
|
|
36
|
+
from contextlib import redirect_stdout
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
from ..errors import BrowserwrightError, serialize
|
|
40
|
+
from .protocol import (
|
|
41
|
+
MAX_TEXT_CHARS,
|
|
42
|
+
ExecuteRequest,
|
|
43
|
+
ExecuteResponse,
|
|
44
|
+
recv_message,
|
|
45
|
+
send_message,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# Cold-start facade-connect retry (Failure #4): a daemon-restart cold-start can
|
|
49
|
+
# race the lazily-launched rdp Chrome. ~13 attempts × 0.75s ≈ 10s of backoff
|
|
50
|
+
# absorbs the startup window without wedging cold-start forever (the spawn-ready
|
|
51
|
+
# timeout in the registry is 35s, comfortably above this).
|
|
52
|
+
_COLD_START_CONNECT_ATTEMPTS = 13
|
|
53
|
+
_COLD_START_CONNECT_BACKOFF_S = 0.75
|
|
54
|
+
|
|
55
|
+
# Extra wait the worker-side `submit` grants the FIRST execute (before
|
|
56
|
+
# `_connected`), to absorb the lazy cold-start (connect+bind) latency on top of
|
|
57
|
+
# the per-call `timeout_ms`. Kept slightly above the connect-retry window
|
|
58
|
+
# (~13×0.75s ≈ 10s) so a legitimate cold-start is never reported as a per-call
|
|
59
|
+
# timeout. Mirrors the client-side recv slack (`client._COLD_START_RECV_SLACK_S`).
|
|
60
|
+
_COLD_START_WAIT_SLACK_S = 45.0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class _LivePageHolder:
|
|
64
|
+
"""Adapts a live Playwright ``page`` to the ``.page`` attribute
|
|
65
|
+
``snapshot.make_snapshot`` expects (it was written against the lazy
|
|
66
|
+
``PlaywrightHandle``). We rebind ``.page`` whenever the worker re-binds the
|
|
67
|
+
session tab (cold-start / future ``reset()``), so ``snapshot()`` always
|
|
68
|
+
observes the executor's current live page."""
|
|
69
|
+
|
|
70
|
+
def __init__(self) -> None:
|
|
71
|
+
self.page: Any = None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class _Worker:
|
|
75
|
+
"""Owns the live Playwright objects + persistent ``state``; runs code FIFO.
|
|
76
|
+
|
|
77
|
+
All Playwright access happens on THIS thread (thread-affinity). The main
|
|
78
|
+
thread enqueues ``(request, reply_box)`` pairs; the worker connects on
|
|
79
|
+
cold-start, then drains the queue."""
|
|
80
|
+
|
|
81
|
+
def __init__(self, session_id: str) -> None:
|
|
82
|
+
self._session_id = session_id
|
|
83
|
+
self._q: "queue.Queue[tuple[ExecuteRequest, queue.Queue[ExecuteResponse]] | None]" = (
|
|
84
|
+
queue.Queue()
|
|
85
|
+
)
|
|
86
|
+
# Whether cold-start (connect_over_cdp + bind) has completed. Cold-start
|
|
87
|
+
# is LAZY: it runs on the worker thread on the FIRST execute request, not
|
|
88
|
+
# at process start (so the control-plane `ensureExecutor` RPC returns the
|
|
89
|
+
# moment the socket is listening, never waiting on the ~10-35s connect).
|
|
90
|
+
self._connected = False
|
|
91
|
+
|
|
92
|
+
# Live Playwright objects (held for the worker's lifetime).
|
|
93
|
+
self._pw_cm: Any = None
|
|
94
|
+
self._pw: Any = None
|
|
95
|
+
self._browser: Any = None
|
|
96
|
+
self._context: Any = None
|
|
97
|
+
self._page: Any = None
|
|
98
|
+
# The currently-armed facade-death handler (Fork 4). Tracked so reset()
|
|
99
|
+
# can DETACH it before tearing down the old browser — otherwise the
|
|
100
|
+
# intentional disconnect during reset would trip the self-exit and kill
|
|
101
|
+
# the process. Re-armed on the rebuilt browser after reset.
|
|
102
|
+
self._facade_death_handler: Any = None
|
|
103
|
+
# Persistent per-session state, injected by reference each call (Fork 5).
|
|
104
|
+
self._state: dict[str, Any] = {}
|
|
105
|
+
self._snapshot_holder = _LivePageHolder()
|
|
106
|
+
self._snapshot: Any = None
|
|
107
|
+
# Per-call warnings/screenshots the running code can append to via the
|
|
108
|
+
# injected `_bw_warn` / screenshot helpers. Reset at the start of each
|
|
109
|
+
# _execute; collected into the ExecuteResponse.
|
|
110
|
+
self._call_warnings: list[str] = []
|
|
111
|
+
self._call_screenshots: list[dict[str, Any]] = []
|
|
112
|
+
|
|
113
|
+
self._thread = threading.Thread(
|
|
114
|
+
target=self._run, name="bw-executor-worker", daemon=True)
|
|
115
|
+
|
|
116
|
+
# ---- lifecycle ------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
def start(self) -> None:
|
|
119
|
+
self._thread.start()
|
|
120
|
+
|
|
121
|
+
def submit(self, req: ExecuteRequest) -> ExecuteResponse:
|
|
122
|
+
"""Enqueue a request and block for its response, ENFORCING the per-call
|
|
123
|
+
timeout (Fork 3 + PR3).
|
|
124
|
+
|
|
125
|
+
The worker thread is thread-affine to the sync-Playwright driver, so a
|
|
126
|
+
running call CANNOT be force-killed from here without corrupting the
|
|
127
|
+
driver. Instead we bound the WAIT: if the worker hasn't replied within
|
|
128
|
+
``timeout_ms`` we return a timeout error to the client immediately. The
|
|
129
|
+
worker keeps finishing the (possibly wedged) call on its own thread;
|
|
130
|
+
subsequent calls QUEUE behind it (FIFO). A merely-slow call thus just
|
|
131
|
+
DELAYS later calls (they run once it finishes); `state` + the live page
|
|
132
|
+
survive (the process is untouched). A PERMANENTLY-stuck call, however,
|
|
133
|
+
blocks the queue forever — even a `reset()` queues behind it — so the
|
|
134
|
+
only hard-wedge escape is `endSession` (the daemon SIGTERMs the executor
|
|
135
|
+
out-of-band; sync Playwright can't be force-killed from here). This is
|
|
136
|
+
the documented semantics: timeout is a CLIENT-side bound, the worker
|
|
137
|
+
drains at its own pace.
|
|
138
|
+
|
|
139
|
+
Cold-start slack: the FIRST execute (before `_connected`) carries the
|
|
140
|
+
lazy facade connect+bind on the worker, which can add up to ~35s. We add
|
|
141
|
+
that slack to the wait so a legitimate cold-start isn't reported as a
|
|
142
|
+
per-call timeout. Once connected the slack is gone (the wait is exactly
|
|
143
|
+
`timeout_ms`). This mirrors the client-side recv slack."""
|
|
144
|
+
box: "queue.Queue[ExecuteResponse]" = queue.Queue(maxsize=1)
|
|
145
|
+
self._q.put((req, box))
|
|
146
|
+
slack = 0.0 if self._connected else _COLD_START_WAIT_SLACK_S
|
|
147
|
+
try:
|
|
148
|
+
return box.get(timeout=max(req.timeout_ms, 1) / 1000.0 + slack)
|
|
149
|
+
except queue.Empty:
|
|
150
|
+
return ExecuteResponse(
|
|
151
|
+
error={
|
|
152
|
+
"type": "TimeoutError",
|
|
153
|
+
"msg": (f"executor call exceeded {req.timeout_ms}ms; the "
|
|
154
|
+
"worker is still finishing it — later calls queue "
|
|
155
|
+
"behind it"),
|
|
156
|
+
"fix": ("the call is slow or wedged; run "
|
|
157
|
+
f"`browserwright session reset {self._session_id}` "
|
|
158
|
+
"to recycle the executor without closing browser "
|
|
159
|
+
"tabs, then retry the call"),
|
|
160
|
+
},
|
|
161
|
+
exit_code=3)
|
|
162
|
+
|
|
163
|
+
def shutdown(self) -> None:
|
|
164
|
+
self._q.put(None)
|
|
165
|
+
|
|
166
|
+
# ---- worker thread --------------------------------------------------
|
|
167
|
+
|
|
168
|
+
def _run(self) -> None:
|
|
169
|
+
"""Worker loop. Cold-start is NOT done here — it is deferred to the
|
|
170
|
+
first execute (see :meth:`_execute` → :meth:`_ensure_cold_started`), so
|
|
171
|
+
the process can start serving its socket immediately while the slow
|
|
172
|
+
facade connect happens off the control-plane RPC's critical path."""
|
|
173
|
+
while True:
|
|
174
|
+
item = self._q.get()
|
|
175
|
+
if item is None:
|
|
176
|
+
break
|
|
177
|
+
req, box = item
|
|
178
|
+
box.put(self._execute(req))
|
|
179
|
+
self._teardown()
|
|
180
|
+
|
|
181
|
+
def _ensure_cold_started(self) -> None:
|
|
182
|
+
"""Lazily connect the facade + bind the session's current tab — ON the
|
|
183
|
+
first execute (worker thread), NOT at process start.
|
|
184
|
+
|
|
185
|
+
Idempotent: a no-op once connected (subsequent executes reuse the live
|
|
186
|
+
objects). ``reset()`` rebuilds independently of this flag. Raises on a
|
|
187
|
+
failed connect; the caller (:meth:`_execute`) turns that into an
|
|
188
|
+
actionable error response so the agent sees it. A failed cold-start
|
|
189
|
+
leaves ``_connected`` False, so the NEXT execute retries the connect —
|
|
190
|
+
matching the facade-death cold-restart discipline.
|
|
191
|
+
|
|
192
|
+
Reuses the shared connect+bind free functions (single source of truth
|
|
193
|
+
for the FATAL "no Playwright CDP session over the extension facade"
|
|
194
|
+
constraint). Bind the session FIRST so ``current_session()`` /
|
|
195
|
+
``current_page()`` resolve the right ledger record."""
|
|
196
|
+
if self._connected:
|
|
197
|
+
return
|
|
198
|
+
from ..session import Session, set_session
|
|
199
|
+
from ..session_ctx import resolve_session
|
|
200
|
+
|
|
201
|
+
# Bind the session from the ledger (same as inline.py's entrypoint).
|
|
202
|
+
rec = resolve_session(self._session_id)
|
|
203
|
+
set_session(Session(record=rec))
|
|
204
|
+
# Enter the sync_playwright() driver ONCE — on the FIRST cold-start only.
|
|
205
|
+
# On a retry after a failed connect we REUSE the live driver (just re-run
|
|
206
|
+
# `_connect_and_bind` below), NEVER re-enter the manager: its event loop
|
|
207
|
+
# is thread-bound and cannot be restarted once `__exit__`-ed
|
|
208
|
+
# ("Event loop is closed"). Same discipline as `reset()`.
|
|
209
|
+
if self._pw_cm is None:
|
|
210
|
+
self._start_driver()
|
|
211
|
+
# Raises on a still-dead facade; `_connected` stays False so the next
|
|
212
|
+
# execute retries the connect on the SAME live driver (or the process is
|
|
213
|
+
# reaped and a fresh ensure cold-starts a new one). The driver is NOT
|
|
214
|
+
# torn down here — only `_teardown` (process exit) exits the manager.
|
|
215
|
+
self._connect_and_bind()
|
|
216
|
+
self._connected = True
|
|
217
|
+
|
|
218
|
+
def _start_driver(self) -> None:
|
|
219
|
+
"""Enter ``sync_playwright()`` ONCE (cold-start only).
|
|
220
|
+
|
|
221
|
+
Playwright's sync driver runs a thread-bound asyncio event loop that
|
|
222
|
+
CANNOT be restarted in the same thread once stopped — re-entering
|
|
223
|
+
``sync_playwright()`` on the worker after a prior ``__exit__`` yields
|
|
224
|
+
"Event loop is closed." So the driver is entered exactly once here at
|
|
225
|
+
cold-start; :meth:`reset` REUSES this same live ``self._pw`` driver
|
|
226
|
+
(it only re-runs :meth:`_connect_and_bind`, never re-enters the manager).
|
|
227
|
+
The manager is ``__exit__``-ed only at :meth:`_teardown` (process exit).
|
|
228
|
+
MUST run on the worker thread (Playwright sync is thread-affine)."""
|
|
229
|
+
from playwright.sync_api import sync_playwright
|
|
230
|
+
|
|
231
|
+
self._pw_cm = sync_playwright()
|
|
232
|
+
self._pw = self._pw_cm.__enter__()
|
|
233
|
+
|
|
234
|
+
def _connect_and_bind(self) -> None:
|
|
235
|
+
"""``connect_over_cdp`` to the facade on the live ``self._pw`` driver,
|
|
236
|
+
bind the session's current tab, rebind ``snapshot``, and arm
|
|
237
|
+
facade-death.
|
|
238
|
+
|
|
239
|
+
Shared by cold-start AND :meth:`reset`. It does NOT enter/exit the
|
|
240
|
+
``sync_playwright()`` manager — that is :meth:`_start_driver` /
|
|
241
|
+
:meth:`_teardown`'s job. It only (re)builds the browser/context/page
|
|
242
|
+
connection on the EXISTING driver, so it is safe to call repeatedly
|
|
243
|
+
(reset) without restarting the dead-once event loop. MUST run on the
|
|
244
|
+
worker thread (Playwright sync is thread-affine)."""
|
|
245
|
+
from ..repl import playwright_handle as ph
|
|
246
|
+
from ..repl.snapshot import make_snapshot
|
|
247
|
+
from ..session import current_session
|
|
248
|
+
|
|
249
|
+
# Failure #4 defense-in-depth: a daemon-restart cold-start races the rdp
|
|
250
|
+
# Chrome that `ensureExecutor` is bringing up lazily — the facade can
|
|
251
|
+
# 404/403 for a brief window while Chrome binds its CDP port. Retry a few
|
|
252
|
+
# times over ~10s so the startup race doesn't hard-fail the executor.
|
|
253
|
+
# `ensureExecutor` now also pre-launches Chrome before returning the exec
|
|
254
|
+
# socket, so this is belt-and-suspenders, not the primary fix.
|
|
255
|
+
sess = current_session()
|
|
256
|
+
self._browser = ph.connect_over_cdp(
|
|
257
|
+
self._pw, session_id=ph._session_id_from(sess),
|
|
258
|
+
attempts=_COLD_START_CONNECT_ATTEMPTS,
|
|
259
|
+
backoff_s=_COLD_START_CONNECT_BACKOFF_S)
|
|
260
|
+
self._context = ph.context_for_browser(self._browser)
|
|
261
|
+
self._page = ph.bind_current_page(self._context, sess)
|
|
262
|
+
self._snapshot_holder.page = self._page
|
|
263
|
+
self._snapshot = make_snapshot(self._snapshot_holder)
|
|
264
|
+
self._arm_facade_death()
|
|
265
|
+
|
|
266
|
+
def _reset(self) -> None:
|
|
267
|
+
"""Fork 6: rebuild the connection from scratch + clear ``state``.
|
|
268
|
+
|
|
269
|
+
The injected ``reset()`` callable (see :meth:`_build_globals`) calls
|
|
270
|
+
this. It runs ON the worker thread (the same thread that owns the
|
|
271
|
+
Playwright driver), so it is safe to touch the live objects.
|
|
272
|
+
|
|
273
|
+
ORDER MATTERS:
|
|
274
|
+
1. DISARM the facade-death handler so dropping the old browser below
|
|
275
|
+
does NOT trip ``os._exit`` and kill the process.
|
|
276
|
+
2. DROP the old ``browser``/``context``/``page`` references — do NOT
|
|
277
|
+
``close()`` them (closing a page kills the user's real tab; a
|
|
278
|
+
browser ``close()`` hangs over the facade) and do NOT ``__exit__``
|
|
279
|
+
the ``sync_playwright()`` manager (its event loop is thread-bound
|
|
280
|
+
and cannot be restarted once stopped — re-entering would yield
|
|
281
|
+
"Event loop is closed"). The old browser CDP connection is simply
|
|
282
|
+
ABANDONED; it is cleaned when the driver finally stops at
|
|
283
|
+
:meth:`_teardown`.
|
|
284
|
+
3. Rebuild via :meth:`_connect_and_bind` on the SAME live
|
|
285
|
+
``self._pw`` driver — which RE-ARMS facade-death on the NEW browser.
|
|
286
|
+
4. Clear ``state`` (playwriter parity; the agent asked for a clean
|
|
287
|
+
slate). Same state-loss contract as a daemon-restart cold start —
|
|
288
|
+
documented."""
|
|
289
|
+
self._disarm_facade_death()
|
|
290
|
+
self._connected = False
|
|
291
|
+
self._browser = None
|
|
292
|
+
self._context = None
|
|
293
|
+
self._page = None
|
|
294
|
+
self._snapshot = None
|
|
295
|
+
self._snapshot_holder.page = None
|
|
296
|
+
# reset() can be called BEFORE the lazy cold-start ever ran (the first
|
|
297
|
+
# heredoc was literally `reset()`), so the driver may not be entered yet.
|
|
298
|
+
if self._pw_cm is None:
|
|
299
|
+
self._start_driver()
|
|
300
|
+
# Rebuild + re-arm on the SAME driver. Raises on a still-dead facade
|
|
301
|
+
# (the agent sees the actionable FacadeUnavailable just as a fresh
|
|
302
|
+
# cold start would).
|
|
303
|
+
self._connect_and_bind()
|
|
304
|
+
self._connected = True
|
|
305
|
+
self._state.clear()
|
|
306
|
+
|
|
307
|
+
def _arm_facade_death(self) -> None:
|
|
308
|
+
"""Fork 4: when the facade transport drops (daemon restarted → facade ws
|
|
309
|
+
gone), self-exit cleanly rather than live-reconnecting. The daemon's
|
|
310
|
+
crash-reaper then drops us from the registry, and the NEXT heredoc
|
|
311
|
+
cold-starts a fresh executor that re-binds the session's current tab via
|
|
312
|
+
the ledger fast-path. `state` is LOST on this path — acceptable + by
|
|
313
|
+
design (same as `reset()`); the cold restart is simpler + already
|
|
314
|
+
correct vs patching a live connection.
|
|
315
|
+
|
|
316
|
+
Playwright fires the browser `disconnected` event from its own driver
|
|
317
|
+
thread; we hard-exit the process from there so the worker thread (which
|
|
318
|
+
may be blocked in a queue.get) doesn't have to notice.
|
|
319
|
+
|
|
320
|
+
We KEEP a reference to the bound handler + the browser it was armed on so
|
|
321
|
+
:meth:`reset` can `off("disconnected", handler)` it BEFORE the
|
|
322
|
+
intentional teardown — otherwise the reset's own disconnect would trip
|
|
323
|
+
the self-exit and defeat the rebuild."""
|
|
324
|
+
handler = lambda *_: self._on_facade_dead() # noqa: E731
|
|
325
|
+
try:
|
|
326
|
+
self._browser.on("disconnected", handler)
|
|
327
|
+
self._facade_death_handler = handler
|
|
328
|
+
except Exception: # noqa: BLE001 - never let arming break cold-start
|
|
329
|
+
self._facade_death_handler = None
|
|
330
|
+
|
|
331
|
+
def _disarm_facade_death(self) -> None:
|
|
332
|
+
"""Detach the armed `disconnected` handler so an INTENTIONAL browser
|
|
333
|
+
teardown (reset) does NOT trip the self-exit. Best-effort + idempotent;
|
|
334
|
+
a failure here only risks an over-eager self-exit, which we avoid by
|
|
335
|
+
clearing the reference unconditionally."""
|
|
336
|
+
handler = self._facade_death_handler
|
|
337
|
+
self._facade_death_handler = None
|
|
338
|
+
if handler is None or self._browser is None:
|
|
339
|
+
return
|
|
340
|
+
try:
|
|
341
|
+
self._browser.off("disconnected", handler)
|
|
342
|
+
except Exception: # noqa: BLE001
|
|
343
|
+
pass
|
|
344
|
+
|
|
345
|
+
def _on_facade_dead(self) -> None:
|
|
346
|
+
sys.stderr.write(
|
|
347
|
+
f"executor {self._session_id}: facade transport dropped "
|
|
348
|
+
"(daemon restart?); self-exiting for cold restart\n")
|
|
349
|
+
# os._exit so we don't run atexit/finalizers that might re-enter the
|
|
350
|
+
# (now dead) Playwright driver and hang. The daemon reaps us + cleans
|
|
351
|
+
# the discovery file; the next heredoc cold-starts a fresh executor.
|
|
352
|
+
os._exit(0)
|
|
353
|
+
|
|
354
|
+
def _execute(self, req: ExecuteRequest) -> ExecuteResponse:
|
|
355
|
+
"""Run one code blob in the persistent namespace, capturing stdout +
|
|
356
|
+
the full PR3 output protocol (return value / warnings / screenshots /
|
|
357
|
+
truncation / error-with-traceback).
|
|
358
|
+
|
|
359
|
+
Runs on the worker thread (Playwright thread-affinity). The per-call
|
|
360
|
+
timeout is enforced by :meth:`submit` on the CLIENT side; this method
|
|
361
|
+
runs the code to completion on its own thread (a sync-Playwright call
|
|
362
|
+
can't be force-killed mid-flight without corrupting the driver).
|
|
363
|
+
|
|
364
|
+
Cold-start (connect_over_cdp + bind) is performed HERE, lazily, on the
|
|
365
|
+
first execute — so the slow connect is absorbed by this data-plane call,
|
|
366
|
+
not the control-plane `ensureExecutor` RPC. A cold-start failure becomes
|
|
367
|
+
an actionable error response (the agent should `reset()` / retry once
|
|
368
|
+
the facade is up); `_connected` stays False so the next execute retries."""
|
|
369
|
+
self._call_warnings = []
|
|
370
|
+
self._call_screenshots = []
|
|
371
|
+
try:
|
|
372
|
+
self._ensure_cold_started()
|
|
373
|
+
except BrowserwrightError as e:
|
|
374
|
+
return self._finish(
|
|
375
|
+
io.StringIO(), error=serialize(e), exit_code=e.exit_code)
|
|
376
|
+
except BaseException as e: # noqa: BLE001 - surface cold-start failure
|
|
377
|
+
return self._finish(
|
|
378
|
+
io.StringIO(),
|
|
379
|
+
error={
|
|
380
|
+
"type": type(e).__name__,
|
|
381
|
+
"msg": f"executor cold-start failed: {e}",
|
|
382
|
+
"fix": ("the facade/browser was not reachable on first use; "
|
|
383
|
+
"retry the call (a fresh connect is attempted), or "
|
|
384
|
+
"end the session to recycle the executor if it "
|
|
385
|
+
"persists"),
|
|
386
|
+
"traceback": traceback.format_exc(),
|
|
387
|
+
},
|
|
388
|
+
exit_code=3)
|
|
389
|
+
globals_ = self._build_globals()
|
|
390
|
+
buf = io.StringIO()
|
|
391
|
+
return_value: str | None = None
|
|
392
|
+
try:
|
|
393
|
+
with redirect_stdout(buf):
|
|
394
|
+
return_value = self._exec_with_return(req.code, globals_)
|
|
395
|
+
except BrowserwrightError as e:
|
|
396
|
+
return self._finish(buf, error=serialize(e), exit_code=e.exit_code)
|
|
397
|
+
except SystemExit as e:
|
|
398
|
+
code = int(e.code) if isinstance(e.code, int) else 0
|
|
399
|
+
return self._finish(buf, exit_code=code)
|
|
400
|
+
except BaseException as e: # noqa: BLE001 - surface to the client
|
|
401
|
+
# Restore traceback fidelity: the in-process path writes
|
|
402
|
+
# `traceback.format_exc()` to stderr; a shipped heredoc must show
|
|
403
|
+
# the SAME traceback. We carry it on the serialized error dict.
|
|
404
|
+
from ..errors import playwright_error_fix
|
|
405
|
+
error = {
|
|
406
|
+
"type": type(e).__name__,
|
|
407
|
+
"msg": str(e),
|
|
408
|
+
"traceback": traceback.format_exc(),
|
|
409
|
+
}
|
|
410
|
+
fix = playwright_error_fix(e)
|
|
411
|
+
if fix:
|
|
412
|
+
error["fix"] = fix
|
|
413
|
+
return self._finish(
|
|
414
|
+
buf,
|
|
415
|
+
error=error,
|
|
416
|
+
exit_code=3)
|
|
417
|
+
return self._finish(buf, return_value=return_value, exit_code=0)
|
|
418
|
+
|
|
419
|
+
@staticmethod
|
|
420
|
+
def _exec_with_return(code: str, globals_: dict[str, Any]) -> str | None:
|
|
421
|
+
"""Exec ``code``; if the LAST statement is a bare expression, return its
|
|
422
|
+
``repr`` (playwriter's ``[return value]``), else None.
|
|
423
|
+
|
|
424
|
+
We split the trailing expression off and ``eval`` it so its value is
|
|
425
|
+
observable — a plain ``exec`` of an expression statement discards the
|
|
426
|
+
value. Non-expression-ending code (assignments, loops, defs) returns
|
|
427
|
+
None. A failed ``repr`` degrades to a type marker, never raising."""
|
|
428
|
+
tree = ast.parse(code, "<executor>", "exec")
|
|
429
|
+
last_value_expr: ast.Expression | None = None
|
|
430
|
+
if tree.body and isinstance(tree.body[-1], ast.Expr):
|
|
431
|
+
last = tree.body.pop()
|
|
432
|
+
last_value_expr = ast.Expression(last.value) # type: ignore[attr-defined]
|
|
433
|
+
ast.copy_location(last_value_expr, last)
|
|
434
|
+
exec(compile(tree, "<executor>", "exec"), globals_)
|
|
435
|
+
if last_value_expr is None:
|
|
436
|
+
return None
|
|
437
|
+
value = eval(compile(last_value_expr, "<executor>", "eval"), globals_)
|
|
438
|
+
if value is None:
|
|
439
|
+
return None
|
|
440
|
+
try:
|
|
441
|
+
return repr(value)
|
|
442
|
+
except Exception: # noqa: BLE001 - a hostile __repr__ must not crash us
|
|
443
|
+
return f"<{type(value).__name__} (repr failed)>"
|
|
444
|
+
|
|
445
|
+
def _finish(self, buf: io.StringIO, *, return_value: str | None = None,
|
|
446
|
+
error: dict[str, Any] | None = None,
|
|
447
|
+
exit_code: int = 0) -> ExecuteResponse:
|
|
448
|
+
"""Assemble the response: cap the console at ``MAX_TEXT_CHARS`` and
|
|
449
|
+
attach the warnings/screenshots collected during the call."""
|
|
450
|
+
console = buf.getvalue()
|
|
451
|
+
truncated = False
|
|
452
|
+
if len(console) > MAX_TEXT_CHARS:
|
|
453
|
+
console = console[:MAX_TEXT_CHARS] + "\n… [truncated]"
|
|
454
|
+
truncated = True
|
|
455
|
+
return ExecuteResponse(
|
|
456
|
+
console=console,
|
|
457
|
+
return_value=return_value,
|
|
458
|
+
error=error,
|
|
459
|
+
exit_code=exit_code,
|
|
460
|
+
warnings=list(self._call_warnings),
|
|
461
|
+
screenshots=list(self._call_screenshots),
|
|
462
|
+
truncated=truncated,
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
def _build_globals(self) -> dict[str, Any]:
|
|
466
|
+
"""The exec namespace: the Phase C surface, but with ``page`` /
|
|
467
|
+
``context`` replaced by the LIVE held objects, ``state`` injected by
|
|
468
|
+
reference (persists across calls — Fork 5), and ``snapshot`` rebound to
|
|
469
|
+
the live page.
|
|
470
|
+
|
|
471
|
+
We reuse ``_namespace.build_globals()`` so the agent helper layer +
|
|
472
|
+
EXPORTS stay identical to the heredoc, then OVERWRITE the lazy proxies
|
|
473
|
+
with live objects. The handle the lazy build injected
|
|
474
|
+
(``__bw_playwright_handle__``) is dropped — the executor owns teardown,
|
|
475
|
+
not the per-call namespace."""
|
|
476
|
+
from ..repl import _namespace
|
|
477
|
+
|
|
478
|
+
g = _namespace.build_globals()
|
|
479
|
+
g.pop("__bw_playwright_handle__", None)
|
|
480
|
+
g["page"] = self._page
|
|
481
|
+
g["context"] = self._context
|
|
482
|
+
g["state"] = self._state # same dict object each call → persistent
|
|
483
|
+
if self._snapshot is not None:
|
|
484
|
+
g["snapshot"] = self._snapshot
|
|
485
|
+
# Fork 6: `reset()` acts on the executor's LIVE objects (a daemon verb
|
|
486
|
+
# can't reach them), so it is an injected callable, not an RPC. It
|
|
487
|
+
# rebuilds the connection (re-binds the session's current tab) and
|
|
488
|
+
# clears `state`. Use it when the connection broke / the page closed /
|
|
489
|
+
# you want a clean slate.
|
|
490
|
+
g["reset"] = self._reset
|
|
491
|
+
# Output-protocol producers the agent (or helpers) can call to surface a
|
|
492
|
+
# `[WARNING]` line or a screenshot path back through the response.
|
|
493
|
+
g["_bw_warn"] = self._call_warnings.append
|
|
494
|
+
g["_bw_record_screenshot"] = self._record_screenshot
|
|
495
|
+
return g
|
|
496
|
+
|
|
497
|
+
def _record_screenshot(self, path: str, **meta: Any) -> str:
|
|
498
|
+
"""Register a screenshot the heredoc captured so its path surfaces in
|
|
499
|
+
the response's ``screenshots`` block (path-based — the bytes stay on
|
|
500
|
+
disk, shared between executor + client). Returns the path so it can be
|
|
501
|
+
used inline (e.g. ``page.screenshot(path=_bw_record_screenshot(p))``)."""
|
|
502
|
+
block: dict[str, Any] = {"path": str(path)}
|
|
503
|
+
block.update(meta)
|
|
504
|
+
self._call_screenshots.append(block)
|
|
505
|
+
return str(path)
|
|
506
|
+
|
|
507
|
+
def _teardown(self) -> None:
|
|
508
|
+
"""Disconnect the facade transport WITHOUT closing the user's tabs.
|
|
509
|
+
|
|
510
|
+
Mirrors ``PlaywrightHandle.close``: only ``__exit__`` the
|
|
511
|
+
``sync_playwright()`` manager (stops the driver = severs CDP). NEVER
|
|
512
|
+
``page/context/browser.close()`` (would kill the user's real tab / hang
|
|
513
|
+
over the facade)."""
|
|
514
|
+
self._browser = None
|
|
515
|
+
self._context = None
|
|
516
|
+
self._page = None
|
|
517
|
+
if self._pw_cm is not None:
|
|
518
|
+
with _suppress():
|
|
519
|
+
self._pw_cm.__exit__(None, None, None)
|
|
520
|
+
self._pw_cm = None
|
|
521
|
+
self._pw = None
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
class _suppress:
|
|
525
|
+
"""Local ``contextlib.suppress(BaseException)`` clone for teardown paths."""
|
|
526
|
+
|
|
527
|
+
def __enter__(self) -> "_suppress":
|
|
528
|
+
return self
|
|
529
|
+
|
|
530
|
+
def __exit__(self, exc_type, exc, tb) -> bool:
|
|
531
|
+
return exc_type is not None
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _serve(session_id: str, sock: socket.socket, worker: _Worker) -> None:
|
|
535
|
+
"""Accept loop: one request → one response per connection (serial).
|
|
536
|
+
|
|
537
|
+
Connections are handled one at a time on the main thread; the worker thread
|
|
538
|
+
runs the code, so even concurrent clients serialize through the worker queue
|
|
539
|
+
(Fork 3). Each client opens, sends one ExecuteRequest, gets one response.
|
|
540
|
+
|
|
541
|
+
PR2 idle signal: after every served call we touch the discovery file's mtime
|
|
542
|
+
so the daemon's idle-watchdog can tell when this executor last did work
|
|
543
|
+
WITHOUT a separate heartbeat channel — the file already exists (we wrote it
|
|
544
|
+
at startup) and the daemon already reads it. A spawn-time write plus a
|
|
545
|
+
per-call touch gives the watchdog an accurate "last did work" clock (read
|
|
546
|
+
daemon-side via `ExecutorHandle.idle_seconds`)."""
|
|
547
|
+
from ..daemon import _ipc
|
|
548
|
+
|
|
549
|
+
while True:
|
|
550
|
+
try:
|
|
551
|
+
conn, _addr = sock.accept()
|
|
552
|
+
except OSError:
|
|
553
|
+
break
|
|
554
|
+
with conn:
|
|
555
|
+
try:
|
|
556
|
+
msg = recv_message(conn)
|
|
557
|
+
except (ConnectionError, ValueError, OSError):
|
|
558
|
+
continue
|
|
559
|
+
try:
|
|
560
|
+
req = ExecuteRequest.from_dict(msg)
|
|
561
|
+
except ValueError as e:
|
|
562
|
+
send_message(conn, ExecuteResponse(
|
|
563
|
+
error={"type": "ValueError", "msg": str(e)},
|
|
564
|
+
exit_code=3).to_dict())
|
|
565
|
+
continue
|
|
566
|
+
resp = worker.submit(req)
|
|
567
|
+
_touch_discovery(session_id, _ipc)
|
|
568
|
+
try:
|
|
569
|
+
send_message(conn, resp.to_dict())
|
|
570
|
+
except OSError:
|
|
571
|
+
pass
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _touch_discovery(session_id: str, _ipc: Any) -> None:
|
|
575
|
+
"""Bump the discovery file mtime = "this executor just did work" (PR2 idle
|
|
576
|
+
signal read by the daemon idle-watchdog). Best-effort — a failed touch only
|
|
577
|
+
costs us an earlier idle reap, never correctness."""
|
|
578
|
+
try:
|
|
579
|
+
os.utime(_ipc.executor_file_path(session_id), None)
|
|
580
|
+
except OSError:
|
|
581
|
+
pass
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def main(argv: list[str] | None = None) -> int:
|
|
585
|
+
import argparse
|
|
586
|
+
|
|
587
|
+
from ..daemon import _ipc
|
|
588
|
+
|
|
589
|
+
parser = argparse.ArgumentParser(prog="browserwright._executor")
|
|
590
|
+
parser.add_argument("--session", required=True,
|
|
591
|
+
help="the session id this executor serves")
|
|
592
|
+
args = parser.parse_args(argv)
|
|
593
|
+
session_id = args.session
|
|
594
|
+
# Keep the env marker for helper code that still reads it; the --session
|
|
595
|
+
# flag is authoritative for binding.
|
|
596
|
+
os.environ["BD_SESSION"] = session_id
|
|
597
|
+
|
|
598
|
+
worker = _Worker(session_id)
|
|
599
|
+
worker.start()
|
|
600
|
+
|
|
601
|
+
# Bind the socket + publish the discovery file BEFORE any cold-start, so the
|
|
602
|
+
# daemon's `ensureExecutor` (which only waits for the discovery file) returns
|
|
603
|
+
# the moment we are LISTENING — sub-second, off the slow connect's path. The
|
|
604
|
+
# facade cold-start (connect_over_cdp + bind, ~10-35s on a daemon-restart
|
|
605
|
+
# race) is deferred to the worker's first execute (the client's data-plane
|
|
606
|
+
# call absorbs it). This decouples executor READINESS (socket up) from
|
|
607
|
+
# CONNECTEDNESS (browser bound), keeping the keepalive-sensitive control
|
|
608
|
+
# plane fast.
|
|
609
|
+
sock = _ipc.make_executor_socket(session_id)
|
|
610
|
+
_ipc.write_executor_file(
|
|
611
|
+
session_id, str(_ipc.executor_sock_path(session_id)), os.getpid())
|
|
612
|
+
|
|
613
|
+
# Defense-in-depth: clean our own discovery file + socket on SIGTERM (the
|
|
614
|
+
# daemon's `registry.kill` also cleans them daemon-side, but a kill from any
|
|
615
|
+
# path — manual SIGTERM, orphan-sweep — must not leave a stale file the next
|
|
616
|
+
# daemon's `ensureExecutor` could latch onto). We can't run the normal
|
|
617
|
+
# `finally` from a default-SIGTERM-killed process, so install a handler that
|
|
618
|
+
# unlinks the discovery file/socket then hard-exits.
|
|
619
|
+
_install_sigterm_cleanup(session_id)
|
|
620
|
+
|
|
621
|
+
try:
|
|
622
|
+
_serve(session_id, sock, worker)
|
|
623
|
+
finally:
|
|
624
|
+
worker.shutdown()
|
|
625
|
+
with _suppress():
|
|
626
|
+
sock.close()
|
|
627
|
+
_ipc.cleanup_executor(session_id)
|
|
628
|
+
return 0
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def _install_sigterm_cleanup(session_id: str) -> None:
|
|
632
|
+
"""Install a SIGTERM handler that unlinks this executor's discovery file +
|
|
633
|
+
socket before exiting. POSIX-only (no SIGTERM on Windows); best-effort."""
|
|
634
|
+
import signal
|
|
635
|
+
|
|
636
|
+
from ..daemon import _ipc
|
|
637
|
+
|
|
638
|
+
if not hasattr(signal, "SIGTERM"):
|
|
639
|
+
return
|
|
640
|
+
|
|
641
|
+
def _handler(_signum, _frame): # noqa: ANN001
|
|
642
|
+
with _suppress():
|
|
643
|
+
_ipc.cleanup_executor(session_id)
|
|
644
|
+
# Hard-exit: avoid re-entering the (about-to-die) Playwright driver via
|
|
645
|
+
# atexit/finalizers, which can hang. The daemon reaps our pid.
|
|
646
|
+
os._exit(0)
|
|
647
|
+
|
|
648
|
+
try:
|
|
649
|
+
signal.signal(signal.SIGTERM, _handler)
|
|
650
|
+
except (ValueError, OSError):
|
|
651
|
+
# Not on the main thread / unsupported — daemon-side cleanup covers us.
|
|
652
|
+
pass
|