parsek-cdp-server 0.1.0__tar.gz → 0.1.1__tar.gz
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-0.1.0 → parsek_cdp_server-0.1.1}/PKG-INFO +2 -2
- {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.1}/parsek_cdp_server/launcher.py +46 -15
- {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.1}/parsek_cdp_server/metrics.py +12 -3
- {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.1}/parsek_cdp_server/proxy.py +69 -4
- {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.1}/parsek_cdp_server/supervisor.py +10 -0
- {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.1}/parsek_cdp_server.egg-info/PKG-INFO +2 -2
- {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.1}/parsek_cdp_server.egg-info/requires.txt +1 -1
- {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.1}/pyproject.toml +2 -2
- {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.1}/LICENSE +0 -0
- {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.1}/README.md +0 -0
- {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.1}/parsek_cdp_server/__init__.py +0 -0
- {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.1}/parsek_cdp_server/py.typed +0 -0
- {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.1}/parsek_cdp_server/reaper.py +0 -0
- {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.1}/parsek_cdp_server.egg-info/SOURCES.txt +0 -0
- {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.1}/parsek_cdp_server.egg-info/dependency_links.txt +0 -0
- {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.1}/parsek_cdp_server.egg-info/top_level.txt +0 -0
- {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.1}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: parsek-cdp-server
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.1
|
|
4
4
|
Summary: Parsek CDP server — launch, supervise and proxy a browser; server-side feature producers.
|
|
5
5
|
Author: xa1era
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -8,7 +8,7 @@ Project-URL: Repository, https://github.com/xa1era/parsek-cdp
|
|
|
8
8
|
Requires-Python: >=3.13
|
|
9
9
|
Description-Content-Type: text/markdown
|
|
10
10
|
License-File: LICENSE
|
|
11
|
-
Requires-Dist: parsek-cdp~=0.1.
|
|
11
|
+
Requires-Dist: parsek-cdp~=0.1.1
|
|
12
12
|
Requires-Dist: aiohttp>=3.9
|
|
13
13
|
Requires-Dist: psutil>=5
|
|
14
14
|
Requires-Dist: prometheus-client>=0.19
|
|
@@ -10,11 +10,11 @@ drives this class.
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
12
|
import asyncio
|
|
13
|
+
import contextlib
|
|
13
14
|
import json
|
|
14
15
|
import os
|
|
15
16
|
import random
|
|
16
17
|
import shutil
|
|
17
|
-
import signal
|
|
18
18
|
import tempfile
|
|
19
19
|
import urllib.error
|
|
20
20
|
import urllib.request
|
|
@@ -271,22 +271,46 @@ class ChromeLauncher:
|
|
|
271
271
|
return psutil.pid_exists(self.pid)
|
|
272
272
|
|
|
273
273
|
async def terminate(self, *, timeout: float = 5.0) -> None:
|
|
274
|
-
"""Terminate the browser process gracefully, then kill
|
|
274
|
+
"""Terminate the browser process tree gracefully, then kill survivors.
|
|
275
|
+
|
|
276
|
+
Chrome frequently re-execs or forks the real browser into a *separate*
|
|
277
|
+
process (headed mode, wrapper launch scripts, the relauncher), after which
|
|
278
|
+
the process we spawned has already exited. Signalling only that direct
|
|
279
|
+
child would then leave the real browser running as an orphan, so we walk
|
|
280
|
+
the whole tree rooted at :attr:`pid` (parent + recursive children) and
|
|
281
|
+
SIGTERM it, escalating to SIGKILL for anything still alive after the grace
|
|
282
|
+
period.
|
|
283
|
+
"""
|
|
284
|
+
procs = self._process_tree()
|
|
285
|
+
for p in procs:
|
|
286
|
+
with _suppress_lookup():
|
|
287
|
+
p.terminate() # SIGTERM
|
|
288
|
+
# psutil.wait_procs is blocking, so run it off the event loop.
|
|
289
|
+
_gone, alive = await asyncio.to_thread(psutil.wait_procs, procs, timeout=timeout)
|
|
290
|
+
for p in alive:
|
|
291
|
+
with _suppress_lookup():
|
|
292
|
+
p.kill() # SIGKILL
|
|
293
|
+
# Reap the asyncio child so its transport doesn't warn about a lost process.
|
|
275
294
|
proc = self._proc
|
|
276
295
|
if proc is not None and proc.returncode is None:
|
|
277
|
-
|
|
278
|
-
proc.
|
|
279
|
-
except ProcessLookupError:
|
|
280
|
-
pass
|
|
281
|
-
else:
|
|
282
|
-
try:
|
|
283
|
-
await asyncio.wait_for(proc.wait(), timeout)
|
|
284
|
-
except asyncio.TimeoutError:
|
|
285
|
-
with _suppress_lookup():
|
|
286
|
-
proc.kill()
|
|
287
|
-
await proc.wait()
|
|
296
|
+
with contextlib.suppress(ProcessLookupError):
|
|
297
|
+
await proc.wait()
|
|
288
298
|
self._cleanup_profile()
|
|
289
299
|
|
|
300
|
+
def _process_tree(self) -> List[psutil.Process]:
|
|
301
|
+
"""The browser process and all its descendants (empty if already gone)."""
|
|
302
|
+
if self.pid is None:
|
|
303
|
+
return []
|
|
304
|
+
try:
|
|
305
|
+
parent = psutil.Process(self.pid)
|
|
306
|
+
except psutil.NoSuchProcess:
|
|
307
|
+
return []
|
|
308
|
+
try:
|
|
309
|
+
children = parent.children(recursive=True)
|
|
310
|
+
except psutil.NoSuchProcess:
|
|
311
|
+
children = []
|
|
312
|
+
return [parent, *children]
|
|
313
|
+
|
|
290
314
|
def _cleanup_profile(self) -> None:
|
|
291
315
|
if self._owned_user_data_dir is not None:
|
|
292
316
|
shutil.rmtree(self._owned_user_data_dir, ignore_errors=True)
|
|
@@ -294,10 +318,17 @@ class ChromeLauncher:
|
|
|
294
318
|
|
|
295
319
|
|
|
296
320
|
class _suppress_lookup:
|
|
297
|
-
"""Context manager swallowing
|
|
321
|
+
"""Context manager swallowing "process already gone" errors.
|
|
322
|
+
|
|
323
|
+
Covers both :class:`ProcessLookupError` (from ``asyncio``/``os`` signalling)
|
|
324
|
+
and :class:`psutil.NoSuchProcess` (from the process-tree walk), which race
|
|
325
|
+
naturally: a process can exit between being listed and being signalled.
|
|
326
|
+
"""
|
|
298
327
|
|
|
299
328
|
def __enter__(self) -> "_suppress_lookup":
|
|
300
329
|
return self
|
|
301
330
|
|
|
302
331
|
def __exit__(self, exc_type, exc, tb) -> bool:
|
|
303
|
-
return exc_type is not None and issubclass(
|
|
332
|
+
return exc_type is not None and issubclass(
|
|
333
|
+
exc_type, (ProcessLookupError, psutil.NoSuchProcess)
|
|
334
|
+
)
|
|
@@ -14,8 +14,12 @@ blocks the event loop:
|
|
|
14
14
|
|
|
15
15
|
Metrics:
|
|
16
16
|
|
|
17
|
+
Only *active* browsers (supervisor state ``READY``) are reported; browsers that
|
|
18
|
+
are still starting, crashed, restarting or closed contribute no series, and any
|
|
19
|
+
event counters they left behind are pruned -- so stale data never lingers.
|
|
20
|
+
|
|
17
21
|
================================== ===== ===========================================
|
|
18
|
-
``parsek_browsers`` gauge
|
|
22
|
+
``parsek_browsers`` gauge active (READY) browsers
|
|
19
23
|
``parsek_targets`` gauge targets, labelled ``browser``, ``type``
|
|
20
24
|
``parsek_nested_targets`` gauge targets with a ``parentId``, per ``browser``
|
|
21
25
|
``parsek_browser_cpu_percent`` gauge CPU% of the browser tree, per ``browser``
|
|
@@ -125,6 +129,11 @@ class ServerMetrics:
|
|
|
125
129
|
snapshot: Dict[str, _BrowserSnapshot] = {}
|
|
126
130
|
live_targets: Set[Tuple[str, str]] = set()
|
|
127
131
|
for browser_uuid, supervisor in list(self._server.supervisors.items()):
|
|
132
|
+
# Only active (READY) browsers contribute metrics; skipping the rest
|
|
133
|
+
# keeps their targets out of ``live_targets`` so their event series
|
|
134
|
+
# are pruned below instead of lingering.
|
|
135
|
+
if not supervisor.is_active:
|
|
136
|
+
continue
|
|
128
137
|
snap = _BrowserSnapshot()
|
|
129
138
|
origin = supervisor.http_origin
|
|
130
139
|
if origin is not None:
|
|
@@ -201,8 +210,8 @@ class _Collector:
|
|
|
201
210
|
m = self._metrics
|
|
202
211
|
snapshot = m._snapshot
|
|
203
212
|
|
|
204
|
-
browsers = GaugeMetricFamily("parsek_browsers", "Number of
|
|
205
|
-
browsers.add_metric([], float(len(
|
|
213
|
+
browsers = GaugeMetricFamily("parsek_browsers", "Number of active (READY) browsers")
|
|
214
|
+
browsers.add_metric([], float(len(snapshot)))
|
|
206
215
|
yield browsers
|
|
207
216
|
|
|
208
217
|
targets = GaugeMetricFamily(
|
|
@@ -30,13 +30,14 @@ import itertools
|
|
|
30
30
|
import json
|
|
31
31
|
import subprocess
|
|
32
32
|
import uuid
|
|
33
|
-
from typing import Callable, Dict, List, Optional, Set, Tuple, Type
|
|
33
|
+
from typing import Awaitable, Callable, Dict, List, Optional, Set, Tuple, Type
|
|
34
34
|
|
|
35
35
|
import websockets
|
|
36
36
|
from aiohttp import web
|
|
37
37
|
from parsek_cdp._logging import get_logger
|
|
38
38
|
from parsek_cdp.core.feature import Feature
|
|
39
39
|
from parsek_cdp.core.target import ProtocolError
|
|
40
|
+
from parsek_cdp.parsek import BrowserState
|
|
40
41
|
from parsek_cdp.parsek.events import BrowserStateChanged
|
|
41
42
|
|
|
42
43
|
from .launcher import LaunchOptions
|
|
@@ -316,6 +317,9 @@ class ParsekServer:
|
|
|
316
317
|
browser_uuid, options=options, idle_timeout=idle_timeout
|
|
317
318
|
)
|
|
318
319
|
supervisor.on_state(self._state_broadcaster(browser_uuid))
|
|
320
|
+
# Registered after the broadcaster so clients still receive the CLOSED
|
|
321
|
+
# frame before the registries are dropped.
|
|
322
|
+
supervisor.on_state(self._forget_on_close(browser_uuid))
|
|
319
323
|
self.supervisors[browser_uuid] = supervisor
|
|
320
324
|
self._browser_features[browser_uuid] = feats
|
|
321
325
|
self._control_clients[browser_uuid] = set()
|
|
@@ -384,12 +388,44 @@ class ParsekServer:
|
|
|
384
388
|
),
|
|
385
389
|
)
|
|
386
390
|
try:
|
|
387
|
-
await self._pipe(
|
|
391
|
+
await self._pipe(
|
|
392
|
+
ws, supervisor.ws_url, on_client_frame=self._on_control_frame(browser_uuid)
|
|
393
|
+
)
|
|
388
394
|
finally:
|
|
389
395
|
clients.discard(ws)
|
|
390
396
|
supervisor.client_disconnected()
|
|
391
397
|
return ws
|
|
392
398
|
|
|
399
|
+
def _on_control_frame(self, browser_uuid: str):
|
|
400
|
+
"""Watch a control channel for the client's ``Browser.close`` request.
|
|
401
|
+
|
|
402
|
+
The frame is still forwarded to the browser (it closes itself gracefully);
|
|
403
|
+
this handler additionally shuts the *supervisor* down so the resulting
|
|
404
|
+
process exit is recorded as an intentional ``CLOSED`` rather than a crash
|
|
405
|
+
to be relaunched.
|
|
406
|
+
"""
|
|
407
|
+
async def handle(data: str) -> None:
|
|
408
|
+
# Cheap substring gate first -- only bother parsing the frame that
|
|
409
|
+
# could actually be the close request, not every control message.
|
|
410
|
+
if "Browser.close" not in data:
|
|
411
|
+
return
|
|
412
|
+
try:
|
|
413
|
+
method = json.loads(data).get("method")
|
|
414
|
+
except Exception:
|
|
415
|
+
return
|
|
416
|
+
if method == "Browser.close":
|
|
417
|
+
await self._graceful_close(browser_uuid)
|
|
418
|
+
|
|
419
|
+
return handle
|
|
420
|
+
|
|
421
|
+
async def _graceful_close(self, browser_uuid: str) -> None:
|
|
422
|
+
"""Stop supervising ``browser_uuid`` and terminate it (idempotent)."""
|
|
423
|
+
supervisor = self.supervisors.pop(browser_uuid, None)
|
|
424
|
+
self._browser_features.pop(browser_uuid, None)
|
|
425
|
+
self._control_clients.pop(browser_uuid, None)
|
|
426
|
+
if supervisor is not None:
|
|
427
|
+
await supervisor.shutdown()
|
|
428
|
+
|
|
393
429
|
async def page_ws(self, request: web.Request) -> web.WebSocketResponse:
|
|
394
430
|
"""``ws /cdp/{browser}/page/{target}``: pipe to a target + aggregation."""
|
|
395
431
|
browser_uuid = request.match_info["browser"]
|
|
@@ -424,8 +460,18 @@ class ParsekServer:
|
|
|
424
460
|
|
|
425
461
|
# -- helpers ----------------------------------------------------------- #
|
|
426
462
|
|
|
427
|
-
async def _pipe(
|
|
428
|
-
|
|
463
|
+
async def _pipe(
|
|
464
|
+
self,
|
|
465
|
+
client_ws: web.WebSocketResponse,
|
|
466
|
+
chrome_url: str,
|
|
467
|
+
on_client_frame: Optional[Callable[[str], Awaitable[None]]] = None,
|
|
468
|
+
) -> None:
|
|
469
|
+
"""Relay raw frames both ways between a client socket and Chrome.
|
|
470
|
+
|
|
471
|
+
``on_client_frame`` (if given) is invoked with each text frame *after* it
|
|
472
|
+
has been forwarded, letting the caller react to specific commands (e.g.
|
|
473
|
+
``Browser.close``) without disturbing the passthrough.
|
|
474
|
+
"""
|
|
429
475
|
async with websockets.connect(
|
|
430
476
|
chrome_url, max_size=None, ping_interval=None
|
|
431
477
|
) as chrome:
|
|
@@ -436,6 +482,8 @@ class ParsekServer:
|
|
|
436
482
|
if msg.type is not web.WSMsgType.TEXT:
|
|
437
483
|
break
|
|
438
484
|
await chrome.send(msg.data)
|
|
485
|
+
if on_client_frame is not None:
|
|
486
|
+
await on_client_frame(msg.data)
|
|
439
487
|
except (websockets.ConnectionClosed, ConnectionError):
|
|
440
488
|
pass
|
|
441
489
|
|
|
@@ -453,6 +501,23 @@ class ParsekServer:
|
|
|
453
501
|
)
|
|
454
502
|
await _close_pending(pending)
|
|
455
503
|
|
|
504
|
+
def _forget_on_close(self, browser_uuid: str):
|
|
505
|
+
"""Drop a browser from the registries once it reaches ``CLOSED``.
|
|
506
|
+
|
|
507
|
+
This is how a self-initiated shutdown -- notably the supervisor's idle
|
|
508
|
+
timeout (:meth:`BrowserSupervisor._close_idle`) -- gets its supervisor
|
|
509
|
+
removed from :attr:`supervisors` (and the sibling registries), without the
|
|
510
|
+
supervisor needing a back-reference to the server. Idempotent, so a
|
|
511
|
+
``CLOSED`` also driven by :meth:`_graceful_close`/:meth:`stop` is harmless.
|
|
512
|
+
"""
|
|
513
|
+
def callback(state, reason=None) -> None:
|
|
514
|
+
if state is BrowserState.CLOSED:
|
|
515
|
+
self.supervisors.pop(browser_uuid, None)
|
|
516
|
+
self._browser_features.pop(browser_uuid, None)
|
|
517
|
+
self._control_clients.pop(browser_uuid, None)
|
|
518
|
+
|
|
519
|
+
return callback
|
|
520
|
+
|
|
456
521
|
def _state_broadcaster(self, browser_uuid: str):
|
|
457
522
|
def callback(state, reason) -> None:
|
|
458
523
|
frame = json.dumps(
|
|
@@ -173,6 +173,16 @@ class BrowserSupervisor:
|
|
|
173
173
|
raise RuntimeError("supervisor not started")
|
|
174
174
|
return f"{self.host}/devtools/page/{target_id}"
|
|
175
175
|
|
|
176
|
+
@property
|
|
177
|
+
def is_active(self) -> bool:
|
|
178
|
+
"""Whether the browser is up and serving (state ``READY``).
|
|
179
|
+
|
|
180
|
+
Metrics report only active browsers, so a starting / crashed / restarting
|
|
181
|
+
/ closed browser contributes no series (and its stale event counters are
|
|
182
|
+
pruned), preventing leftover data from lingering.
|
|
183
|
+
"""
|
|
184
|
+
return self.state is BrowserState.READY
|
|
185
|
+
|
|
176
186
|
@property
|
|
177
187
|
def pid(self) -> Optional[int]:
|
|
178
188
|
"""PID of the supervised browser process (``None`` before launch)."""
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: parsek-cdp-server
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.1
|
|
4
4
|
Summary: Parsek CDP server — launch, supervise and proxy a browser; server-side feature producers.
|
|
5
5
|
Author: xa1era
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -8,7 +8,7 @@ Project-URL: Repository, https://github.com/xa1era/parsek-cdp
|
|
|
8
8
|
Requires-Python: >=3.13
|
|
9
9
|
Description-Content-Type: text/markdown
|
|
10
10
|
License-File: LICENSE
|
|
11
|
-
Requires-Dist: parsek-cdp~=0.1.
|
|
11
|
+
Requires-Dist: parsek-cdp~=0.1.1
|
|
12
12
|
Requires-Dist: aiohttp>=3.9
|
|
13
13
|
Requires-Dist: psutil>=5
|
|
14
14
|
Requires-Dist: prometheus-client>=0.19
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "parsek-cdp-server"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.1"
|
|
8
8
|
description = "Parsek CDP server — launch, supervise and proxy a browser; server-side feature producers."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.13"
|
|
@@ -13,7 +13,7 @@ license = "Apache-2.0"
|
|
|
13
13
|
dependencies = [
|
|
14
14
|
# Reuses the shared layers (cdp/, core/, parsek/) from the client distribution,
|
|
15
15
|
# including internal modules -- so track the client's patch line (>=0.1.0,<0.2.0).
|
|
16
|
-
"parsek-cdp~=0.1.
|
|
16
|
+
"parsek-cdp~=0.1.1",
|
|
17
17
|
"aiohttp>=3.9", # ws + HTTP endpoints (/cdp/{browser}/control, /cdp/{browser}/page/{target}, /browsers)
|
|
18
18
|
"psutil>=5", # watchdog over the Chrome process + zombie reaper
|
|
19
19
|
"prometheus-client>=0.19", # /metrics exposition
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.1}/parsek_cdp_server.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.1}/parsek_cdp_server.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|