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,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