parsek-cdp-server 0.1.0__tar.gz → 0.1.2__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.2}/PKG-INFO +2 -2
  2. {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.2}/parsek_cdp_server/launcher.py +59 -41
  3. {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.2}/parsek_cdp_server/metrics.py +12 -3
  4. {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.2}/parsek_cdp_server/proxy.py +78 -16
  5. {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.2}/parsek_cdp_server/supervisor.py +10 -0
  6. {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.2}/parsek_cdp_server.egg-info/PKG-INFO +2 -2
  7. {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.2}/parsek_cdp_server.egg-info/requires.txt +1 -1
  8. {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.2}/pyproject.toml +2 -2
  9. {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.2}/LICENSE +0 -0
  10. {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.2}/README.md +0 -0
  11. {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.2}/parsek_cdp_server/__init__.py +0 -0
  12. {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.2}/parsek_cdp_server/py.typed +0 -0
  13. {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.2}/parsek_cdp_server/reaper.py +0 -0
  14. {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.2}/parsek_cdp_server.egg-info/SOURCES.txt +0 -0
  15. {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.2}/parsek_cdp_server.egg-info/dependency_links.txt +0 -0
  16. {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.2}/parsek_cdp_server.egg-info/top_level.txt +0 -0
  17. {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.2}/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.2
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.2
12
12
  Requires-Dist: aiohttp>=3.9
13
13
  Requires-Dist: psutil>=5
14
14
  Requires-Dist: prometheus-client>=0.19
@@ -10,19 +10,19 @@ 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
21
- from dataclasses import dataclass, field
22
21
  from pathlib import Path
23
22
  from typing import List, Optional
24
23
 
25
24
  import psutil
25
+ from parsek_cdp.core.browser import LaunchOptions
26
26
 
27
27
  #: Env var naming *directories* to search for browser binaries,
28
28
  #: ``os.pathsep``-separated (like ``PATH``), e.g. ``/opt/browsers/chrome:/opt/browsers/brave``.
@@ -102,27 +102,26 @@ def _is_browser(filename: str) -> bool:
102
102
 
103
103
 
104
104
  def _find_in_dirs(dirs: List[str]) -> List[str]:
105
- """Recursively collect browser executables found under ``dirs``."""
105
+ """Collect browser executables directly under ``dirs`` (non-recursive)."""
106
106
  found: List[str] = []
107
107
  for directory in dirs:
108
108
  directory = directory.strip()
109
109
  if not directory or not os.path.isdir(directory):
110
110
  continue
111
- for root, _subdirs, files in os.walk(directory):
112
- for filename in files:
113
- if not _is_browser(filename):
114
- continue
115
- full = os.path.join(root, filename)
116
- if os.path.isfile(full) and os.access(full, os.X_OK):
117
- found.append(full)
111
+ for filename in os.listdir(directory):
112
+ if not _is_browser(filename):
113
+ continue
114
+ full = os.path.join(directory, filename)
115
+ if os.path.isfile(full) and os.access(full, os.X_OK):
116
+ found.append(full)
118
117
  return found
119
118
 
120
119
 
121
120
  def _detect_executable() -> str:
122
121
  """Pick a Chromium-based binary, or raise if none is available.
123
122
 
124
- Precedence: a random browser executable found by recursively searching the
125
- directories in :data:`CHROMES_PATH_ENV` (defaulting to :data:`_KNOWN_PATHS`),
123
+ Precedence: a random browser executable found directly in the directories
124
+ listed in :data:`CHROMES_PATH_ENV` (defaulting to :data:`_KNOWN_PATHS`),
126
125
  else the first :data:`_CANDIDATES` name resolvable on ``PATH``.
127
126
  """
128
127
  raw = os.environ.get(CHROMES_PATH_ENV)
@@ -140,18 +139,6 @@ def _detect_executable() -> str:
140
139
  )
141
140
 
142
141
 
143
- @dataclass
144
- class LaunchOptions:
145
- """How to start the browser."""
146
-
147
- executable: Optional[str] = None # autodetect if None
148
- headless: bool = True
149
- port: int = 0
150
- user_data_dir: Optional[str] = None
151
- extra_args: List[str] = field(default_factory=list)
152
- default_args: bool = True
153
-
154
-
155
142
  class ChromeLauncher:
156
143
  """Spawn a browser process and expose its browser-level websocket url.
157
144
 
@@ -173,8 +160,6 @@ class ChromeLauncher:
173
160
  #: Temp profile dir we created (and must clean up); None if caller supplied one.
174
161
  self._owned_user_data_dir: Optional[str] = None
175
162
 
176
- # -- argv -------------------------------------------------------------- #
177
-
178
163
  def _resolve_user_data_dir(self) -> str:
179
164
  if self.options.user_data_dir is not None:
180
165
  return self.options.user_data_dir
@@ -185,7 +170,7 @@ class ChromeLauncher:
185
170
  def _build_argv(self, user_data_dir: str) -> List[str]:
186
171
  executable = self.options.executable or _detect_executable()
187
172
  argv = [executable, f"--remote-debugging-port={self.options.port}"]
188
- if self.options.default_args:
173
+ if self.options.use_default_args:
189
174
  argv += [
190
175
  f"--user-data-dir={user_data_dir}",
191
176
  "--no-first-run",
@@ -271,22 +256,48 @@ class ChromeLauncher:
271
256
  return psutil.pid_exists(self.pid)
272
257
 
273
258
  async def terminate(self, *, timeout: float = 5.0) -> None:
274
- """Terminate the browser process gracefully, then kill if it lingers."""
259
+ """Terminate the browser process tree gracefully, then kill survivors.
260
+
261
+ Chrome frequently re-execs or forks the real browser into a *separate*
262
+ process (headed mode, wrapper launch scripts, the relauncher), after which
263
+ the process we spawned has already exited. Signalling only that direct
264
+ child would then leave the real browser running as an orphan, so we walk
265
+ the whole tree rooted at :attr:`pid` (parent + recursive children) and
266
+ SIGTERM it, escalating to SIGKILL for anything still alive after the grace
267
+ period.
268
+ """
269
+ procs = self._process_tree()
270
+ for p in procs:
271
+ with _suppress_lookup():
272
+ p.terminate() # SIGTERM
273
+ # psutil.wait_procs is blocking, so run it off the event loop.
274
+ _gone, alive = await asyncio.to_thread(
275
+ psutil.wait_procs, procs, timeout=timeout
276
+ )
277
+ for p in alive:
278
+ with _suppress_lookup():
279
+ p.kill() # SIGKILL
280
+ # Reap the asyncio child so its transport doesn't warn about a lost process.
275
281
  proc = self._proc
276
282
  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()
283
+ with contextlib.suppress(ProcessLookupError):
284
+ await proc.wait()
288
285
  self._cleanup_profile()
289
286
 
287
+ def _process_tree(self) -> List[psutil.Process]:
288
+ """The browser process and all its descendants (empty if already gone)."""
289
+ if self.pid is None:
290
+ return []
291
+ try:
292
+ parent = psutil.Process(self.pid)
293
+ except psutil.NoSuchProcess:
294
+ return []
295
+ try:
296
+ children = parent.children(recursive=True)
297
+ except psutil.NoSuchProcess:
298
+ children = []
299
+ return [parent, *children]
300
+
290
301
  def _cleanup_profile(self) -> None:
291
302
  if self._owned_user_data_dir is not None:
292
303
  shutil.rmtree(self._owned_user_data_dir, ignore_errors=True)
@@ -294,10 +305,17 @@ class ChromeLauncher:
294
305
 
295
306
 
296
307
  class _suppress_lookup:
297
- """Context manager swallowing ``ProcessLookupError`` (process already gone)."""
308
+ """Context manager swallowing "process already gone" errors.
309
+
310
+ Covers both :class:`ProcessLookupError` (from ``asyncio``/``os`` signalling)
311
+ and :class:`psutil.NoSuchProcess` (from the process-tree walk), which race
312
+ naturally: a process can exit between being listed and being signalled.
313
+ """
298
314
 
299
315
  def __enter__(self) -> "_suppress_lookup":
300
316
  return self
301
317
 
302
318
  def __exit__(self, exc_type, exc, tb) -> bool:
303
- return exc_type is not None and issubclass(exc_type, ProcessLookupError)
319
+ return exc_type is not None and issubclass(
320
+ exc_type, (ProcessLookupError, psutil.NoSuchProcess)
321
+ )
@@ -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,16 +30,17 @@ 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
+ from parsek_cdp.core.browser import LaunchOptions
38
39
  from parsek_cdp.core.feature import Feature
39
40
  from parsek_cdp.core.target import ProtocolError
41
+ from parsek_cdp.parsek import BrowserState
40
42
  from parsek_cdp.parsek.events import BrowserStateChanged
41
43
 
42
- from .launcher import LaunchOptions
43
44
  from .metrics import ServerMetrics
44
45
  from .reaper import spawn_reaper
45
46
  from .supervisor import BrowserSupervisor
@@ -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()
@@ -329,17 +333,8 @@ class ParsekServer:
329
333
  body = await request.json() if request.can_read_body else {}
330
334
  except Exception:
331
335
  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)
336
+ if body:
337
+ return LaunchOptions.from_json(body)
343
338
  return self.launch_options
344
339
 
345
340
  async def _idle_timeout(self, request: web.Request) -> Optional[float]:
@@ -384,12 +379,47 @@ class ParsekServer:
384
379
  ),
385
380
  )
386
381
  try:
387
- await self._pipe(ws, supervisor.ws_url)
382
+ await self._pipe(
383
+ ws,
384
+ supervisor.ws_url,
385
+ on_client_frame=self._on_control_frame(browser_uuid),
386
+ )
388
387
  finally:
389
388
  clients.discard(ws)
390
389
  supervisor.client_disconnected()
391
390
  return ws
392
391
 
392
+ def _on_control_frame(self, browser_uuid: str):
393
+ """Watch a control channel for the client's ``Browser.close`` request.
394
+
395
+ The frame is still forwarded to the browser (it closes itself gracefully);
396
+ this handler additionally shuts the *supervisor* down so the resulting
397
+ process exit is recorded as an intentional ``CLOSED`` rather than a crash
398
+ to be relaunched.
399
+ """
400
+
401
+ async def handle(data: str) -> None:
402
+ # Cheap substring gate first -- only bother parsing the frame that
403
+ # could actually be the close request, not every control message.
404
+ if "Browser.close" not in data:
405
+ return
406
+ try:
407
+ method = json.loads(data).get("method")
408
+ except Exception:
409
+ return
410
+ if method == "Browser.close":
411
+ await self._graceful_close(browser_uuid)
412
+
413
+ return handle
414
+
415
+ async def _graceful_close(self, browser_uuid: str) -> None:
416
+ """Stop supervising ``browser_uuid`` and terminate it (idempotent)."""
417
+ supervisor = self.supervisors.pop(browser_uuid, None)
418
+ self._browser_features.pop(browser_uuid, None)
419
+ self._control_clients.pop(browser_uuid, None)
420
+ if supervisor is not None:
421
+ await supervisor.shutdown()
422
+
393
423
  async def page_ws(self, request: web.Request) -> web.WebSocketResponse:
394
424
  """``ws /cdp/{browser}/page/{target}``: pipe to a target + aggregation."""
395
425
  browser_uuid = request.match_info["browser"]
@@ -404,8 +434,10 @@ class ParsekServer:
404
434
  feats = self._browser_features.get(browser_uuid, self.features)
405
435
  on_event = None
406
436
  if self.metrics is not None:
437
+
407
438
  def on_event(domain: str) -> None:
408
439
  self.metrics.record_event(browser_uuid, target_id, domain)
440
+
409
441
  bridge = PageBridge(
410
442
  ws,
411
443
  supervisor.target_ws_url(target_id),
@@ -424,8 +456,18 @@ class ParsekServer:
424
456
 
425
457
  # -- helpers ----------------------------------------------------------- #
426
458
 
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."""
459
+ async def _pipe(
460
+ self,
461
+ client_ws: web.WebSocketResponse,
462
+ chrome_url: str,
463
+ on_client_frame: Optional[Callable[[str], Awaitable[None]]] = None,
464
+ ) -> None:
465
+ """Relay raw frames both ways between a client socket and Chrome.
466
+
467
+ ``on_client_frame`` (if given) is invoked with each text frame *after* it
468
+ has been forwarded, letting the caller react to specific commands (e.g.
469
+ ``Browser.close``) without disturbing the passthrough.
470
+ """
429
471
  async with websockets.connect(
430
472
  chrome_url, max_size=None, ping_interval=None
431
473
  ) as chrome:
@@ -436,6 +478,8 @@ class ParsekServer:
436
478
  if msg.type is not web.WSMsgType.TEXT:
437
479
  break
438
480
  await chrome.send(msg.data)
481
+ if on_client_frame is not None:
482
+ await on_client_frame(msg.data)
439
483
  except (websockets.ConnectionClosed, ConnectionError):
440
484
  pass
441
485
 
@@ -453,6 +497,24 @@ class ParsekServer:
453
497
  )
454
498
  await _close_pending(pending)
455
499
 
500
+ def _forget_on_close(self, browser_uuid: str):
501
+ """Drop a browser from the registries once it reaches ``CLOSED``.
502
+
503
+ This is how a self-initiated shutdown -- notably the supervisor's idle
504
+ timeout (:meth:`BrowserSupervisor._close_idle`) -- gets its supervisor
505
+ removed from :attr:`supervisors` (and the sibling registries), without the
506
+ supervisor needing a back-reference to the server. Idempotent, so a
507
+ ``CLOSED`` also driven by :meth:`_graceful_close`/:meth:`stop` is harmless.
508
+ """
509
+
510
+ def callback(state, reason=None) -> None:
511
+ if state is BrowserState.CLOSED:
512
+ self.supervisors.pop(browser_uuid, None)
513
+ self._browser_features.pop(browser_uuid, None)
514
+ self._control_clients.pop(browser_uuid, None)
515
+
516
+ return callback
517
+
456
518
  def _state_broadcaster(self, browser_uuid: str):
457
519
  def callback(state, reason) -> None:
458
520
  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.2
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.2
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.2
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.2"
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.2",
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