parsek-cdp-server 0.1.0__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.
@@ -0,0 +1,476 @@
1
+ """The proxy: a transparent CDP pipe between clients and the browser.
2
+
3
+ Each client connection (a parser flow's browser / context / page) gets its own
4
+ websocket, mirroring how the client library connects one socket per target. The
5
+ proxy relays raw CDP frames 1:1 to the browser and back -- it does *not*
6
+ multiplex by ``sessionId`` and does *not* remap message ids, because each pipe
7
+ carries exactly one client's traffic.
8
+
9
+ Two things are *not* pure passthrough:
10
+
11
+ * **lifecycle** -- the control channel also pushes ``Parsek.browserStateChanged``
12
+ so a parser learns when its browser crashed/restarted instead of hanging;
13
+ * **feature aggregation** -- on a page pipe the server-side feature *producers*
14
+ digest the raw CDP burst (e.g. all of ``Network.*``) into a couple of
15
+ aggregated ``Parsek.*`` events and *suppress* the raw events they consumed, so
16
+ the channel is not flooded. ``?raw=1`` opts out of suppression.
17
+
18
+ Endpoints::
19
+
20
+ POST /browsers create a task -> browser_uuid
21
+ GET /metrics Prometheus metrics (browsers/targets/...)
22
+ ws /cdp/{browser}/control pipe to the browser endpoint + Parsek.*
23
+ ws /cdp/{browser}/page/{target_id} pipe to one target + feature aggregation
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import asyncio
29
+ import itertools
30
+ import json
31
+ import subprocess
32
+ import uuid
33
+ from typing import Callable, Dict, List, Optional, Set, Tuple, Type
34
+
35
+ import websockets
36
+ from aiohttp import web
37
+ from parsek_cdp._logging import get_logger
38
+ from parsek_cdp.core.feature import Feature
39
+ from parsek_cdp.core.target import ProtocolError
40
+ from parsek_cdp.parsek.events import BrowserStateChanged
41
+
42
+ from .launcher import LaunchOptions
43
+ from .metrics import ServerMetrics
44
+ from .reaper import spawn_reaper
45
+ from .supervisor import BrowserSupervisor
46
+
47
+ logger = get_logger(__name__)
48
+
49
+ #: Server-injected commands (feature enables, body fetches) get ids from this
50
+ #: range so they never collide with the client's ids on the same socket.
51
+ _SERVER_ID_BASE = 1 << 30
52
+
53
+
54
+ async def _close_pending(pending) -> None:
55
+ for task in pending:
56
+ task.cancel()
57
+ for task in pending:
58
+ try:
59
+ await task
60
+ except asyncio.CancelledError:
61
+ pass
62
+
63
+
64
+ class PageBridge:
65
+ """One client websocket bridged 1:1 to one browser target websocket.
66
+
67
+ Relays raw CDP both ways and hosts the server-side feature producers, which
68
+ aggregate the raw event burst into ``Parsek.*`` events and suppress the raw
69
+ events they digest. Acts as the feature ``host`` (``send_cdp`` /
70
+ ``emit_parsek``), keeping its server-injected commands on a disjoint id range.
71
+ """
72
+
73
+ def __init__(
74
+ self,
75
+ client_ws: web.WebSocketResponse,
76
+ chrome_url: str,
77
+ feature_classes: Tuple[Type[Feature], ...],
78
+ *,
79
+ raw_passthrough: bool = False,
80
+ on_event: Optional[Callable[[str], None]] = None,
81
+ ) -> None:
82
+ self.client_ws = client_ws
83
+ self.chrome_url = chrome_url
84
+ self.feature_classes = feature_classes
85
+ self.raw_passthrough = raw_passthrough
86
+ self._on_event = on_event
87
+ self._chrome: Optional[websockets.ClientConnection] = None
88
+ self._server_ids = itertools.count(_SERVER_ID_BASE)
89
+ self._server_pending: Dict[int, asyncio.Future] = {}
90
+ self._features: List[Feature] = []
91
+ self._suppress: Tuple[str, ...] = ()
92
+
93
+ async def run(self) -> None:
94
+ async with websockets.connect(
95
+ self.chrome_url, max_size=None, ping_interval=None
96
+ ) as chrome:
97
+ self._chrome = chrome
98
+ self._features = [fc(self, role="server") for fc in self.feature_classes]
99
+ self._suppress = tuple(
100
+ prefix
101
+ for fc in self.feature_classes
102
+ for prefix in fc.suppressed_prefixes()
103
+ )
104
+ # The receive loop must run before features enable their domains, so
105
+ # their send_cdp responses can be matched.
106
+ recv = asyncio.ensure_future(self._chrome_to_client())
107
+ for feature in self._features:
108
+ await feature.start()
109
+ send = asyncio.ensure_future(self._client_to_chrome())
110
+ _done, pending = await asyncio.wait(
111
+ {recv, send}, return_when=asyncio.FIRST_COMPLETED
112
+ )
113
+ await _close_pending(pending)
114
+
115
+ # -- feature host interface ------------------------------------------- #
116
+
117
+ async def send_cdp(self, method: str, params: dict) -> dict:
118
+ """Issue a server-originated CDP command and await its result."""
119
+ if self._chrome is None:
120
+ raise ConnectionError("chrome connection not open")
121
+ message_id = next(self._server_ids)
122
+ future: asyncio.Future = asyncio.get_running_loop().create_future()
123
+ self._server_pending[message_id] = future
124
+ await self._chrome.send(
125
+ json.dumps({"id": message_id, "method": method, "params": params})
126
+ )
127
+ return await future
128
+
129
+ async def emit_parsek(
130
+ self, method: str, params: dict, session_id: Optional[str] = None
131
+ ) -> None:
132
+ """Send an aggregated ``Parsek.*`` event to the client."""
133
+ await self._send_client(json.dumps({"method": method, "params": params}))
134
+
135
+ # -- pumps ------------------------------------------------------------- #
136
+
137
+ async def _chrome_to_client(self) -> None:
138
+ assert self._chrome is not None
139
+ try:
140
+ await self._pump_chrome()
141
+ except (websockets.ConnectionClosed, ConnectionError):
142
+ pass # socket dropped -- the other pump tears the bridge down
143
+
144
+ async def _pump_chrome(self) -> None:
145
+ assert self._chrome is not None
146
+ async for raw in self._chrome:
147
+ msg = json.loads(raw)
148
+ if "id" in msg:
149
+ future = self._server_pending.pop(msg["id"], None)
150
+ if future is not None: # our own command -> consume, don't relay
151
+ if not future.done():
152
+ if "error" in msg:
153
+ err = msg["error"]
154
+ future.set_exception(
155
+ ProtocolError(
156
+ err.get("code", -1),
157
+ err.get("message", ""),
158
+ err.get("data"),
159
+ )
160
+ )
161
+ else:
162
+ future.set_result(msg.get("result", {}))
163
+ continue
164
+ await self._send_client(raw) # the client's own response
165
+ continue
166
+ method = msg.get("method", "")
167
+ params = msg.get("params", {})
168
+ if self._on_event and method:
169
+ self._on_event(method.split(".", 1)[0])
170
+ for feature in self._features:
171
+ try:
172
+ await feature.handle_cdp_event(method, params, "")
173
+ except Exception:
174
+ logger.exception("feature failed on %s", method)
175
+ if self.raw_passthrough or not method.startswith(self._suppress):
176
+ await self._send_client(raw)
177
+
178
+ async def _client_to_chrome(self) -> None:
179
+ assert self._chrome is not None
180
+ try:
181
+ async for msg in self.client_ws:
182
+ if msg.type is not web.WSMsgType.TEXT:
183
+ break
184
+ frame = json.loads(msg.data)
185
+ if str(frame.get("method", "")).startswith("Parsek."):
186
+ await self._handle_parsek(frame)
187
+ else:
188
+ await self._chrome.send(msg.data)
189
+ except (websockets.ConnectionClosed, ConnectionError):
190
+ pass
191
+
192
+ async def _handle_parsek(self, frame: dict) -> None:
193
+ """Handle a page-scoped ``Parsek.*`` command locally and reply."""
194
+ method = frame.get("method")
195
+ params = frame.get("params", {})
196
+ result: dict = {}
197
+ if method == "Parsek.setRawPassthrough":
198
+ self.raw_passthrough = bool(params.get("enabled"))
199
+ elif method == "Parsek.getRequests":
200
+ for feature in self._features:
201
+ snap = await feature.snapshot()
202
+ if snap:
203
+ result = snap
204
+ break
205
+ else:
206
+ logger.warning("unhandled Parsek command %r", method)
207
+ if frame.get("id") is not None:
208
+ await self._send_client(json.dumps({"id": frame["id"], "result": result}))
209
+
210
+ async def _send_client(self, data: str) -> None:
211
+ try:
212
+ await self.client_ws.send_str(data)
213
+ except Exception:
214
+ pass
215
+
216
+
217
+ class ParsekServer:
218
+ """The HTTP/ws front door: routes connections to supervisors and bridges."""
219
+
220
+ def __init__(
221
+ self,
222
+ *,
223
+ launch_options: Optional[LaunchOptions] = None,
224
+ idle_timeout: Optional[float] = None,
225
+ enable_metrics: bool = True,
226
+ metrics_refresh: float = 5.0,
227
+ reap_zombies: bool = True,
228
+ reap_interval: float = 30.0,
229
+ ) -> None:
230
+ self.supervisors: Dict[str, BrowserSupervisor] = {}
231
+ #: Feature producers hosted on pages. The proxy is a pure CDP
232
+ #: passthrough by default; register features with :meth:`register_feature`
233
+ #: to make them selectable per browser via ``?feature=``.
234
+ self.features: Tuple[Type[Feature], ...] = ()
235
+ self.launch_options = launch_options
236
+ self.idle_timeout = idle_timeout
237
+ self._feature_registry: Dict[str, Type[Feature]] = {}
238
+ self._browser_features: Dict[str, Tuple[Type[Feature], ...]] = {}
239
+ self._control_clients: Dict[str, Set[web.WebSocketResponse]] = {}
240
+ self._runner: Optional[web.AppRunner] = None
241
+ self.metrics: Optional[ServerMetrics] = (
242
+ ServerMetrics(self, refresh_interval=metrics_refresh)
243
+ if enable_metrics
244
+ else None
245
+ )
246
+ self._reap_zombies = reap_zombies
247
+ self._reap_interval = reap_interval
248
+ self._reaper = None
249
+
250
+ def register_feature(self, feature: Type[Feature]) -> None:
251
+ """Make ``feature`` selectable by clients via ``?feature=<name>``."""
252
+ self._feature_registry[feature.__name__] = feature
253
+
254
+ async def start(self, host: str = "127.0.0.1", port: int = 9333) -> None:
255
+ """Start the aiohttp app serving the endpoints documented above."""
256
+ app = web.Application()
257
+ app.router.add_post("/browsers", self.create_browser)
258
+ app.router.add_get("/cdp/{browser}/control", self.control_ws)
259
+ app.router.add_get("/cdp/{browser}/page/{target}", self.page_ws)
260
+ if self.metrics is not None:
261
+ app.router.add_get("/metrics", self.metrics.handle)
262
+ self._runner = web.AppRunner(app)
263
+ await self._runner.setup()
264
+ await web.TCPSite(self._runner, host, port).start()
265
+ if self.metrics is not None:
266
+ await self.metrics.start()
267
+ if self._reap_zombies:
268
+ self._reaper = spawn_reaper(self._reap_interval)
269
+ logger.info("ParsekServer listening on http://%s:%d", host, port)
270
+
271
+ async def stop(self) -> None:
272
+ """Shut down every supervised browser and the HTTP server."""
273
+ for supervisor in list(self.supervisors.values()):
274
+ await supervisor.shutdown()
275
+ self.supervisors.clear()
276
+ self._browser_features.clear()
277
+ self._control_clients.clear()
278
+ if self.metrics is not None:
279
+ await self.metrics.stop()
280
+ if self._reaper is not None:
281
+ self._reaper.terminate()
282
+ try:
283
+ self._reaper.wait(timeout=5)
284
+ except subprocess.TimeoutExpired:
285
+ self._reaper.kill()
286
+ self._reaper = None
287
+ if self._runner is not None:
288
+ await self._runner.cleanup()
289
+ self._runner = None
290
+
291
+ # -- endpoint handlers ------------------------------------------------- #
292
+
293
+ async def create_browser(self, request: web.Request) -> web.Response:
294
+ """``POST /browsers?feature=...``: spawn a supervisor, return its endpoint.
295
+
296
+ ``?feature=`` query params (repeatable) select which registered features
297
+ are hosted on this browser's pages (none by default -- pure passthrough);
298
+ the JSON body carries the launch settings (``headless``, ``executable``,
299
+ ``extra_args``, ...). Returns ``{browserUuid, wsUrl}`` where ``wsUrl`` is
300
+ the control-channel endpoint.
301
+ """
302
+ names = request.query.getall("feature", [])
303
+ unknown = [n for n in names if n not in self._feature_registry]
304
+ if unknown:
305
+ logger.warning("ignoring unknown features: %s", unknown)
306
+ feats = tuple(
307
+ self._feature_registry[n] for n in names if n in self._feature_registry
308
+ )
309
+ if not feats:
310
+ feats = self.features
311
+
312
+ options = await self._launch_options(request)
313
+ idle_timeout = await self._idle_timeout(request)
314
+ browser_uuid = uuid.uuid4().hex
315
+ supervisor = BrowserSupervisor(
316
+ browser_uuid, options=options, idle_timeout=idle_timeout
317
+ )
318
+ supervisor.on_state(self._state_broadcaster(browser_uuid))
319
+ self.supervisors[browser_uuid] = supervisor
320
+ self._browser_features[browser_uuid] = feats
321
+ self._control_clients[browser_uuid] = set()
322
+ await supervisor.start()
323
+ ws_url = f"ws://{request.host}/cdp/{browser_uuid}/control"
324
+ return web.json_response({"browserUuid": browser_uuid, "wsUrl": ws_url})
325
+
326
+ async def _launch_options(self, request: web.Request) -> Optional[LaunchOptions]:
327
+ """Build :class:`LaunchOptions` from the request body, else the server default."""
328
+ try:
329
+ body = await request.json() if request.can_read_body else {}
330
+ except Exception:
331
+ body = {}
332
+ allowed = {
333
+ "executable",
334
+ "headless",
335
+ "port",
336
+ "user_data_dir",
337
+ "extra_args",
338
+ "default_args",
339
+ }
340
+ kwargs = {k: body[k] for k in allowed if k in body}
341
+ if kwargs:
342
+ return LaunchOptions(**kwargs)
343
+ return self.launch_options
344
+
345
+ async def _idle_timeout(self, request: web.Request) -> Optional[float]:
346
+ """Seconds-with-no-connection before the browser self-closes.
347
+
348
+ Read from the request body's ``idle_timeout`` (falling back to the
349
+ server default); a non-positive or unparseable value disables it.
350
+ """
351
+ try:
352
+ body = await request.json() if request.can_read_body else {}
353
+ except Exception:
354
+ body = {}
355
+ if "idle_timeout" in body:
356
+ try:
357
+ value = float(body["idle_timeout"])
358
+ except (TypeError, ValueError):
359
+ return self.idle_timeout
360
+ return value if value > 0 else None
361
+ return self.idle_timeout
362
+
363
+ async def control_ws(self, request: web.Request) -> web.WebSocketResponse:
364
+ """``ws /cdp/{browser}/control``: pipe to the browser endpoint + state."""
365
+ browser_uuid = request.match_info["browser"]
366
+ supervisor = self.supervisors.get(browser_uuid)
367
+ ws = web.WebSocketResponse(max_msg_size=0)
368
+ await ws.prepare(request)
369
+ if supervisor is None or supervisor.ws_url is None:
370
+ await ws.close(code=4040, message=b"unknown browser")
371
+ return ws
372
+ clients = self._control_clients.setdefault(browser_uuid, set())
373
+ clients.add(ws)
374
+ supervisor.client_connected()
375
+ await self._send(
376
+ ws,
377
+ json.dumps(
378
+ {
379
+ "method": BrowserStateChanged.EVENT_METHOD,
380
+ "params": BrowserStateChanged(
381
+ state=supervisor.state, browser_uuid=browser_uuid
382
+ ).to_json(),
383
+ }
384
+ ),
385
+ )
386
+ try:
387
+ await self._pipe(ws, supervisor.ws_url)
388
+ finally:
389
+ clients.discard(ws)
390
+ supervisor.client_disconnected()
391
+ return ws
392
+
393
+ async def page_ws(self, request: web.Request) -> web.WebSocketResponse:
394
+ """``ws /cdp/{browser}/page/{target}``: pipe to a target + aggregation."""
395
+ browser_uuid = request.match_info["browser"]
396
+ target_id = request.match_info["target"]
397
+ supervisor = self.supervisors.get(browser_uuid)
398
+ ws = web.WebSocketResponse(max_msg_size=0)
399
+ await ws.prepare(request)
400
+ if supervisor is None or supervisor.host is None:
401
+ await ws.close(code=4040, message=b"unknown browser")
402
+ return ws
403
+ raw = request.query.get("raw") in ("1", "true", "yes")
404
+ feats = self._browser_features.get(browser_uuid, self.features)
405
+ on_event = None
406
+ if self.metrics is not None:
407
+ def on_event(domain: str) -> None:
408
+ self.metrics.record_event(browser_uuid, target_id, domain)
409
+ bridge = PageBridge(
410
+ ws,
411
+ supervisor.target_ws_url(target_id),
412
+ feats,
413
+ raw_passthrough=raw,
414
+ on_event=on_event,
415
+ )
416
+ supervisor.client_connected()
417
+ try:
418
+ await bridge.run()
419
+ except Exception:
420
+ logger.exception("page bridge for %s crashed", target_id)
421
+ finally:
422
+ supervisor.client_disconnected()
423
+ return ws
424
+
425
+ # -- helpers ----------------------------------------------------------- #
426
+
427
+ async def _pipe(self, client_ws: web.WebSocketResponse, chrome_url: str) -> None:
428
+ """Relay raw frames both ways between a client socket and Chrome."""
429
+ async with websockets.connect(
430
+ chrome_url, max_size=None, ping_interval=None
431
+ ) as chrome:
432
+
433
+ async def client_to_chrome() -> None:
434
+ try:
435
+ async for msg in client_ws:
436
+ if msg.type is not web.WSMsgType.TEXT:
437
+ break
438
+ await chrome.send(msg.data)
439
+ except (websockets.ConnectionClosed, ConnectionError):
440
+ pass
441
+
442
+ async def chrome_to_client() -> None:
443
+ try:
444
+ async for raw in chrome:
445
+ await self._send(client_ws, raw)
446
+ except (websockets.ConnectionClosed, ConnectionError):
447
+ pass
448
+
449
+ c2b = asyncio.ensure_future(client_to_chrome())
450
+ b2c = asyncio.ensure_future(chrome_to_client())
451
+ _done, pending = await asyncio.wait(
452
+ {c2b, b2c}, return_when=asyncio.FIRST_COMPLETED
453
+ )
454
+ await _close_pending(pending)
455
+
456
+ def _state_broadcaster(self, browser_uuid: str):
457
+ def callback(state, reason) -> None:
458
+ frame = json.dumps(
459
+ {
460
+ "method": BrowserStateChanged.EVENT_METHOD,
461
+ "params": BrowserStateChanged(
462
+ state=state, reason=reason, browser_uuid=browser_uuid
463
+ ).to_json(),
464
+ }
465
+ )
466
+ for ws in list(self._control_clients.get(browser_uuid, ())):
467
+ asyncio.ensure_future(self._send(ws, frame))
468
+
469
+ return callback
470
+
471
+ @staticmethod
472
+ async def _send(ws: web.WebSocketResponse, data: str) -> None:
473
+ try:
474
+ await ws.send_str(data)
475
+ except Exception:
476
+ pass
File without changes
@@ -0,0 +1,173 @@
1
+ """A standalone reaper subprocess that kills leaked Chrome processes.
2
+
3
+ The supervisor terminates the browsers it owns, but a process can still leak:
4
+ a relaunch whose ``terminate()`` raced the crash, a server killed with ``-9``
5
+ before it cleaned up, a renderer subtree the parent never reaped. Those linger
6
+ as **zombies** (defunct, status ``Z``) or **orphans** (reparented to PID 1 once
7
+ their launching server died) and pile up holding RAM and profile dirs.
8
+
9
+ This module is a *separate OS process* (spawned via :func:`multiprocessing`,
10
+ ``daemon=True`` so it dies with the server) that periodically scans the process
11
+ table and reaps those leaks. It is deliberately self-sufficient -- it does not
12
+ talk to the server -- so it only targets processes that are unambiguously
13
+ abandoned:
14
+
15
+ * recognised as **parsek-launched** -- the argv carries
16
+ ``--user-data-dir=<...PROFILE_PREFIX...>`` (see :data:`launcher.PROFILE_PREFIX`),
17
+ so a live, properly-parented browser of ours is never in scope; and
18
+ * **zombie** (``status == Z``) *or* **orphaned** (``ppid == 1``).
19
+
20
+ Run it standalone for ops/debugging::
21
+
22
+ python -m parsek_cdp_server.reaper --interval 30 --once
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import os
29
+ import signal
30
+ import subprocess
31
+ import sys
32
+ import time
33
+ from typing import List
34
+
35
+ import psutil
36
+
37
+ from parsek_cdp._logging import get_logger
38
+ from .launcher import PROFILE_PREFIX, _is_browser
39
+
40
+ logger = get_logger(__name__)
41
+
42
+ #: Default seconds between reap sweeps.
43
+ DEFAULT_INTERVAL = 30.0
44
+
45
+
46
+ def _is_parsek_chrome(proc: psutil.Process) -> bool:
47
+ """Whether ``proc`` is a browser *we* launched (matched by the profile marker)."""
48
+ try:
49
+ if not _is_browser(proc.name()):
50
+ return False
51
+ return any(
52
+ arg.startswith("--user-data-dir=") and PROFILE_PREFIX in arg
53
+ for arg in proc.cmdline()
54
+ )
55
+ except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
56
+ return False
57
+
58
+
59
+ def _is_abandoned(proc: psutil.Process) -> bool:
60
+ """A parsek browser is abandoned if it is defunct or reparented to init."""
61
+ try:
62
+ if proc.status() == psutil.STATUS_ZOMBIE:
63
+ return True
64
+ return proc.ppid() == 1
65
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
66
+ return False
67
+ except psutil.ZombieProcess:
68
+ return True
69
+
70
+
71
+ def find_leaked_chromes() -> List[psutil.Process]:
72
+ """Return the parsek-launched browser processes that are zombies or orphans."""
73
+ leaked: List[psutil.Process] = []
74
+ for proc in psutil.process_iter():
75
+ if _is_parsek_chrome(proc) and _is_abandoned(proc):
76
+ leaked.append(proc)
77
+ return leaked
78
+
79
+
80
+ def _kill_tree(proc: psutil.Process) -> int:
81
+ """SIGKILL ``proc`` and its descendants; return how many we signalled.
82
+
83
+ Zombies cannot be killed (only reaped by their parent), but ``waitpid`` on a
84
+ child of ours clears it; for orphans, init reaps them once we kill the tree.
85
+ """
86
+ killed = 0
87
+ try:
88
+ victims = proc.children(recursive=True) + [proc]
89
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
90
+ victims = [proc]
91
+ for victim in victims:
92
+ try:
93
+ victim.kill()
94
+ killed += 1
95
+ except psutil.NoSuchProcess:
96
+ pass
97
+ except psutil.AccessDenied:
98
+ logger.warning("reaper: access denied killing pid %s", victim.pid)
99
+ # Reap any of our own now-dead children so they do not become zombies.
100
+ try:
101
+ os.waitpid(proc.pid, os.WNOHANG)
102
+ except (ChildProcessError, OSError):
103
+ pass
104
+ return killed
105
+
106
+
107
+ def reap_once() -> int:
108
+ """Run one sweep; return the number of processes signalled."""
109
+ total = 0
110
+ for proc in find_leaked_chromes():
111
+ pid = proc.pid
112
+ n = _kill_tree(proc)
113
+ if n:
114
+ logger.info("reaper: killed leaked chrome tree (root pid %s, %d procs)", pid, n)
115
+ total += n
116
+ return total
117
+
118
+
119
+ def run_reaper(interval: float = DEFAULT_INTERVAL) -> None:
120
+ """Reap loop for the subprocess: sweep, sleep, repeat until signalled."""
121
+ stop = {"flag": False}
122
+
123
+ def _handle(_signum, _frame) -> None:
124
+ stop["flag"] = True
125
+
126
+ signal.signal(signal.SIGTERM, _handle)
127
+ signal.signal(signal.SIGINT, _handle)
128
+ logger.info("chrome reaper started (interval=%.1fs, pid=%s)", interval, os.getpid())
129
+ while not stop["flag"]:
130
+ try:
131
+ reap_once()
132
+ except Exception:
133
+ logger.exception("reaper sweep failed")
134
+ # Sleep in small slices so a SIGTERM is honoured promptly.
135
+ slept = 0.0
136
+ while slept < interval and not stop["flag"]:
137
+ time.sleep(min(0.5, interval - slept))
138
+ slept += 0.5
139
+ logger.info("chrome reaper stopped")
140
+
141
+
142
+ def spawn_reaper(interval: float = DEFAULT_INTERVAL) -> subprocess.Popen:
143
+ """Launch the reaper as a separate ``python -m`` subprocess and return it.
144
+
145
+ A real subprocess (rather than a fork) keeps the reaper independent of the
146
+ server's event loop and avoids the spawn/forkserver re-import of the entry
147
+ module. The parent's ``sys.path`` is forwarded via ``PYTHONPATH`` so it also
148
+ works from a source checkout, not just an installed package.
149
+ """
150
+ env = dict(os.environ)
151
+ env["PYTHONPATH"] = os.pathsep.join(p for p in sys.path if p)
152
+ # ``-c`` rather than ``-m`` so importing the package (which pulls in this
153
+ # module) before execution does not trigger runpy's double-import warning.
154
+ code = f"from parsek_cdp_server.reaper import run_reaper; run_reaper({float(interval)!r})"
155
+ return subprocess.Popen([sys.executable, "-c", code], env=env)
156
+
157
+
158
+ def _main() -> None:
159
+ parser = argparse.ArgumentParser(description="Reap leaked parsek Chrome processes.")
160
+ parser.add_argument("--interval", type=float, default=DEFAULT_INTERVAL,
161
+ help="seconds between sweeps (loop mode)")
162
+ parser.add_argument("--once", action="store_true",
163
+ help="run a single sweep and exit")
164
+ args = parser.parse_args()
165
+ if args.once:
166
+ killed = reap_once()
167
+ print(f"reaped {killed} process(es)")
168
+ else:
169
+ run_reaper(args.interval)
170
+
171
+
172
+ if __name__ == "__main__":
173
+ _main()