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.
Files changed (17) hide show
  1. {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.1}/PKG-INFO +2 -2
  2. {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.1}/parsek_cdp_server/launcher.py +46 -15
  3. {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.1}/parsek_cdp_server/metrics.py +12 -3
  4. {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.1}/parsek_cdp_server/proxy.py +69 -4
  5. {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.1}/parsek_cdp_server/supervisor.py +10 -0
  6. {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.1}/parsek_cdp_server.egg-info/PKG-INFO +2 -2
  7. {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.1}/parsek_cdp_server.egg-info/requires.txt +1 -1
  8. {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.1}/pyproject.toml +2 -2
  9. {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.1}/LICENSE +0 -0
  10. {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.1}/README.md +0 -0
  11. {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.1}/parsek_cdp_server/__init__.py +0 -0
  12. {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.1}/parsek_cdp_server/py.typed +0 -0
  13. {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.1}/parsek_cdp_server/reaper.py +0 -0
  14. {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.1}/parsek_cdp_server.egg-info/SOURCES.txt +0 -0
  15. {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.1}/parsek_cdp_server.egg-info/dependency_links.txt +0 -0
  16. {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.1}/parsek_cdp_server.egg-info/top_level.txt +0 -0
  17. {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.0
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.0
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 if it lingers."""
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
- try:
278
- proc.send_signal(signal.SIGTERM)
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 ``ProcessLookupError`` (process already gone)."""
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(exc_type, ProcessLookupError)
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 supervised browsers
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 supervised browsers")
205
- browsers.add_metric([], float(len(m._server.supervisors)))
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(ws, supervisor.ws_url)
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(self, client_ws: web.WebSocketResponse, chrome_url: str) -> None:
428
- """Relay raw frames both ways between a client socket and Chrome."""
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.0
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.0
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
@@ -1,4 +1,4 @@
1
- parsek-cdp~=0.1.0
1
+ parsek-cdp~=0.1.1
2
2
  aiohttp>=3.9
3
3
  psutil>=5
4
4
  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.0"
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.0",
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