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,434 @@
|
|
|
1
|
+
"""Phase B: daemon-side per-session executor registry + lazy spawn.
|
|
2
|
+
|
|
3
|
+
The daemon is ALREADY a per-session child-process manager (it spawns + tracks +
|
|
4
|
+
SIGTERMs per-session rdp Chrome). The executor is "rdp Chrome v2": same
|
|
5
|
+
supervision contract, a different child binary. PR1 builds only what
|
|
6
|
+
``ensureExecutor`` needs — a registry keyed by ``session_id`` + a single-flight
|
|
7
|
+
spawn guard so two concurrent first-heredocs can't double-spawn. FULL
|
|
8
|
+
supervision (idle reap / endSession kill / crash reap / orphan sweep) is PR2;
|
|
9
|
+
the registry shape here is deliberately the slot PR2 slots into.
|
|
10
|
+
|
|
11
|
+
Lifecycle ownership = the daemon (Fork 1a). Transport ownership = the executor's
|
|
12
|
+
own socket (Fork 2): the daemon only spawns + waits for the discovery file, then
|
|
13
|
+
hands the socket path back to the thin client.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
import signal
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
import threading
|
|
24
|
+
import time
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
|
|
27
|
+
from .. import _ipc
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
# How long to wait for a freshly-spawned executor to bind its socket + write its
|
|
32
|
+
# discovery file. This is now ONLY the process-start + socket-bind window
|
|
33
|
+
# (sub-second in practice) — the executor writes the discovery file BEFORE its
|
|
34
|
+
# slow facade cold-start (connect_over_cdp + bind), which is deferred to the
|
|
35
|
+
# worker's first execute on the data plane. So `ensureExecutor` returns fast and
|
|
36
|
+
# never holds the keepalive-sensitive control-plane RPC open for the connect.
|
|
37
|
+
# Kept generously above the bind window to tolerate a loaded interpreter start.
|
|
38
|
+
_SPAWN_READY_TIMEOUT_S = 15.0
|
|
39
|
+
|
|
40
|
+
# Grace window between SIGTERM and SIGKILL when reaping an executor (mirrors the
|
|
41
|
+
# rdp-Chrome teardown discipline — terminate, then escalate if it won't die).
|
|
42
|
+
_KILL_GRACE_S = 3.0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class ExecutorHandle:
|
|
47
|
+
"""A live per-session executor subprocess. PR2 grows this with idle/crash
|
|
48
|
+
tracking; PR1 only needs the pid + socket path + a spawn lock."""
|
|
49
|
+
|
|
50
|
+
session_id: str
|
|
51
|
+
proc: subprocess.Popen
|
|
52
|
+
sock_path: str
|
|
53
|
+
spawned_at: float = field(default_factory=time.monotonic)
|
|
54
|
+
# Reserved by PR1; the AUTHORITATIVE idle clock is the discovery-file mtime
|
|
55
|
+
# (see `idle_seconds`), because the data plane (Fork 2) bypasses the daemon
|
|
56
|
+
# so the daemon never observes the executes directly. Kept for symmetry /
|
|
57
|
+
# potential daemon-side stamping but NOT consulted by the watchdog.
|
|
58
|
+
last_execute_at: float = field(default_factory=time.monotonic)
|
|
59
|
+
# Wall-clock spawn time, used to floor the discovery-file-mtime idle clock
|
|
60
|
+
# (mtime is wall-clock; spawned_at above is a monotonic clock for races).
|
|
61
|
+
spawned_wall: float = field(default_factory=time.time)
|
|
62
|
+
|
|
63
|
+
def is_alive(self) -> bool:
|
|
64
|
+
return self.proc.poll() is None
|
|
65
|
+
|
|
66
|
+
def idle_seconds(self, *, now: float | None = None) -> float:
|
|
67
|
+
"""Seconds since this executor last did work.
|
|
68
|
+
|
|
69
|
+
The signal is the executor's discovery-file mtime: the executor touches
|
|
70
|
+
it after every served call (`_executor.process._touch_discovery`). We
|
|
71
|
+
read the file rather than tracking activity daemon-side because the data
|
|
72
|
+
plane bypasses the daemon entirely (Fork 2) — the daemon never sees the
|
|
73
|
+
executes, so the file mtime is the cheapest accurate clock. Falls back
|
|
74
|
+
to the spawn time when the file can't be stat'd (treat as just-spawned,
|
|
75
|
+
never prematurely reaped)."""
|
|
76
|
+
now = time.time() if now is None else now
|
|
77
|
+
try:
|
|
78
|
+
mtime = os.path.getmtime(_ipc.executor_file_path(self.session_id))
|
|
79
|
+
except OSError:
|
|
80
|
+
mtime = self.spawned_wall
|
|
81
|
+
return max(0.0, now - max(mtime, self.spawned_wall))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class ExecutorRegistry:
|
|
85
|
+
"""``dict[session_id, ExecutorHandle]`` with single-flight lazy spawn.
|
|
86
|
+
|
|
87
|
+
Mirrors ``Daemon.contexts`` keying. Hung off ``Daemon`` (NOT a holder):
|
|
88
|
+
extension sessions multiplex onto one shared holder, so a per-session
|
|
89
|
+
executor cannot live on the shared holder."""
|
|
90
|
+
|
|
91
|
+
def __init__(self) -> None:
|
|
92
|
+
self._handles: dict[str, ExecutorHandle] = {}
|
|
93
|
+
# One lock per session id guards its spawn (the rdp `_open_lock`
|
|
94
|
+
# equivalent — prevents the double-spawn race, Fork 1 risk).
|
|
95
|
+
self._locks: dict[str, asyncio.Lock] = {}
|
|
96
|
+
|
|
97
|
+
def _lock_for(self, session_id: str) -> asyncio.Lock:
|
|
98
|
+
lock = self._locks.get(session_id)
|
|
99
|
+
if lock is None:
|
|
100
|
+
lock = asyncio.Lock()
|
|
101
|
+
self._locks[session_id] = lock
|
|
102
|
+
return lock
|
|
103
|
+
|
|
104
|
+
async def ensure(self, session_id: str) -> str:
|
|
105
|
+
"""Get-or-spawn the session's executor; return its socket path.
|
|
106
|
+
|
|
107
|
+
Idempotent + single-flight: concurrent callers for the same session
|
|
108
|
+
serialize on the per-session lock, and a live handle short-circuits.
|
|
109
|
+
|
|
110
|
+
Robust to a STALE discovery file (Fork 4 / daemon-restart): a fresh
|
|
111
|
+
daemon has no in-memory handle, but a `bw-exec-*.json` from a prior
|
|
112
|
+
(now-dead) executor may linger on disk. We treat such a file as ABSENT
|
|
113
|
+
— `_spawn` unconditionally `cleanup_executor`s it before launching a
|
|
114
|
+
fresh executor, so a dead pid's stale path can never be handed back."""
|
|
115
|
+
async with self._lock_for(session_id):
|
|
116
|
+
handle = self._handles.get(session_id)
|
|
117
|
+
if handle is not None and handle.is_alive():
|
|
118
|
+
return handle.sock_path
|
|
119
|
+
# Dead handle (crashed) → drop it and cold-spawn a fresh one.
|
|
120
|
+
if handle is not None:
|
|
121
|
+
self._handles.pop(session_id, None)
|
|
122
|
+
# No in-memory handle (e.g. just-restarted daemon): a leftover
|
|
123
|
+
# discovery file whose process is dead is worthless. Validate +
|
|
124
|
+
# purge it so neither this spawn nor the thin client latches onto a
|
|
125
|
+
# dead socket (Fork 4 stale-file robustness).
|
|
126
|
+
if not _discovery_alive(session_id):
|
|
127
|
+
_ipc.cleanup_executor(session_id)
|
|
128
|
+
handle = await self._spawn(session_id)
|
|
129
|
+
self._handles[session_id] = handle
|
|
130
|
+
return handle.sock_path
|
|
131
|
+
|
|
132
|
+
async def _spawn(self, session_id: str) -> ExecutorHandle:
|
|
133
|
+
"""Spawn ``python -m browserwright._executor --session <id>`` detached
|
|
134
|
+
(same ``start_new_session=True`` pattern as launch_chrome) and wait for
|
|
135
|
+
its discovery file."""
|
|
136
|
+
# Clear any stale discovery file from a prior (dead) executor so the
|
|
137
|
+
# readiness wait below can't latch onto it.
|
|
138
|
+
_ipc.cleanup_executor(session_id)
|
|
139
|
+
cmd = [sys.executable, "-m", "browserwright._executor",
|
|
140
|
+
"--session", session_id]
|
|
141
|
+
proc = subprocess.Popen(
|
|
142
|
+
cmd,
|
|
143
|
+
stdout=subprocess.DEVNULL,
|
|
144
|
+
stderr=subprocess.DEVNULL,
|
|
145
|
+
stdin=subprocess.DEVNULL,
|
|
146
|
+
**_spawn_kwargs(),
|
|
147
|
+
)
|
|
148
|
+
sock_path = await self._await_ready(session_id, proc)
|
|
149
|
+
logger.info("spawned executor for session %s (pid=%s)",
|
|
150
|
+
session_id, proc.pid)
|
|
151
|
+
return ExecutorHandle(
|
|
152
|
+
session_id=session_id, proc=proc, sock_path=sock_path)
|
|
153
|
+
|
|
154
|
+
async def _await_ready(self, session_id: str,
|
|
155
|
+
proc: subprocess.Popen) -> str:
|
|
156
|
+
"""Poll the executor's ``_ipc`` discovery file until it appears (or the
|
|
157
|
+
child dies / we time out).
|
|
158
|
+
|
|
159
|
+
The discovery file now signals only that the executor's SOCKET IS
|
|
160
|
+
LISTENING (bound) — NOT that the facade cold-start has completed. The
|
|
161
|
+
executor publishes the file before connecting the facade; the slow
|
|
162
|
+
connect+bind is deferred to the worker's first execute on the data
|
|
163
|
+
plane. So this wait is fast (process start + bind), keeping the
|
|
164
|
+
`ensureExecutor` control-plane RPC well under the daemon keepalive
|
|
165
|
+
window. We still detect a child that dies before binding."""
|
|
166
|
+
deadline = time.monotonic() + _SPAWN_READY_TIMEOUT_S
|
|
167
|
+
while time.monotonic() < deadline:
|
|
168
|
+
if proc.poll() is not None:
|
|
169
|
+
raise RuntimeError(
|
|
170
|
+
f"executor for session {session_id!r} exited during "
|
|
171
|
+
f"cold-start (code={proc.returncode})")
|
|
172
|
+
sock_path, _pid = _ipc.read_executor_file(session_id)
|
|
173
|
+
if sock_path:
|
|
174
|
+
return sock_path
|
|
175
|
+
await asyncio.sleep(0.05)
|
|
176
|
+
# Timed out — kill the stuck child so it doesn't leak.
|
|
177
|
+
try:
|
|
178
|
+
proc.terminate()
|
|
179
|
+
except OSError:
|
|
180
|
+
pass
|
|
181
|
+
raise RuntimeError(
|
|
182
|
+
f"executor for session {session_id!r} never became ready")
|
|
183
|
+
|
|
184
|
+
# ---- introspection --------------------------------------------------
|
|
185
|
+
|
|
186
|
+
def get(self, session_id: str) -> ExecutorHandle | None:
|
|
187
|
+
return self._handles.get(session_id)
|
|
188
|
+
|
|
189
|
+
def all_handles(self) -> list[ExecutorHandle]:
|
|
190
|
+
return list(self._handles.values())
|
|
191
|
+
|
|
192
|
+
# ---- PR2 supervision ------------------------------------------------
|
|
193
|
+
|
|
194
|
+
def kill(self, session_id: str) -> bool:
|
|
195
|
+
"""SIGTERM (escalating to SIGKILL) the session's executor and drop it
|
|
196
|
+
from the registry. Returns True if a handle was found + signalled.
|
|
197
|
+
|
|
198
|
+
The endSession path (rdp + extension symmetric) and the shutdown path
|
|
199
|
+
call this; it mirrors the rdp-Chrome `_kill_rdp_chrome` discipline
|
|
200
|
+
(terminate the detached process group, escalate, never raise)."""
|
|
201
|
+
handle = self._handles.pop(session_id, None)
|
|
202
|
+
if handle is None:
|
|
203
|
+
return False
|
|
204
|
+
_terminate(handle)
|
|
205
|
+
_ipc.cleanup_executor(session_id)
|
|
206
|
+
logger.info("killed executor for session %s (pid=%s)",
|
|
207
|
+
session_id, handle.proc.pid)
|
|
208
|
+
return True
|
|
209
|
+
|
|
210
|
+
def kill_all(self) -> None:
|
|
211
|
+
"""Shutdown path: SIGTERM every registered executor. Mirrors
|
|
212
|
+
`_graceful_shutdown` iterating `all_contexts()`."""
|
|
213
|
+
for session_id in list(self._handles.keys()):
|
|
214
|
+
self.kill(session_id)
|
|
215
|
+
|
|
216
|
+
def reap_dead(self) -> list[str]:
|
|
217
|
+
"""Crash-reap: drop every handle whose child has exited (it died on its
|
|
218
|
+
own — e.g. the Fork-4 facade-death self-exit, or a segfault). Returns
|
|
219
|
+
the session ids dropped. The next `ensure()` for those sessions
|
|
220
|
+
cold-starts a fresh executor — mirrors `_on_upstream_closed` →
|
|
221
|
+
`drop_rdp_context`."""
|
|
222
|
+
dead: list[str] = []
|
|
223
|
+
for session_id, handle in list(self._handles.items()):
|
|
224
|
+
if not handle.is_alive():
|
|
225
|
+
self._handles.pop(session_id, None)
|
|
226
|
+
_ipc.cleanup_executor(session_id)
|
|
227
|
+
dead.append(session_id)
|
|
228
|
+
logger.info("reaped dead executor for session %s (code=%s)",
|
|
229
|
+
session_id, handle.proc.returncode)
|
|
230
|
+
return dead
|
|
231
|
+
|
|
232
|
+
def reap_idle(self, idle_after: float) -> list[str]:
|
|
233
|
+
"""Idle-reap: SIGTERM + drop every executor idle longer than
|
|
234
|
+
`idle_after` seconds. Returns the session ids reaped. Mirrors
|
|
235
|
+
`_idle_watchdog` closing idle upstreams."""
|
|
236
|
+
reaped: list[str] = []
|
|
237
|
+
now = time.time()
|
|
238
|
+
for session_id, handle in list(self._handles.items()):
|
|
239
|
+
if not handle.is_alive():
|
|
240
|
+
continue
|
|
241
|
+
idle_for = handle.idle_seconds(now=now)
|
|
242
|
+
if idle_for >= idle_after:
|
|
243
|
+
logger.info("idle-reap: executor %s idle %.1fs >= %.1fs",
|
|
244
|
+
session_id, idle_for, idle_after)
|
|
245
|
+
self.kill(session_id)
|
|
246
|
+
reaped.append(session_id)
|
|
247
|
+
return reaped
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _pid_alive(pid: int) -> bool:
|
|
251
|
+
"""POSIX liveness probe: ``kill(pid, 0)`` raises if the process is gone."""
|
|
252
|
+
if sys.platform == "win32":
|
|
253
|
+
# No cheap signal-0 probe; conservatively treat as alive so we don't
|
|
254
|
+
# purge a discovery file out from under a live executor on Windows.
|
|
255
|
+
return True
|
|
256
|
+
try:
|
|
257
|
+
os.kill(pid, 0)
|
|
258
|
+
except ProcessLookupError:
|
|
259
|
+
return False
|
|
260
|
+
except PermissionError:
|
|
261
|
+
return True # exists but not ours to signal — still alive
|
|
262
|
+
except OSError:
|
|
263
|
+
return False
|
|
264
|
+
return True
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _discovery_alive(session_id: str) -> bool:
|
|
268
|
+
"""Whether the session's on-disk discovery file names a STILL-LIVE executor.
|
|
269
|
+
|
|
270
|
+
Returns False when the file is absent OR names a dead pid — both cases mean
|
|
271
|
+
the file is stale and must be purged before a fresh spawn (Fork 4). This is
|
|
272
|
+
the robustness guard that keeps `ensureExecutor` from handing the thin
|
|
273
|
+
client a dead socket after a daemon restart."""
|
|
274
|
+
_sock, pid = _ipc.read_executor_file(session_id)
|
|
275
|
+
if pid is None:
|
|
276
|
+
return False
|
|
277
|
+
return _pid_alive(pid)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _spawn_kwargs() -> dict:
|
|
281
|
+
"""Detach the spawn from this process group — mirrors
|
|
282
|
+
``launch_chrome._spawn_kwargs`` so the executor survives independently and
|
|
283
|
+
its pid is ours to reap (PR2)."""
|
|
284
|
+
import platform
|
|
285
|
+
|
|
286
|
+
if platform.system() == "Windows":
|
|
287
|
+
return {
|
|
288
|
+
"creationflags": (
|
|
289
|
+
subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined]
|
|
290
|
+
| subprocess.CREATE_NO_WINDOW # type: ignore[attr-defined]
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
return {"start_new_session": True}
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _terminate(handle: ExecutorHandle) -> None:
|
|
297
|
+
"""SIGTERM the executor's whole process group, escalate to SIGKILL after a
|
|
298
|
+
grace window. Mirrors `listener._kill_rdp_chrome` but signals the GROUP
|
|
299
|
+
(the spawn used `start_new_session=True`, so the executor is a session
|
|
300
|
+
leader — `killpg` reaps any grandchildren too). Best-effort + never raises.
|
|
301
|
+
|
|
302
|
+
The initial SIGTERM is synchronous; the grace-wait + SIGKILL escalation +
|
|
303
|
+
zombie reap run on a short-lived BACKGROUND daemon thread so we NEVER block
|
|
304
|
+
the daemon's asyncio event loop (this is called from `_handle_end_session`,
|
|
305
|
+
`_idle_watchdog`, and `_graceful_shutdown`, all on the loop — unlike
|
|
306
|
+
`_kill_rdp_chrome` which only fire-and-forgets a SIGTERM, we additionally
|
|
307
|
+
guarantee escalation without stalling the loop for the grace window).
|
|
308
|
+
|
|
309
|
+
On Windows there is no process group / SIGTERM; we fall back to the
|
|
310
|
+
Popen.terminate/kill API."""
|
|
311
|
+
proc = handle.proc
|
|
312
|
+
if proc.poll() is not None:
|
|
313
|
+
return # already gone
|
|
314
|
+
pid = proc.pid
|
|
315
|
+
if sys.platform == "win32":
|
|
316
|
+
with _quiet():
|
|
317
|
+
proc.terminate()
|
|
318
|
+
_escalate_async(proc, lambda: proc.kill())
|
|
319
|
+
return
|
|
320
|
+
# POSIX: signal the process group (negative pid) so detached grandchildren
|
|
321
|
+
# die too. Fall back to the bare pid if the group signal isn't permitted.
|
|
322
|
+
with _quiet():
|
|
323
|
+
try:
|
|
324
|
+
os.killpg(os.getpgid(pid), signal.SIGTERM)
|
|
325
|
+
except (ProcessLookupError, PermissionError, OSError):
|
|
326
|
+
proc.terminate()
|
|
327
|
+
_escalate_async(proc, lambda: _killpg_or_kill(proc, signal.SIGKILL))
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _escalate_async(proc: subprocess.Popen, escalate) -> None:
|
|
331
|
+
"""Background-watch a SIGTERMed process: if it doesn't exit within the grace
|
|
332
|
+
window, escalate (SIGKILL), then reap the zombie. Runs on a daemon thread so
|
|
333
|
+
the caller (often the asyncio event loop) returns immediately."""
|
|
334
|
+
t = threading.Thread(
|
|
335
|
+
target=_wait_or_kill, args=(proc, escalate),
|
|
336
|
+
name="bw-executor-reaper", daemon=True)
|
|
337
|
+
t.start()
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _wait_or_kill(proc: subprocess.Popen, escalate) -> None:
|
|
341
|
+
"""Wait up to the grace window for the process to exit; escalate if it
|
|
342
|
+
won't. Runs on a background thread (see `_escalate_async`), so the polling
|
|
343
|
+
`time.sleep` never touches the event loop.
|
|
344
|
+
|
|
345
|
+
After escalating (SIGKILL) we poll a few more times to REAP the zombie — the
|
|
346
|
+
handle is dropped from the registry by the caller, so nothing else will
|
|
347
|
+
`poll()`/`wait()` this pid; without a final reap a SIGKILLed child lingers as
|
|
348
|
+
a zombie until the daemon exits."""
|
|
349
|
+
deadline = time.monotonic() + _KILL_GRACE_S
|
|
350
|
+
while time.monotonic() < deadline:
|
|
351
|
+
if proc.poll() is not None:
|
|
352
|
+
return
|
|
353
|
+
time.sleep(0.05)
|
|
354
|
+
with _quiet():
|
|
355
|
+
escalate()
|
|
356
|
+
# Reap the zombie now that it has been SIGKILLed (bounded short wait).
|
|
357
|
+
reap_deadline = time.monotonic() + 1.0
|
|
358
|
+
while time.monotonic() < reap_deadline:
|
|
359
|
+
if proc.poll() is not None:
|
|
360
|
+
return
|
|
361
|
+
time.sleep(0.02)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _killpg_or_kill(proc: subprocess.Popen, sig: int) -> None:
|
|
365
|
+
try:
|
|
366
|
+
os.killpg(os.getpgid(proc.pid), sig)
|
|
367
|
+
except (ProcessLookupError, PermissionError, OSError):
|
|
368
|
+
proc.kill()
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
class _quiet:
|
|
372
|
+
"""Swallow OS errors from best-effort signal/terminate calls."""
|
|
373
|
+
|
|
374
|
+
def __enter__(self) -> "_quiet":
|
|
375
|
+
return self
|
|
376
|
+
|
|
377
|
+
def __exit__(self, exc_type, exc, tb) -> bool:
|
|
378
|
+
return exc_type is not None and issubclass(exc_type, OSError)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def cleanup_orphan_executors() -> None:
|
|
382
|
+
"""Startup orphan-sweep (mirrors `listener._cleanup_orphan_rdp_chrome`).
|
|
383
|
+
|
|
384
|
+
A hard daemon crash / SIGKILL leaves executor subprocesses running + their
|
|
385
|
+
`bw-exec-*.json` discovery files + `bw-exec-*.sock` sockets on disk. On the
|
|
386
|
+
next daemon start we: read each discovery file, SIGTERM the pid it names (if
|
|
387
|
+
that process is still alive), then unlink the stale socket + discovery file
|
|
388
|
+
so a fresh `ensureExecutor` cold-starts clean.
|
|
389
|
+
|
|
390
|
+
Conservative: we ONLY signal a pid we read from one of OUR discovery files —
|
|
391
|
+
we never scan the system process table. Every step is wrapped so a
|
|
392
|
+
permission error / race never crashes serve."""
|
|
393
|
+
runtime_dir = _ipc._runtime_dir()
|
|
394
|
+
if not runtime_dir.is_dir():
|
|
395
|
+
return
|
|
396
|
+
for entry in runtime_dir.glob("bw-exec-*.json"):
|
|
397
|
+
pid: int | None = None
|
|
398
|
+
try:
|
|
399
|
+
import json
|
|
400
|
+
d = json.loads(entry.read_text())
|
|
401
|
+
raw = d.get("pid")
|
|
402
|
+
if isinstance(raw, int) and 0 < raw < (1 << 31):
|
|
403
|
+
pid = raw
|
|
404
|
+
sock = d.get("sock")
|
|
405
|
+
except (OSError, ValueError, TypeError):
|
|
406
|
+
sock = None
|
|
407
|
+
if pid is not None:
|
|
408
|
+
try:
|
|
409
|
+
# SIGTERM the orphan's process group; it's a session leader.
|
|
410
|
+
try:
|
|
411
|
+
os.killpg(os.getpgid(pid), signal.SIGTERM)
|
|
412
|
+
except (ProcessLookupError, PermissionError, OSError):
|
|
413
|
+
os.kill(pid, signal.SIGTERM)
|
|
414
|
+
logger.info("orphan-cleanup: SIGTERM stray executor pid %d "
|
|
415
|
+
"(%s)", pid, entry.name)
|
|
416
|
+
except (ProcessLookupError, PermissionError, OSError):
|
|
417
|
+
pass
|
|
418
|
+
# Remove the stale discovery file + its socket.
|
|
419
|
+
for p in (entry, entry.with_suffix(".sock"),
|
|
420
|
+
*( [_to_path(sock)] if isinstance(sock, str) else [] )):
|
|
421
|
+
if p is None:
|
|
422
|
+
continue
|
|
423
|
+
try:
|
|
424
|
+
p.unlink()
|
|
425
|
+
except (FileNotFoundError, IsADirectoryError, OSError):
|
|
426
|
+
pass
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _to_path(s: str):
|
|
430
|
+
from pathlib import Path
|
|
431
|
+
try:
|
|
432
|
+
return Path(s)
|
|
433
|
+
except (TypeError, ValueError):
|
|
434
|
+
return None
|