wpr-runtime-core 0.2.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,867 @@
1
+ """Relocatable sync/async client for one real Gecko Worker session."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import os
9
+ import shutil
10
+ import subprocess
11
+ import tempfile
12
+ import time
13
+ import uuid
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import Any
17
+ from urllib.parse import urlsplit
18
+
19
+ from ._leases import DeferredBlobReleases
20
+ from .installer import configured_runtime_home
21
+ from .pipe import JsonLinePipe, request
22
+
23
+ PACKAGE_ROOT = Path(__file__).resolve().parents[2]
24
+ _logger = logging.getLogger("wpr_runtime_core.session")
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class RuntimeConfig:
29
+ """Runtime paths resolved from the bundle, not from a developer checkout."""
30
+
31
+ package_root: Path
32
+ runtime_dir: Path
33
+ worker_exe: Path
34
+ network_worker_exe: Path | None = None
35
+ profile_root: Path | None = None
36
+ log_root: Path | None = None
37
+ fpfile: Path | None = None
38
+ proxy: str | None = None
39
+ webrtc_permission: str = "allow"
40
+ webrtc_allowed_origins: tuple[str, ...] | None = None
41
+
42
+ def __post_init__(self) -> None:
43
+ if self.webrtc_permission not in {"allow", "deny", "observe"}:
44
+ raise ValueError("webrtc_permission must be allow, deny, or observe")
45
+ if self.webrtc_allowed_origins is not None:
46
+ if not isinstance(self.webrtc_allowed_origins, tuple):
47
+ raise TypeError(
48
+ "webrtc_allowed_origins must be a tuple of HTTP(S) origins"
49
+ )
50
+ for origin in self.webrtc_allowed_origins:
51
+ if not isinstance(origin, str):
52
+ raise ValueError("webrtc_allowed_origins requires HTTP(S) origins") # noqa: TRY004
53
+ parsed = urlsplit(origin)
54
+ if (
55
+ parsed.scheme not in {"http", "https"}
56
+ or not parsed.hostname
57
+ or parsed.username is not None
58
+ or parsed.path
59
+ or parsed.query
60
+ or parsed.fragment
61
+ or origin != self._origin(parsed)
62
+ ):
63
+ raise ValueError(
64
+ "webrtc_allowed_origins requires canonical HTTP(S) origins without paths"
65
+ )
66
+
67
+ @staticmethod
68
+ def _origin(parsed: Any) -> str:
69
+ host = parsed.hostname or ""
70
+ if ":" in host:
71
+ host = "[" + host + "]"
72
+ port = parsed.port
73
+ if port is not None and port != (443 if parsed.scheme == "https" else 80):
74
+ host += ":" + str(port)
75
+ return parsed.scheme + "://" + host
76
+
77
+ @classmethod
78
+ def bundled(cls) -> RuntimeConfig:
79
+ configured = configured_runtime_home()
80
+ root = Path(
81
+ os.environ.get("WPR_RUNTIME_HOME")
82
+ or (str(configured) if configured else str(PACKAGE_ROOT))
83
+ ).resolve()
84
+ runtime = Path(os.environ.get("WPR_RUNTIME_DIR", root / "runtime")).resolve()
85
+ worker = Path(
86
+ os.environ.get("WPR_WORKER_EXE", runtime / "gecko-web-worker.exe")
87
+ ).resolve()
88
+ network_worker = Path(
89
+ os.environ.get(
90
+ "WPR_NETWORK_WORKER_EXE",
91
+ runtime / "net-v155" / "gecko-net-worker.exe",
92
+ )
93
+ ).resolve()
94
+ profile = os.environ.get("WPR_PROFILE_ROOT")
95
+ logs = os.environ.get("WPR_LOG_ROOT")
96
+ fpfile = os.environ.get("WPR_FPFILE")
97
+ proxy = os.environ.get("WPR_PROXY")
98
+ origins = os.environ.get("WPR_WEBRTC_ALLOWED_ORIGINS")
99
+ allowed_origins = json.loads(origins) if origins is not None else None
100
+ if allowed_origins is not None and not isinstance(allowed_origins, list):
101
+ raise ValueError("WPR_WEBRTC_ALLOWED_ORIGINS must be a JSON array or null")
102
+ return cls(
103
+ package_root=root,
104
+ runtime_dir=runtime,
105
+ worker_exe=worker,
106
+ network_worker_exe=network_worker,
107
+ profile_root=Path(profile).resolve() if profile else None,
108
+ log_root=Path(logs).resolve() if logs else None,
109
+ fpfile=Path(fpfile).resolve() if fpfile else None,
110
+ proxy=proxy,
111
+ webrtc_permission=os.environ.get("WPR_WEBRTC_PERMISSION", "allow"),
112
+ webrtc_allowed_origins=tuple(allowed_origins)
113
+ if allowed_origins is not None
114
+ else None,
115
+ )
116
+
117
+ def validate(self, *, network_only: bool = False) -> None:
118
+ if os.name != "nt":
119
+ raise OSError("wpr-runtime-155-win64 requires Windows")
120
+ worker = self.network_worker_exe if network_only else self.worker_exe
121
+ if worker is None or not worker.is_file():
122
+ label = "network worker" if network_only else "worker"
123
+ raise FileNotFoundError(f"{label} executable not found: {worker}")
124
+
125
+
126
+ class WprProtocolError(RuntimeError):
127
+ def __init__(self, method: str, response: dict[str, Any]):
128
+ error = response.get("error")
129
+ if isinstance(error, dict):
130
+ self.code = str(error.get("code", "UNKNOWN_ERROR"))
131
+ self.phase = str(error.get("phase", "unknown"))
132
+ message = str(error.get("message", self.code))
133
+ else:
134
+ # Older/minimal Worker responses use a string error code. Keep it
135
+ # as the public code instead of hiding useful diagnostics behind
136
+ # UNKNOWN_ERROR (for example stale_frame_handle).
137
+ self.code = str(error or "UNKNOWN_ERROR")
138
+ self.phase = "unknown"
139
+ message = str(error or "request failed")
140
+ self.method = method
141
+ self.response = response
142
+ self.rv = error.get("rv") if isinstance(error, dict) else None
143
+ self.details = error.get("details", {}) if isinstance(error, dict) else {}
144
+ super().__init__(f"{method} [{self.code}] {message}")
145
+
146
+
147
+ def require_ok(method: str, response: dict[str, Any]) -> Any:
148
+ if not response.get("ok"):
149
+ raise WprProtocolError(method, response)
150
+ # Preserve protocol values exactly. In particular, an empty list is the
151
+ # valid result for page.permission_requests() when no native request is
152
+ # pending; coercing false-y values to {} changes the public data model.
153
+ return response.get("value", {})
154
+
155
+
156
+ class WorkerSession:
157
+ """One Worker process, profile and transport lifetime."""
158
+
159
+ def __init__(
160
+ self,
161
+ config: RuntimeConfig | None = None,
162
+ fpfile: str | Path | None = None,
163
+ proxy: str | None = None,
164
+ *,
165
+ network_only: bool = False,
166
+ pipe_mode: str | None = None,
167
+ network_channels: int = 1,
168
+ ):
169
+ self.config = config or RuntimeConfig.bundled()
170
+ self.network_only = bool(network_only)
171
+ self.fpfile = (
172
+ Path(fpfile).resolve() if fpfile is not None else self.config.fpfile
173
+ )
174
+ self.proxy = proxy if proxy is not None else self.config.proxy
175
+ if self.fpfile is not None and not self.fpfile.is_file():
176
+ raise FileNotFoundError(f"fpfile not found: {self.fpfile}")
177
+ self.config.validate(network_only=self.network_only)
178
+ self.worker_exe = (
179
+ self.config.network_worker_exe
180
+ if self.network_only
181
+ else self.config.worker_exe
182
+ )
183
+ assert self.worker_exe is not None
184
+ self.session_id = uuid.uuid4().hex[:12]
185
+ self.pipe_name = rf"\\.\pipe\wpr-gecko-{self.session_id}"
186
+ self.pipe_mode = (
187
+ pipe_mode or os.environ.get("WPR_PIPE_MODE", "rpc")
188
+ if self.network_only
189
+ else "rpc"
190
+ )
191
+ if self.pipe_mode not in {"rpc", "persistent"}:
192
+ raise ValueError("WPR_PIPE_MODE must be 'rpc' or 'persistent'")
193
+ self._persistent_rpc = (
194
+ JsonLinePipe(self.pipe_name, persistent=True)
195
+ if self.network_only and self.pipe_mode == "persistent"
196
+ else None
197
+ )
198
+ profile_parent = self.config.profile_root
199
+ if profile_parent:
200
+ profile_parent.mkdir(parents=True, exist_ok=True)
201
+ else:
202
+ profile_parent = Path(tempfile.gettempdir())
203
+ self.profile_dir = Path(
204
+ tempfile.mkdtemp(
205
+ prefix=f"wpr-profile-{self.session_id}-", dir=profile_parent
206
+ )
207
+ )
208
+ (self.profile_dir / "local").mkdir(parents=True, exist_ok=True)
209
+ # GPU/WebGPU A/B runs must set these prefs before Gecko loads the
210
+ # profile and initializes the blocklist. Setting them later from the
211
+ # parent JS runtime is too late for StaticPrefs/graphics startup.
212
+ if os.environ.get("WPR_ENABLE_GPU_PATH") == "1":
213
+ gpu_prefs = [
214
+ 'user_pref("layers.gpu-process.enabled", true);',
215
+ 'user_pref("dom.webgpu.enabled", true);',
216
+ ]
217
+ if os.environ.get("WPR_IGNORE_GPU_BLOCKLIST") == "1":
218
+ gpu_prefs.append('user_pref("gfx.webgpu.ignore-blocklist", true);')
219
+ extra_prefs = []
220
+ nursery_max_kb = os.environ.get("WPR_JS_NURSERY_MAX_KB")
221
+ if nursery_max_kb and nursery_max_kb.isdigit():
222
+ extra_prefs.append(
223
+ f'user_pref("javascript.options.mem.nursery.max_kb", {int(nursery_max_kb)});'
224
+ )
225
+ debugger_pref = os.environ.get("WPR_PAGE_DEBUGGER")
226
+ if debugger_pref in {"0", "1"}:
227
+ extra_prefs.append(
228
+ 'user_pref("wpr.page_control.use_debugger", '
229
+ + ("true" if debugger_pref == "1" else "false")
230
+ + ");"
231
+ )
232
+ jit_mode = os.environ.get("WPR_JS_JIT_MODE")
233
+ if jit_mode == "baseline-off":
234
+ extra_prefs.append(
235
+ 'user_pref("javascript.options.baselinejit", false);'
236
+ )
237
+ elif jit_mode == "ion-off":
238
+ extra_prefs.append('user_pref("javascript.options.ion", false);')
239
+ elif jit_mode == "all-off":
240
+ extra_prefs.extend(
241
+ [
242
+ 'user_pref("javascript.options.baselinejit", false);',
243
+ 'user_pref("javascript.options.ion", false);',
244
+ ]
245
+ )
246
+ keepalive = os.environ.get("WPR_KEEP_WEB_PROCESSES_ALIVE")
247
+ if keepalive in {"0", "1"}:
248
+ extra_prefs.append(
249
+ f'user_pref("dom.ipc.keepProcessesAlive.web", {keepalive});'
250
+ )
251
+ (self.profile_dir / "user.js").write_text(
252
+ "\n".join(gpu_prefs + extra_prefs) + "\n", encoding="utf-8"
253
+ )
254
+ elif (
255
+ os.environ.get("WPR_JS_NURSERY_MAX_KB", "").isdigit()
256
+ or os.environ.get("WPR_PAGE_DEBUGGER") in {"0", "1"}
257
+ or os.environ.get("WPR_JS_JIT_MODE")
258
+ in {"baseline-off", "ion-off", "all-off"}
259
+ or os.environ.get("WPR_KEEP_WEB_PROCESSES_ALIVE") in {"0", "1"}
260
+ ):
261
+ prefs = []
262
+ if os.environ.get("WPR_JS_NURSERY_MAX_KB", "").isdigit():
263
+ prefs.append(
264
+ 'user_pref("javascript.options.mem.nursery.max_kb", '
265
+ f"{int(os.environ['WPR_JS_NURSERY_MAX_KB'])});"
266
+ )
267
+ debugger_pref = os.environ.get("WPR_PAGE_DEBUGGER")
268
+ if debugger_pref in {"0", "1"}:
269
+ prefs.append(
270
+ 'user_pref("wpr.page_control.use_debugger", '
271
+ + ("true" if debugger_pref == "1" else "false")
272
+ + ");"
273
+ )
274
+ jit_mode = os.environ.get("WPR_JS_JIT_MODE")
275
+ if jit_mode == "baseline-off":
276
+ prefs.append('user_pref("javascript.options.baselinejit", false);')
277
+ elif jit_mode == "ion-off":
278
+ prefs.append('user_pref("javascript.options.ion", false);')
279
+ elif jit_mode == "all-off":
280
+ prefs.extend(
281
+ [
282
+ 'user_pref("javascript.options.baselinejit", false);',
283
+ 'user_pref("javascript.options.ion", false);',
284
+ ]
285
+ )
286
+ keepalive = os.environ.get("WPR_KEEP_WEB_PROCESSES_ALIVE")
287
+ if keepalive in {"0", "1"}:
288
+ prefs.append(
289
+ f'user_pref("dom.ipc.keepProcessesAlive.web", {keepalive});'
290
+ )
291
+ (self.profile_dir / "user.js").write_text(
292
+ "\n".join(prefs) + "\n",
293
+ encoding="utf-8",
294
+ )
295
+ if not self.network_only:
296
+ prefs_path = self.profile_dir / "user.js"
297
+ existing = (
298
+ prefs_path.read_text(encoding="utf-8") if prefs_path.exists() else ""
299
+ )
300
+ policy_prefs = [
301
+ 'user_pref("wpr.webrtc.permission.default", '
302
+ + json.dumps(self.config.webrtc_permission)
303
+ + ");",
304
+ 'user_pref("wpr.webrtc.permission.allowed_origins", '
305
+ + json.dumps(json.dumps(self.config.webrtc_allowed_origins))
306
+ + ");",
307
+ ]
308
+ prefs_path.write_text(
309
+ existing + "\n" + "\n".join(policy_prefs) + "\n", encoding="utf-8"
310
+ )
311
+ if self.proxy == "":
312
+ prefs_path = self.profile_dir / "user.js"
313
+ existing = (
314
+ prefs_path.read_text(encoding="utf-8") if prefs_path.exists() else ""
315
+ )
316
+ prefs_path.write_text(
317
+ existing + '\nuser_pref("network.proxy.type", 0);\n', encoding="utf-8"
318
+ )
319
+ if self.proxy:
320
+ parsed = urlsplit(str(self.proxy).strip())
321
+ scheme = parsed.scheme.lower()
322
+ if scheme not in {"http", "https", "socks4", "socks5"}:
323
+ raise ValueError("proxy scheme must be http, https, socks4, or socks5")
324
+ if not parsed.hostname or not parsed.port:
325
+ raise ValueError("proxy must include host and port")
326
+ host = parsed.hostname.replace("\\", "\\\\").replace('"', '\\"')
327
+ prefs_path = self.profile_dir / "user.js"
328
+ existing = (
329
+ prefs_path.read_text(encoding="utf-8") if prefs_path.exists() else ""
330
+ )
331
+ proxy_prefs = [
332
+ 'user_pref("network.proxy.type", 1);',
333
+ 'user_pref("network.proxy.no_proxies_on", "");',
334
+ 'user_pref("network.proxy.share_proxy_settings", true);',
335
+ 'user_pref("network.captive-portal-service.enabled", false);',
336
+ 'user_pref("network.connectivity-service.enabled", false);',
337
+ 'user_pref("signon.autologin.proxy", true);',
338
+ 'user_pref("network.auth.subresource-http-auth-allow", 2);',
339
+ ]
340
+ if scheme.startswith("socks"):
341
+ proxy_prefs.extend(
342
+ [
343
+ f'user_pref("network.proxy.socks", "{host}");',
344
+ f'user_pref("network.proxy.socks_port", {parsed.port});',
345
+ f'user_pref("network.proxy.socks_version", {5 if scheme == "socks5" else 4});',
346
+ 'user_pref("network.proxy.socks_remote_dns", true);',
347
+ ]
348
+ )
349
+ else:
350
+ proxy_prefs.extend(
351
+ [
352
+ f'user_pref("network.proxy.http", "{host}");',
353
+ f'user_pref("network.proxy.http_port", {parsed.port});',
354
+ f'user_pref("network.proxy.ssl", "{host}");',
355
+ f'user_pref("network.proxy.ssl_port", {parsed.port});',
356
+ ]
357
+ )
358
+ prefs_path.write_text(
359
+ existing.rstrip()
360
+ + ("\n" if existing.strip() else "")
361
+ + "\n".join(proxy_prefs)
362
+ + "\n",
363
+ encoding="utf-8",
364
+ )
365
+ log_root = (
366
+ self.config.log_root or Path(tempfile.gettempdir()) / "wpr-runtime-logs"
367
+ )
368
+ log_root.mkdir(parents=True, exist_ok=True)
369
+ self.stdout = (log_root / f"wpr-{self.session_id}.stdout.log").open(
370
+ "w", encoding="utf-8", errors="replace"
371
+ )
372
+ self.stderr = (log_root / f"wpr-{self.session_id}.stderr.log").open(
373
+ "w", encoding="utf-8", errors="replace"
374
+ )
375
+ env = os.environ.copy()
376
+ if self.proxy == "":
377
+ env = {
378
+ key: value
379
+ for key, value in env.items()
380
+ if not key.startswith("WPR_PROXY")
381
+ }
382
+ env.update(
383
+ {
384
+ "WPR_RPC": "1",
385
+ "WPR_PIPE_NAME": self.pipe_name,
386
+ "WPR_PROFILE_DIR": str(self.profile_dir),
387
+ "WPR_PIPE_MODE": self.pipe_mode,
388
+ "WPR_NETWORK_CHANNELS": str(network_channels),
389
+ }
390
+ )
391
+ if self.network_only:
392
+ env["WPR_NETWORK_ONLY"] = "1"
393
+ else:
394
+ env["WPR_REMOTE_PAGE"] = "1"
395
+ if self.fpfile is not None:
396
+ env["WPR_FPFILE"] = str(self.fpfile)
397
+ if self.proxy:
398
+ parsed_proxy = urlsplit(str(self.proxy).strip())
399
+ if parsed_proxy.hostname:
400
+ env["WPR_PROXY_HOST"] = parsed_proxy.hostname
401
+ if parsed_proxy.port:
402
+ env["WPR_PROXY_PORT"] = str(parsed_proxy.port)
403
+ if parsed_proxy.username is not None:
404
+ env["WPR_PROXY_USERNAME"] = parsed_proxy.username
405
+ if parsed_proxy.password is not None:
406
+ env["WPR_PROXY_PASSWORD"] = parsed_proxy.password
407
+ env["PATH"] = str(self.config.runtime_dir) + os.pathsep + env.get("PATH", "")
408
+ self.process = subprocess.Popen(
409
+ [str(self.worker_exe)],
410
+ cwd=str(self.config.runtime_dir),
411
+ env=env,
412
+ stdout=self.stdout,
413
+ stderr=self.stderr,
414
+ )
415
+ self.closed = False
416
+
417
+ def request(
418
+ self,
419
+ payload: dict[str, Any],
420
+ timing_out: dict[str, float] | None = None,
421
+ ) -> dict[str, Any]:
422
+ if self.closed:
423
+ raise RuntimeError("WorkerSession is closed")
424
+ if self._persistent_rpc is not None:
425
+ return self._persistent_rpc.request(payload, timing_out)
426
+ return request(payload, self.pipe_name, timing_out)
427
+
428
+ def network_fetch(
429
+ self,
430
+ url: str,
431
+ *,
432
+ method: str = "GET",
433
+ headers: dict[str, str] | None = None,
434
+ body: str | None = None,
435
+ follow_redirects: bool = True,
436
+ timing_out: dict[str, float] | None = None,
437
+ ) -> dict[str, Any]:
438
+ if not self.network_only:
439
+ raise RuntimeError("network_fetch requires network_only=True")
440
+ if isinstance(body, bytes):
441
+ raise TypeError(
442
+ "binary request bodies are not supported by this Worker protocol"
443
+ )
444
+ payload: dict[str, Any] = {
445
+ "method": "network.fetch",
446
+ "url": str(url),
447
+ "http_method": str(method).upper(),
448
+ "headers": headers or {},
449
+ "follow_redirects": bool(follow_redirects),
450
+ }
451
+ if body is not None:
452
+ payload["body"] = body
453
+ return self.request(payload, timing_out)
454
+
455
+ def diagnostic_dump_network_log(self) -> Any:
456
+ """Return bounded, metadata-only Gecko network events."""
457
+ return require_ok(
458
+ "diagnostic.dump_network_log",
459
+ self.request({"method": "diagnostic.dump_network_log"}),
460
+ )
461
+
462
+ def diagnostic_wait_network_event(
463
+ self, after_seq: int = 0, timeout_ms: int = 1000
464
+ ) -> Any:
465
+ """Wait in Gecko until a network-log sequence advances or times out."""
466
+ return require_ok(
467
+ "diagnostic.wait_network_event",
468
+ self.request(
469
+ {
470
+ "method": "diagnostic.wait_network_event",
471
+ "after_seq": int(after_seq),
472
+ "timeout_ms": int(timeout_ms),
473
+ }
474
+ ),
475
+ )
476
+
477
+ def diagnostic_clear_network_log(self) -> Any:
478
+ """Clear the current session's bounded network event buffer."""
479
+ return require_ok(
480
+ "diagnostic.clear_network_log",
481
+ self.request({"method": "diagnostic.clear_network_log"}),
482
+ )
483
+
484
+ def close(self, *, request_shutdown: bool = True) -> None:
485
+ if self.closed:
486
+ return
487
+ try:
488
+ if self.process.poll() is None:
489
+ if request_shutdown:
490
+ try:
491
+ self.request({"method": "runtime.shutdown"})
492
+ except Exception as exc: # noqa: BLE001 -- continue process teardown
493
+ _logger.debug("Worker shutdown RPC failed: %s", exc)
494
+ try:
495
+ self.process.wait(timeout=15)
496
+ except subprocess.TimeoutExpired:
497
+ self.process.kill()
498
+ self.process.wait(timeout=5)
499
+ finally:
500
+ self.closed = True
501
+ if self._persistent_rpc is not None:
502
+ self._persistent_rpc.close()
503
+ self.stdout.close()
504
+ self.stderr.close()
505
+ shutil.rmtree(self.profile_dir, ignore_errors=True)
506
+
507
+ def abort(self) -> None:
508
+ """Terminate immediately, used to enforce a request deadline."""
509
+ if self.closed:
510
+ return
511
+ try:
512
+ if self.process.poll() is None:
513
+ self.process.kill()
514
+ self.process.wait(timeout=5)
515
+ finally:
516
+ self.closed = True
517
+ if self._persistent_rpc is not None:
518
+ self._persistent_rpc.close()
519
+ self.stdout.close()
520
+ self.stderr.close()
521
+ shutil.rmtree(self.profile_dir, ignore_errors=True)
522
+
523
+ def __enter__(self) -> WorkerSession: # noqa: PYI034
524
+ return self
525
+
526
+ def __exit__(self, exc_type, exc, tb) -> None:
527
+ self.close()
528
+
529
+
530
+ class AsyncWorkerSession:
531
+ sync_session_type = WorkerSession
532
+
533
+ """Async process/RPC facade with optional multiplexed network channels."""
534
+
535
+ def __init__(
536
+ self,
537
+ config: RuntimeConfig | None = None,
538
+ fpfile: str | Path | None = None,
539
+ proxy: str | None = None,
540
+ *,
541
+ network_only: bool = False,
542
+ defer_body_releases: bool = False,
543
+ ipc_backend: str = "threaded",
544
+ network_channels: int = 1,
545
+ ):
546
+ self.config = config
547
+ self.fpfile = fpfile
548
+ self.proxy = proxy
549
+ self.network_only = bool(network_only)
550
+ if ipc_backend not in {"threaded", "iocp"}:
551
+ raise ValueError("ipc_backend must be 'threaded' or 'iocp'")
552
+ if ipc_backend == "iocp" and not self.network_only:
553
+ raise ValueError("IOCP backend is currently network-only")
554
+ self.ipc_backend = ipc_backend
555
+ if type(network_channels) is not int or not 1 <= network_channels <= 64:
556
+ raise ValueError("network_channels must be an integer between 1 and 64")
557
+ if network_channels > 1 and ipc_backend != "iocp":
558
+ raise ValueError("multiple channels require the IOCP backend")
559
+ self.network_channels = network_channels
560
+ self.supports_request_cancel = False
561
+ self._async_rpc = None
562
+ self.defer_body_releases = bool(defer_body_releases)
563
+ if self.defer_body_releases and not self.network_only:
564
+ raise ValueError("deferred body release requires a network-only session")
565
+ self._blob_releases: DeferredBlobReleases | None = None
566
+ self._rpc_dispatches: dict[str, int] = {}
567
+ self._sync: WorkerSession | None = None
568
+ self._lock = asyncio.Lock()
569
+
570
+ async def __aenter__(self) -> AsyncWorkerSession: # noqa: PYI034
571
+ starting = asyncio.create_task(
572
+ asyncio.to_thread(
573
+ self.sync_session_type,
574
+ self.config,
575
+ self.fpfile,
576
+ self.proxy,
577
+ network_only=self.network_only,
578
+ pipe_mode="persistent" if self.ipc_backend == "iocp" else None,
579
+ network_channels=self.network_channels,
580
+ )
581
+ )
582
+ try:
583
+ self._sync = await asyncio.shield(starting)
584
+ except asyncio.CancelledError:
585
+ sync = await starting
586
+ await asyncio.to_thread(sync.abort)
587
+ raise
588
+ if self.ipc_backend == "iocp":
589
+ from .iocp_pipe import IocpJsonLinePipe
590
+
591
+ self._async_rpc = IocpJsonLinePipe(
592
+ self._sync.pipe_name,
593
+ multiplex=self.network_channels > 1,
594
+ on_cancel=self._cancel_network_rpc,
595
+ )
596
+ if self.defer_body_releases or self._async_rpc is not None:
597
+ try:
598
+ capabilities = require_ok(
599
+ "runtime.capabilities",
600
+ await asyncio.wait_for(
601
+ self.request({"method": "runtime.capabilities"}), 15
602
+ ),
603
+ )
604
+ if not capabilities.get("network_release_batch"):
605
+ raise RuntimeError(
606
+ "piggyback release requires a Worker with network_release_batch support"
607
+ )
608
+ if self.network_channels > 1:
609
+ if (
610
+ not capabilities.get("network_multiplex")
611
+ or capabilities.get("max_channels", 0) < self.network_channels
612
+ ):
613
+ raise RuntimeError(
614
+ "Worker does not support the requested channel concurrency"
615
+ )
616
+ self.supports_request_cancel = True
617
+ if self.defer_body_releases:
618
+ self._blob_releases = DeferredBlobReleases(
619
+ self._flush_blob_release_batch
620
+ )
621
+ except BaseException:
622
+ await self.abort()
623
+ raise
624
+ return self
625
+
626
+ @property
627
+ def is_open(self) -> bool:
628
+ return self._sync is not None
629
+
630
+ async def request(self, payload: dict[str, Any]) -> dict[str, Any]:
631
+ response, _timings = await self.request_with_timing(payload)
632
+ return response
633
+
634
+ async def request_with_timing(
635
+ self, payload: dict[str, Any]
636
+ ) -> tuple[dict[str, Any], dict[str, Any]]:
637
+ if self._sync is None:
638
+ raise RuntimeError("AsyncWorkerSession is not open")
639
+ lock_started = time.perf_counter_ns()
640
+ await self._lock.acquire()
641
+ lock_acquired = time.perf_counter_ns()
642
+ lock_held = True
643
+ batch: list[int] = []
644
+ try:
645
+ pipe_timings: dict[str, Any] = {}
646
+ wire_payload = payload
647
+ if self._blob_releases is not None and payload.get("method") in {
648
+ "network.fetch",
649
+ "data.release_batch",
650
+ }:
651
+ batch = (
652
+ self._blob_releases.reserve_batch()
653
+ if self.supports_request_cancel
654
+ else self._blob_releases.snapshot()
655
+ )
656
+ if payload.get("method") == "data.release_batch" and not batch:
657
+ return {"ok": True, "released_blob_ids": []}, {}
658
+ wire_payload = {**payload, "release_blob_ids": batch}
659
+ rpc_started = time.perf_counter_ns()
660
+ rpc_method = str(payload.get("method", ""))
661
+ self._rpc_dispatches[rpc_method] = (
662
+ self._rpc_dispatches.get(rpc_method, 0) + 1
663
+ )
664
+ if self._async_rpc is None:
665
+ response = await asyncio.to_thread(
666
+ self._sync.request, wire_payload, pipe_timings
667
+ )
668
+ else:
669
+ if self.supports_request_cancel:
670
+ self._lock.release()
671
+ lock_held = False
672
+ response = await self._async_rpc.request(wire_payload, pipe_timings)
673
+ rpc_finished = time.perf_counter_ns()
674
+ if batch:
675
+ assert self._blob_releases is not None
676
+ receipt = response.get("released_blob_ids")
677
+ if receipt is not None or response.get("ok"):
678
+ self._blob_releases.acknowledge(batch, receipt)
679
+ pipe_timings["piggyback_release_count"] = float(len(batch))
680
+ except (RuntimeError, OSError, ValueError, TypeError):
681
+ if self._async_rpc is not None:
682
+ await self.abort()
683
+ raise
684
+ finally:
685
+ if self._blob_releases is not None and self.supports_request_cancel:
686
+ self._blob_releases.unreserve(batch)
687
+ if lock_held:
688
+ self._lock.release()
689
+ executor_roundtrip_ms = (rpc_finished - rpc_started) / 1_000_000
690
+ pipe_total_ms = pipe_timings.get("pipe_total_ms", 0.0)
691
+ timings = {
692
+ "session_lock_wait_ms": (lock_acquired - lock_started) / 1_000_000,
693
+ **pipe_timings,
694
+ }
695
+ if self._async_rpc is None:
696
+ timings["executor_roundtrip_ms"] = executor_roundtrip_ms
697
+ timings["python_executor_overhead_est_ms"] = max(
698
+ 0.0, executor_roundtrip_ms - pipe_total_ms
699
+ )
700
+ else:
701
+ timings["iocp_rpc_roundtrip_ms"] = executor_roundtrip_ms
702
+ return response, timings
703
+
704
+ async def _cancel_network_rpc(self, request_id, future, payload):
705
+ assert self._async_rpc is not None
706
+ self._rpc_dispatches["network.cancel"] = (
707
+ self._rpc_dispatches.get("network.cancel", 0) + 1
708
+ )
709
+ try:
710
+ await asyncio.wait_for(
711
+ self._async_rpc.request(
712
+ {
713
+ "method": "network.cancel",
714
+ "target_request_id": request_id,
715
+ }
716
+ ),
717
+ 2,
718
+ )
719
+ raw, _decode_ms, _size = await asyncio.wait_for(asyncio.shield(future), 3)
720
+ batch = payload.get("release_blob_ids") or []
721
+ if (
722
+ batch
723
+ and self._blob_releases is not None
724
+ and raw.get("released_blob_ids") is not None
725
+ ):
726
+ self._blob_releases.acknowledge(batch, raw["released_blob_ids"])
727
+ body = raw.get("body") if raw.get("ok") else None
728
+ if isinstance(body, dict) and "blob_id" in body:
729
+ await self.data_release(int(body["blob_id"]))
730
+ except BaseException:
731
+ await self.abort()
732
+ raise
733
+
734
+ async def network_fetch(
735
+ self,
736
+ url: str,
737
+ *,
738
+ method: str = "GET",
739
+ headers: dict[str, str] | None = None,
740
+ body: str | None = None,
741
+ follow_redirects: bool = True,
742
+ ) -> dict[str, Any]:
743
+ response, _timings = await self.network_fetch_with_timing(
744
+ url,
745
+ method=method,
746
+ headers=headers,
747
+ body=body,
748
+ follow_redirects=follow_redirects,
749
+ )
750
+ return response
751
+
752
+ async def network_fetch_with_timing(
753
+ self,
754
+ url: str,
755
+ *,
756
+ method: str = "GET",
757
+ headers: dict[str, str] | None = None,
758
+ body: str | None = None,
759
+ follow_redirects: bool = True,
760
+ ) -> tuple[dict[str, Any], dict[str, Any]]:
761
+ if not self.network_only:
762
+ raise RuntimeError("network_fetch requires network_only=True")
763
+ if isinstance(body, bytes):
764
+ raise TypeError(
765
+ "binary request bodies are not supported by this Worker protocol"
766
+ )
767
+ payload: dict[str, Any] = {
768
+ "method": "network.fetch",
769
+ "url": str(url),
770
+ "http_method": str(method).upper(),
771
+ "headers": headers or {},
772
+ "follow_redirects": bool(follow_redirects),
773
+ }
774
+ if body is not None:
775
+ payload["body"] = body
776
+ return await self.request_with_timing(payload)
777
+
778
+ async def data_release(self, blob_id: int) -> Any:
779
+ value, _timings = await self.data_release_with_timing(blob_id)
780
+ return value
781
+
782
+ async def queue_body_release(self, blob_id: int, size: int) -> dict[str, Any]:
783
+ if self._blob_releases is None:
784
+ raise RuntimeError("deferred release queue is not enabled")
785
+ return await self._blob_releases.enqueue(int(blob_id), int(size))
786
+
787
+ async def _flush_blob_release_batch(self) -> None:
788
+ response = await self.request({"method": "data.release_batch"})
789
+ require_ok("data.release_batch", response)
790
+
791
+ async def flush_body_releases(self) -> None:
792
+ if self._blob_releases is not None:
793
+ await self._blob_releases.drain()
794
+
795
+ @property
796
+ def transport_stats(self) -> dict[str, Any]:
797
+ pending = self._blob_releases.pending if self._blob_releases else {}
798
+ return {
799
+ "rpc_dispatches": dict(self._rpc_dispatches),
800
+ "pending_blob_count": len(pending),
801
+ "pending_blob_bytes": sum(pending.values()),
802
+ }
803
+
804
+ async def data_release_with_timing(
805
+ self, blob_id: int
806
+ ) -> tuple[Any, dict[str, float]]:
807
+ response, timings = await self.request_with_timing(
808
+ {"method": "data.release", "blob_id": int(blob_id)}
809
+ )
810
+ return require_ok("data.release", response), timings
811
+
812
+ async def diagnostic_dump_network_log(self) -> Any:
813
+ """Return bounded, metadata-only Gecko network events."""
814
+ return require_ok(
815
+ "diagnostic.dump_network_log",
816
+ await self.request({"method": "diagnostic.dump_network_log"}),
817
+ )
818
+
819
+ async def diagnostic_wait_network_event(
820
+ self, after_seq: int = 0, timeout_ms: int = 1000
821
+ ) -> Any:
822
+ """Wait in Gecko until a network-log sequence advances or times out."""
823
+ return require_ok(
824
+ "diagnostic.wait_network_event",
825
+ await self.request(
826
+ {
827
+ "method": "diagnostic.wait_network_event",
828
+ "after_seq": int(after_seq),
829
+ "timeout_ms": int(timeout_ms),
830
+ }
831
+ ),
832
+ )
833
+
834
+ async def diagnostic_clear_network_log(self) -> Any:
835
+ """Clear the current session's bounded network event buffer."""
836
+ return require_ok(
837
+ "diagnostic.clear_network_log",
838
+ await self.request({"method": "diagnostic.clear_network_log"}),
839
+ )
840
+
841
+ async def __aexit__(self, exc_type, exc, tb) -> None:
842
+ if self._sync is not None:
843
+ sync = self._sync
844
+ try:
845
+ if self._blob_releases is not None:
846
+ await self._blob_releases.close()
847
+ finally:
848
+ if self._async_rpc is not None:
849
+ try:
850
+ await self.request({"method": "runtime.shutdown"})
851
+ finally:
852
+ await self._async_rpc.aclose()
853
+ await asyncio.to_thread(sync.close, request_shutdown=False)
854
+ else:
855
+ await asyncio.to_thread(sync.close)
856
+ self._sync = None
857
+ if self._blob_releases is not None:
858
+ self._blob_releases.discard()
859
+
860
+ async def abort(self) -> None:
861
+ if self._blob_releases is not None:
862
+ self._blob_releases.discard()
863
+ if self._sync is not None:
864
+ sync, self._sync = self._sync, None
865
+ await asyncio.to_thread(sync.abort)
866
+ if self._async_rpc is not None:
867
+ await self._async_rpc.aclose()