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.
- wpr_runtime_core/__init__.py +3 -0
- wpr_runtime_core/_leases.py +120 -0
- wpr_runtime_core/data_plane.py +148 -0
- wpr_runtime_core/installer.py +326 -0
- wpr_runtime_core/iocp_pipe.py +204 -0
- wpr_runtime_core/pipe.py +169 -0
- wpr_runtime_core/protocols.py +23 -0
- wpr_runtime_core/registry.py +157 -0
- wpr_runtime_core/runtime-catalog.json +37 -0
- wpr_runtime_core/session.py +867 -0
- wpr_runtime_core-0.2.0.dist-info/METADATA +18 -0
- wpr_runtime_core-0.2.0.dist-info/RECORD +14 -0
- wpr_runtime_core-0.2.0.dist-info/WHEEL +5 -0
- wpr_runtime_core-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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()
|