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,3 @@
1
+ """Shared runtime infrastructure; browser APIs live in gecko_web_runtime."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,120 @@
1
+ """Acknowledged, session-scoped deferred release of network response blobs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from collections.abc import Awaitable, Callable
8
+
9
+ _logger = logging.getLogger("wpr_runtime_core.leases")
10
+
11
+
12
+ class DeferredBlobReleases:
13
+ max_batch = 32
14
+ flush_bytes = 8 * 1024 * 1024
15
+ idle_seconds = 0.05
16
+
17
+ def __init__(self, flush: Callable[[], Awaitable[None]]) -> None:
18
+ self.pending: dict[int, int] = {}
19
+ self.reserved: set[int] = set()
20
+ self._changed = asyncio.Event()
21
+ self._flush = flush
22
+ self._timer: asyncio.TimerHandle | None = None
23
+ self._background: asyncio.Task | None = None
24
+ self._drain_lock = asyncio.Lock()
25
+ self._stopped = False
26
+
27
+ async def enqueue(self, blob_id: int, size: int) -> dict:
28
+ if self._stopped:
29
+ raise RuntimeError("deferred release queue is closed")
30
+ self.pending.setdefault(blob_id, size)
31
+ if self._timer is None:
32
+ self._timer = asyncio.get_running_loop().call_later(
33
+ self.idle_seconds, self._idle_flush
34
+ )
35
+ if (
36
+ len(self.pending) >= self.max_batch
37
+ or sum(self.pending.values()) >= self.flush_bytes
38
+ ):
39
+ await self.drain()
40
+ return {
41
+ "released": blob_id not in self.pending,
42
+ "release_deferred": blob_id in self.pending,
43
+ "pending_release_count": len(self.pending),
44
+ }
45
+
46
+ def snapshot(self) -> list[int]:
47
+ return [item for item in self.pending if item not in self.reserved][
48
+ : self.max_batch
49
+ ]
50
+
51
+ def reserve_batch(self) -> list[int]:
52
+ batch = self.snapshot()
53
+ self.reserved.update(batch)
54
+ return batch
55
+
56
+ def unreserve(self, batch: list[int]) -> None:
57
+ self.reserved.difference_update(batch)
58
+ self._changed.set()
59
+
60
+ def acknowledge(self, batch: list[int], receipt: object) -> None:
61
+ if not isinstance(receipt, list) or any(
62
+ type(item) is not int for item in receipt
63
+ ):
64
+ raise RuntimeError("Worker omitted valid blob release acknowledgements")
65
+ if set(receipt) != set(batch) or len(receipt) != len(batch):
66
+ raise RuntimeError(
67
+ "Worker blob release acknowledgements did not match the batch"
68
+ )
69
+ for blob_id in batch:
70
+ self.pending.pop(blob_id, None)
71
+ self.unreserve(batch)
72
+ if not self.pending and self._timer is not None:
73
+ self._timer.cancel()
74
+ self._timer = None
75
+
76
+ def _idle_flush(self) -> None:
77
+ self._timer = None
78
+ if self.pending and not self._stopped and self._background is None:
79
+ self._background = asyncio.create_task(self.drain())
80
+ self._background.add_done_callback(self._background_done)
81
+
82
+ def _background_done(self, task: asyncio.Task) -> None:
83
+ self._background = None
84
+ if not task.cancelled() and (error := task.exception()) is not None:
85
+ _logger.warning("http.blob_release_flush_failed error=%s", error)
86
+
87
+ async def drain(self) -> None:
88
+ async with self._drain_lock:
89
+ while self.pending and not self._stopped:
90
+ self._changed.clear()
91
+ before = self.snapshot()
92
+ if not before:
93
+ await self._changed.wait()
94
+ continue
95
+ await self._flush()
96
+ if before == self.snapshot():
97
+ raise RuntimeError("Worker made no progress flushing blob releases")
98
+
99
+ async def close(self) -> None:
100
+ if self._timer is not None:
101
+ self._timer.cancel()
102
+ self._timer = None
103
+ await self.drain()
104
+ self._stopped = True
105
+ if self._background is not None:
106
+ await self._background
107
+
108
+ def discard(self) -> None:
109
+ self._stopped = True
110
+ self.pending.clear()
111
+ self.reserved.clear()
112
+ self._changed.set()
113
+ if self._timer is not None:
114
+ self._timer.cancel()
115
+ self._timer = None
116
+ if (
117
+ self._background is not None
118
+ and self._background is not asyncio.current_task()
119
+ ):
120
+ self._background.cancel()
@@ -0,0 +1,148 @@
1
+ """Large-result data-plane leases for the WPR control RPC.
2
+
3
+ The Worker returns a small JSON descriptor for a named read-only mapping. The
4
+ consumer maps it only when needed and explicitly releases the Worker-side
5
+ handle after closing its local view.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import mmap
11
+ import time
12
+ from dataclasses import dataclass
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ if TYPE_CHECKING:
16
+ from .protocols import ReleaseSession
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class SharedBlobDescriptor:
21
+ transport: str
22
+ blob_id: int
23
+ mapping_name: str
24
+ size: int
25
+ encoding: str = "utf-8"
26
+ readonly: bool = True
27
+ mapping_size: int | None = None
28
+ offset: int = 0
29
+ generation: int | None = None
30
+
31
+ @classmethod
32
+ def from_value(cls, value: Any) -> SharedBlobDescriptor:
33
+ if not isinstance(value, dict) or value.get("transport") not in {
34
+ "shared_memory",
35
+ "shared_memory_ring",
36
+ }:
37
+ raise ValueError("invalid shared blob descriptor")
38
+ size = int(value["size"])
39
+ mapping_size = int(value.get("mapping_size") or size)
40
+ offset = int(value.get("offset", 0))
41
+ if size < 0 or offset < 0 or mapping_size < size + offset:
42
+ raise ValueError("invalid shared blob bounds")
43
+ return cls(
44
+ transport=str(value["transport"]),
45
+ blob_id=int(value["blob_id"]),
46
+ mapping_name=str(value["mapping_name"]),
47
+ size=size,
48
+ encoding=str(value.get("encoding") or "utf-8"),
49
+ readonly=bool(value.get("readonly", True)),
50
+ mapping_size=mapping_size,
51
+ offset=offset,
52
+ generation=(int(value["generation"]) if "generation" in value else None),
53
+ )
54
+
55
+
56
+ class SharedBlob:
57
+ """A mapped blob with an explicit asynchronous release handshake."""
58
+
59
+ def __init__(
60
+ self,
61
+ descriptor: SharedBlobDescriptor,
62
+ session: ReleaseSession,
63
+ ) -> None:
64
+ self.descriptor = descriptor
65
+ self._session = session
66
+ self._mapping: mmap.mmap | None = None
67
+ self._released = False
68
+ self.mapping_open_ms = 0.0
69
+ self.copy_ms = 0.0
70
+ self.release_timings: dict[str, float] = {}
71
+
72
+ def open(self) -> SharedBlob:
73
+ if self._mapping is None:
74
+ if self.descriptor.size < 0:
75
+ raise ValueError("shared blob size must be non-negative")
76
+ # Windows mmap opens an existing named CreateFileMapping object via
77
+ # the tagname argument. Keep a one-byte view for an empty result.
78
+ started_ns = time.perf_counter_ns()
79
+ self._mapping = mmap.mmap(
80
+ -1,
81
+ max(1, int(self.descriptor.mapping_size or self.descriptor.size)),
82
+ tagname=self.descriptor.mapping_name,
83
+ access=mmap.ACCESS_READ,
84
+ )
85
+ self.mapping_open_ms += (time.perf_counter_ns() - started_ns) / 1_000_000
86
+ return self
87
+
88
+ def view(self) -> memoryview:
89
+ if self._mapping is None:
90
+ self.open()
91
+ assert self._mapping is not None
92
+ start = self.descriptor.offset
93
+ return memoryview(self._mapping)[start : start + self.descriptor.size]
94
+
95
+ def read_bytes(self) -> bytes:
96
+ started_ns = time.perf_counter_ns()
97
+ data = self.view().tobytes()
98
+ self.copy_ms += (time.perf_counter_ns() - started_ns) / 1_000_000
99
+ return data
100
+
101
+ def read_text(self) -> str:
102
+ return self.read_bytes().decode(self.descriptor.encoding)
103
+
104
+ def close(self) -> None:
105
+ if self._mapping is not None:
106
+ self._mapping.close()
107
+ self._mapping = None
108
+
109
+ async def release(self) -> dict[str, Any]:
110
+ if self._released:
111
+ return {"released": True, "already_released": True}
112
+ self.close()
113
+ if not self._session.is_open:
114
+ # A Worker may be aborted after Python mapped the response. The
115
+ # OS mapping remains readable while this local view is open, and
116
+ # the dead process has already discarded its own blob registry.
117
+ self._released = True
118
+ return {"released": False, "worker_closed": True}
119
+ try:
120
+ if getattr(self._session, "defer_body_releases", False):
121
+ response = await self._session.queue_body_release(
122
+ self.descriptor.blob_id, self.descriptor.size
123
+ )
124
+ self.release_timings = {
125
+ "release_deferred": float(response["release_deferred"])
126
+ }
127
+ elif hasattr(self._session, "data_release_with_timing"):
128
+ (
129
+ response,
130
+ self.release_timings,
131
+ ) = await self._session.data_release_with_timing(
132
+ self.descriptor.blob_id
133
+ )
134
+ else:
135
+ response = await self._session.data_release(self.descriptor.blob_id)
136
+ except Exception:
137
+ if self._session.is_open:
138
+ raise
139
+ self._released = True
140
+ return {"released": False, "worker_closed": True}
141
+ self._released = True
142
+ return response
143
+
144
+ async def __aenter__(self) -> SharedBlob: # noqa: PYI034
145
+ return self.open()
146
+
147
+ async def __aexit__(self, exc_type, exc, tb) -> None:
148
+ await self.release()
@@ -0,0 +1,326 @@
1
+ """Download and install a pinned WPR runtime release."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import shutil
9
+ import subprocess
10
+ import tempfile
11
+ import urllib.error
12
+ import urllib.parse
13
+ import urllib.request
14
+ import uuid
15
+ import warnings
16
+ import zipfile
17
+ from importlib.resources import files
18
+ from pathlib import Path
19
+
20
+ RUNTIME_CATALOG = json.loads(
21
+ files(__package__).joinpath("runtime-catalog.json").read_text(encoding="utf-8")
22
+ )
23
+ RUNTIME_RELEASES = {
24
+ engine.removeprefix("firefox"): {
25
+ **definition["builds"][definition["default_build"]],
26
+ **definition["builds"][definition["default_build"]]["products"]["browser"],
27
+ }
28
+ for engine, definition in RUNTIME_CATALOG["engines"].items()
29
+ }
30
+ NETWORK_RELEASES = {
31
+ engine.removeprefix("firefox"): {
32
+ **definition["builds"][definition["default_build"]],
33
+ **definition["builds"][definition["default_build"]]["products"]["network"],
34
+ }
35
+ for engine, definition in RUNTIME_CATALOG["engines"].items()
36
+ }
37
+
38
+
39
+ def select_release(version: str | int, *, product: str = "browser") -> dict[str, str | int]:
40
+ """Resolve one exact native product without crossing engine versions."""
41
+ from .registry import RuntimeCompatibilityError, engine_major
42
+
43
+ major = engine_major(version)
44
+ if product not in {"browser", "network"}:
45
+ raise ValueError(f"unknown runtime product: {product}")
46
+ engine = RUNTIME_CATALOG["engines"].get(f"firefox{major}")
47
+ if engine is None:
48
+ raise RuntimeCompatibilityError(
49
+ f"Firefox {major} has no published runtime; available: {', '.join(RUNTIME_RELEASES)}"
50
+ )
51
+ build = str(version) if "." in str(version) else engine["default_build"]
52
+ build_info = engine["builds"].get(build)
53
+ if build_info is None:
54
+ raise RuntimeCompatibilityError(
55
+ f"Firefox {version} has no published runtime; available builds: {', '.join(engine['builds'])}"
56
+ )
57
+ # Keep the browser default mapping patchable for the historical installer
58
+ # compatibility tests and existing third-party imports.
59
+ if product == "browser" and build == engine["default_build"]:
60
+ release = RUNTIME_RELEASES[major]
61
+ else:
62
+ release = build_info["products"][product]
63
+ if (build_info["firefox_major"] != int(major)
64
+ or build_info["version"] != build
65
+ or release["product"] != ("wpr-runtime" if product == "browser" else "wpr-network-runtime")):
66
+ raise RuntimeCompatibilityError("runtime catalog entry is inconsistent")
67
+ return {**build_info, **release}
68
+ return release
69
+
70
+
71
+ def _github_token() -> str | None:
72
+ for name in ("WPR_GITHUB_TOKEN", "GITHUB_TOKEN", "GH_TOKEN"):
73
+ value = os.environ.get(name)
74
+ if value:
75
+ return value.strip()
76
+ # Git Credential Manager may already hold the user's HTTPS credential.
77
+ try:
78
+ query = "protocol=https\nhost=github.com\n\n"
79
+ result = subprocess.run(
80
+ ["git", "credential", "fill"],
81
+ input=query,
82
+ text=True,
83
+ capture_output=True,
84
+ timeout=10,
85
+ check=True,
86
+ )
87
+ for line in result.stdout.splitlines():
88
+ if line.startswith("password=") and line[9:]:
89
+ return line[9:].strip()
90
+ except (OSError, subprocess.SubprocessError):
91
+ pass
92
+ return None
93
+
94
+
95
+ def _github_asset_request(release: dict[str, str]) -> urllib.request.Request:
96
+ token = _github_token()
97
+ if not token:
98
+ return urllib.request.Request(
99
+ release["url"],
100
+ headers={"User-Agent": "gecko-web-runtime-client/1.3.1"},
101
+ )
102
+
103
+ api = (
104
+ f"https://api.github.com/repos/{release['repository']}"
105
+ f"/releases/tags/{release['tag']}"
106
+ )
107
+ metadata_request = urllib.request.Request(
108
+ api,
109
+ headers={
110
+ "Accept": "application/vnd.github+json",
111
+ "Authorization": f"Bearer {token}",
112
+ "User-Agent": "gecko-web-runtime-client/1.3.1",
113
+ "X-GitHub-Api-Version": "2022-11-28",
114
+ },
115
+ )
116
+ with _open_github(metadata_request, timeout=30) as response:
117
+ metadata = json.load(response)
118
+ asset = next(
119
+ (
120
+ item
121
+ for item in metadata.get("assets", [])
122
+ if item.get("name") == release["asset"]
123
+ ),
124
+ None,
125
+ )
126
+ if not asset:
127
+ raise RuntimeError(f"GitHub release asset not found: {release['asset']}")
128
+ return urllib.request.Request(
129
+ f"https://api.github.com/repos/{release['repository']}"
130
+ f"/releases/assets/{asset['id']}",
131
+ headers={
132
+ "Accept": "application/octet-stream",
133
+ "Authorization": f"Bearer {token}",
134
+ "User-Agent": "gecko-web-runtime-client/1.3.1",
135
+ "X-GitHub-Api-Version": "2022-11-28",
136
+ },
137
+ )
138
+
139
+
140
+ def _open_github(request: urllib.request.Request, *, timeout: int):
141
+ """Retry GitHub through a direct route if a system proxy breaks TLS."""
142
+ try:
143
+ return urllib.request.urlopen(request, timeout=timeout)
144
+ except urllib.error.URLError:
145
+ host = urllib.parse.urlsplit(request.full_url).hostname or ""
146
+ if host not in {"api.github.com", "github.com"}:
147
+ raise
148
+ return urllib.request.build_opener(urllib.request.ProxyHandler({})).open(
149
+ request, timeout=timeout
150
+ )
151
+
152
+
153
+ def config_path() -> Path:
154
+ base = os.environ.get("LOCALAPPDATA")
155
+ if not base:
156
+ base = str(Path.home() / "AppData" / "Local")
157
+ return Path(base) / "WPR" / "config.json"
158
+
159
+
160
+ def configured_runtime_home() -> Path | None:
161
+ path = config_path()
162
+ try:
163
+ data = json.loads(path.read_text(encoding="utf-8"))
164
+ value = data.get("runtime_home")
165
+ return Path(value).resolve() if value else None
166
+ except (FileNotFoundError, OSError, json.JSONDecodeError):
167
+ return None
168
+
169
+
170
+ def _sha256(path: Path) -> str:
171
+ digest = hashlib.sha256()
172
+ with path.open("rb") as stream:
173
+ for chunk in iter(lambda: stream.read(1024 * 1024), b""):
174
+ digest.update(chunk)
175
+ return digest.hexdigest()
176
+
177
+
178
+ def _extract_safely(archive: Path, destination: Path) -> None:
179
+ destination.mkdir(parents=True, exist_ok=True)
180
+ root = destination.resolve()
181
+ with zipfile.ZipFile(archive) as source:
182
+ for member in source.infolist():
183
+ target = (destination / member.filename).resolve()
184
+ if target != root and root not in target.parents:
185
+ raise RuntimeError(f"unsafe path in runtime archive: {member.filename}")
186
+ source.extractall(destination)
187
+
188
+
189
+ def _runtime_is_current(destination: Path, version: str, product: str = "browser") -> bool:
190
+ try:
191
+ manifest = json.loads(
192
+ (destination / "manifest.json").read_text(encoding="utf-8")
193
+ )
194
+ if manifest.get("version") != version:
195
+ return False
196
+ if product == "network":
197
+ if manifest.get("product") != "wpr-network-runtime":
198
+ return False
199
+ from .registry import RuntimeManifest
200
+ RuntimeManifest.load(destination, version)
201
+ elif not (destination / "runtime" / "gecko-web-worker.exe").is_file():
202
+ return False
203
+ except (FileNotFoundError, OSError, ValueError, KeyError, TypeError):
204
+ return False
205
+ return True
206
+
207
+
208
+ def install_runtime(
209
+ version: str,
210
+ runtime_home: str | os.PathLike[str] | None = None,
211
+ *,
212
+ force: bool = False,
213
+ product: str = "browser",
214
+ ) -> Path:
215
+ """Install a pinned runtime and persist its location for the client."""
216
+ if os.name != "nt":
217
+ raise OSError("wpr-runtime-155 requires Windows")
218
+ release = select_release(version, product=product)
219
+ key = str(release["firefox_major"])
220
+
221
+ if runtime_home is None:
222
+ local = os.environ.get("LOCALAPPDATA") or str(Path.home() / "AppData" / "Local")
223
+ if product == "browser":
224
+ destination = Path(local) / "WPR" / "runtime" / key
225
+ else:
226
+ destination = Path(local) / "WPR" / "network-runtimes" / f"firefox{key}" / release["version"]
227
+ else:
228
+ destination = Path(runtime_home)
229
+ destination = destination.expanduser().resolve()
230
+
231
+ if (
232
+ destination.exists()
233
+ and not force
234
+ and _runtime_is_current(destination, release["version"], product)
235
+ ):
236
+ _write_config(destination, product=product)
237
+ return destination
238
+
239
+ destination.parent.mkdir(parents=True, exist_ok=True)
240
+ stage = destination.parent / f".{destination.name}.staging-{uuid.uuid4().hex}"
241
+ backup = None
242
+ moved_new = False
243
+ preserve_stage = False
244
+ try:
245
+ with tempfile.TemporaryDirectory(prefix="wpr-runtime-download-") as temp:
246
+ archive = Path(temp) / "runtime.zip"
247
+ request = _github_asset_request(release)
248
+ with (
249
+ _open_github(request, timeout=120) as response,
250
+ archive.open("wb") as output,
251
+ ):
252
+ shutil.copyfileobj(response, output)
253
+ actual = _sha256(archive)
254
+ if actual != release["sha256"]:
255
+ raise RuntimeError(
256
+ f"runtime SHA256 mismatch: expected {release['sha256']}, got {actual}"
257
+ )
258
+ _extract_safely(archive, stage)
259
+
260
+ if not _runtime_is_current(stage, release["version"], product):
261
+ raise RuntimeError(
262
+ f"{product} runtime archive is missing its Worker or manifest"
263
+ )
264
+ if destination.exists():
265
+ backup = (
266
+ destination.parent / f"{destination.name}.backup-{uuid.uuid4().hex}"
267
+ )
268
+ destination.rename(backup)
269
+ try:
270
+ stage.replace(destination)
271
+ moved_new = True
272
+ if not _runtime_is_current(destination, release["version"], product):
273
+ raise RuntimeError("installed runtime failed post-swap validation")
274
+ _write_config(destination, product=product)
275
+ except BaseException:
276
+ try:
277
+ if moved_new:
278
+ destination.rename(stage)
279
+ if backup is not None:
280
+ backup.rename(destination)
281
+ except OSError as rollback_error:
282
+ preserve_stage = True
283
+ raise RuntimeError(
284
+ f"runtime upgrade rollback incomplete; inspect {destination}, {backup}, and {stage}"
285
+ ) from rollback_error
286
+ raise
287
+ if backup is not None:
288
+ try:
289
+ shutil.rmtree(backup)
290
+ except OSError:
291
+ warnings.warn(
292
+ f"Upgrade succeeded, but the temporary backup remains at {backup}",
293
+ stacklevel=2,
294
+ )
295
+ return destination
296
+ finally:
297
+ if not preserve_stage and stage.exists():
298
+ shutil.rmtree(stage, ignore_errors=True)
299
+
300
+
301
+ def _write_config(destination: Path, *, product: str = "browser") -> None:
302
+ path = config_path()
303
+ path.parent.mkdir(parents=True, exist_ok=True)
304
+ staged = path.with_name(f".{path.name}.staging-{uuid.uuid4().hex}")
305
+ try:
306
+ settings = json.loads(path.read_text(encoding="utf-8"))
307
+ except (OSError, ValueError):
308
+ settings = {}
309
+ manifest = json.loads((destination / "manifest.json").read_text(encoding="utf-8"))
310
+ major = str(manifest["version"]).split(".", 1)[0]
311
+ if product == "network":
312
+ settings.setdefault("network_runtimes", {})[str(manifest["version"])] = str(destination)
313
+ if NETWORK_RELEASES.get(major, {}).get("version", manifest["version"]) == manifest["version"]:
314
+ settings["network_runtimes"][major] = str(destination)
315
+ else:
316
+ settings.setdefault("runtimes", {})[major] = str(destination)
317
+ settings["runtimes"][str(manifest["version"])] = str(destination)
318
+ settings["runtime_home"] = str(destination)
319
+ try:
320
+ staged.write_text(
321
+ json.dumps(settings, indent=2) + "\n",
322
+ encoding="utf-8",
323
+ )
324
+ staged.replace(path)
325
+ finally:
326
+ staged.unlink(missing_ok=True)