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.
Files changed (98) hide show
  1. browserwright/__init__.py +33 -0
  2. browserwright/__main__.py +6 -0
  3. browserwright/_executor/__init__.py +47 -0
  4. browserwright/_executor/__main__.py +9 -0
  5. browserwright/_executor/client.py +127 -0
  6. browserwright/_executor/process.py +652 -0
  7. browserwright/_executor/protocol.py +152 -0
  8. browserwright/api.py +66 -0
  9. browserwright/cdp.py +285 -0
  10. browserwright/cli.py +741 -0
  11. browserwright/daemon/__init__.py +8 -0
  12. browserwright/daemon/_ipc.py +444 -0
  13. browserwright/daemon/active_tab.py +183 -0
  14. browserwright/daemon/auth.py +395 -0
  15. browserwright/daemon/backends/__init__.py +59 -0
  16. browserwright/daemon/backends/base.py +120 -0
  17. browserwright/daemon/backends/cloud.py +222 -0
  18. browserwright/daemon/backends/env.py +119 -0
  19. browserwright/daemon/backends/extension.py +185 -0
  20. browserwright/daemon/backends/rdp.py +214 -0
  21. browserwright/daemon/cli.py +1437 -0
  22. browserwright/daemon/config.py +380 -0
  23. browserwright/daemon/doctor.py +179 -0
  24. browserwright/daemon/errors.py +34 -0
  25. browserwright/daemon/launch_chrome.py +353 -0
  26. browserwright/daemon/observability.py +181 -0
  27. browserwright/daemon/platforms.py +234 -0
  28. browserwright/daemon/resolver.py +72 -0
  29. browserwright/daemon/server/__init__.py +6 -0
  30. browserwright/daemon/server/daemon.py +229 -0
  31. browserwright/daemon/server/executor_registry.py +434 -0
  32. browserwright/daemon/server/extension_upstream.py +677 -0
  33. browserwright/daemon/server/facade.py +375 -0
  34. browserwright/daemon/server/facade_extension.py +969 -0
  35. browserwright/daemon/server/listener.py +1058 -0
  36. browserwright/daemon/server/proxy.py +1991 -0
  37. browserwright/daemon/server/relay.py +783 -0
  38. browserwright/daemon/server/state.py +432 -0
  39. browserwright/daemon/server/upstream.py +266 -0
  40. browserwright/daemon/userscripts.py +150 -0
  41. browserwright/discovery.py +213 -0
  42. browserwright/errors.py +177 -0
  43. browserwright/health.py +169 -0
  44. browserwright/install.py +628 -0
  45. browserwright/memory/__init__.py +15 -0
  46. browserwright/memory/_md.py +120 -0
  47. browserwright/memory/_yaml.py +217 -0
  48. browserwright/memory/global_mem.py +201 -0
  49. browserwright/memory/repl_mem.py +28 -0
  50. browserwright/memory/session_decisions.py +53 -0
  51. browserwright/memory/site_mem.py +381 -0
  52. browserwright/mode_b_client.py +590 -0
  53. browserwright/multitask.py +131 -0
  54. browserwright/output_schema.py +99 -0
  55. browserwright/primitives/__init__.py +67 -0
  56. browserwright/primitives/discovery_api.py +79 -0
  57. browserwright/primitives/http.py +42 -0
  58. browserwright/primitives/inspect.py +876 -0
  59. browserwright/primitives/interact.py +518 -0
  60. browserwright/primitives/page.py +556 -0
  61. browserwright/primitives/site.py +143 -0
  62. browserwright/release_install.py +466 -0
  63. browserwright/repl/__init__.py +6 -0
  64. browserwright/repl/_namespace.py +106 -0
  65. browserwright/repl/_smart_goto.py +236 -0
  66. browserwright/repl/inline.py +180 -0
  67. browserwright/repl/playwright_handle.py +449 -0
  68. browserwright/repl/snapshot.py +150 -0
  69. browserwright/session.py +229 -0
  70. browserwright/session_create.py +252 -0
  71. browserwright/session_ctx.py +24 -0
  72. browserwright/session_registry.py +133 -0
  73. browserwright/session_runtime.py +133 -0
  74. browserwright/site_skills_starter/github.com/SKILL.md +14 -0
  75. browserwright/site_skills_starter/github.com/memory.md +29 -0
  76. browserwright/site_skills_starter/github.com/tasks/list_issues.py +55 -0
  77. browserwright/site_skills_starter/google.com/SKILL.md +16 -0
  78. browserwright/site_skills_starter/google.com/memory.md +27 -0
  79. browserwright/site_skills_starter/google.com/tasks/search.py +53 -0
  80. browserwright/site_skills_starter/producthunt.com/SKILL.md +7 -0
  81. browserwright/site_skills_starter/producthunt.com/memory.md +26 -0
  82. browserwright/site_skills_starter/producthunt.com/tasks/today.py +64 -0
  83. browserwright/site_skills_starter/wikipedia.org/SKILL.md +7 -0
  84. browserwright/site_skills_starter/wikipedia.org/memory.md +22 -0
  85. browserwright/site_skills_starter/wikipedia.org/tasks/lookup.py +55 -0
  86. browserwright/site_skills_starter/ycombinator.com/SKILL.md +8 -0
  87. browserwright/site_skills_starter/ycombinator.com/memory.md +25 -0
  88. browserwright/site_skills_starter/ycombinator.com/tasks/front_page.py +63 -0
  89. browserwright/skill_doc.py +140 -0
  90. browserwright/skill_runtime.md +194 -0
  91. browserwright/subscriptions.py +213 -0
  92. browserwright/task_runner.py +125 -0
  93. browserwright/version.py +117 -0
  94. browserwright-0.6.2.dist-info/METADATA +12 -0
  95. browserwright-0.6.2.dist-info/RECORD +98 -0
  96. browserwright-0.6.2.dist-info/WHEEL +5 -0
  97. browserwright-0.6.2.dist-info/entry_points.txt +3 -0
  98. 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