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.
- parsek_cdp_server/__init__.py +46 -0
- parsek_cdp_server/launcher.py +303 -0
- parsek_cdp_server/metrics.py +239 -0
- parsek_cdp_server/proxy.py +476 -0
- parsek_cdp_server/py.typed +0 -0
- parsek_cdp_server/reaper.py +173 -0
- parsek_cdp_server/supervisor.py +190 -0
- parsek_cdp_server-0.1.0.dist-info/METADATA +139 -0
- parsek_cdp_server-0.1.0.dist-info/RECORD +12 -0
- parsek_cdp_server-0.1.0.dist-info/WHEEL +5 -0
- parsek_cdp_server-0.1.0.dist-info/licenses/LICENSE +201 -0
- parsek_cdp_server-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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()
|