sandboxedjs 0.2.11 → 0.2.12
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.
- package/README.md +16 -9
- package/assets/logo.png +0 -0
- package/bin/sandboxedjs-egress.mjs +25 -10
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/service-worker.js +3 -2
- package/docs/agent/COMMANDS.md +85 -0
- package/docs/agent/DECISION-TREE.md +84 -0
- package/docs/agent/INVARIANTS.md +40 -0
- package/docs/agent/LAUNCH-PROMPT.md +37 -0
- package/docs/agent/LOOP.md +84 -0
- package/docs/agent/README.md +77 -0
- package/docs/agent/ROADMAP.md +37 -0
- package/docs/agent/STATE.md +93 -0
- package/docs/agent/tasks/00-verify-inherited-work.md +36 -0
- package/docs/agent/tasks/01-authoritative-metadata.md +34 -0
- package/docs/agent/tasks/02-native-dependencies.md +35 -0
- package/docs/agent/tasks/03-reproducible-inputs.md +27 -0
- package/docs/agent/tasks/04-build-frontends.md +27 -0
- package/docs/agent/tasks/05-registry-integration.md +27 -0
- package/docs/agent/tasks/06-package-cohorts.md +42 -0
- package/docs/agent/tasks/07-build-on-miss-boundary.md +31 -0
- package/docs/browser-runtime-architecture.md +142 -0
- package/docs/compatibility-implementation-plan.md +98 -0
- package/docs/developer-tool-packs.md +134 -0
- package/docs/frontend-automation.md +49 -0
- package/docs/fullstack-deployment.md +163 -0
- package/docs/handoff.md +275 -0
- package/docs/original-x64.md +39 -0
- package/docs/platform-hardening.md +49 -0
- package/docs/python/abi.md +97 -0
- package/docs/python/architecture.md +94 -0
- package/docs/python/baseline-inventory.md +54 -0
- package/docs/python/build-on-miss.md +198 -0
- package/docs/python/compatibility.md +206 -0
- package/docs/python/cross-build.md +354 -0
- package/docs/python/extensions.md +282 -0
- package/docs/python/release-gates.md +46 -0
- package/docs/python/virtual-sockets-plan.md +331 -0
- package/docs/runtime-lifecycle-fixes.md +39 -0
- package/docs/server-previews.md +268 -0
- package/docs/virtual-browser.md +120 -0
- package/package.json +5 -3
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
# Native extensions for the owned CPython runtime
|
|
2
|
+
|
|
3
|
+
This describes how a compiled CPython extension — a C module, or a Rust one
|
|
4
|
+
built with PyO3 — is built, tagged, installed and loaded by the SandboxedJs
|
|
5
|
+
Python distribution.
|
|
6
|
+
|
|
7
|
+
It is a general pipeline. Nothing in the runtime, the installer or the import
|
|
8
|
+
machinery knows about any particular package. Adding support for a package
|
|
9
|
+
means adding a *recipe*; it never means adding a runtime adapter.
|
|
10
|
+
|
|
11
|
+
## The contract
|
|
12
|
+
|
|
13
|
+
`python-runtime/abi/extension-abi.json` is the single source of truth. The
|
|
14
|
+
wheel tag, the compiler flags side modules are built with, the identifier the
|
|
15
|
+
wheel cache is keyed on and the checks the builder makes are all generated
|
|
16
|
+
from it by `scripts/generate_extension_abi.py` into:
|
|
17
|
+
|
|
18
|
+
- `python-runtime/abi/sbx_ext_abi.h` — for C extensions
|
|
19
|
+
- `src/runtime/python/extension-abi.ts` — for the installer and loader
|
|
20
|
+
|
|
21
|
+
Regenerate with `make -C python-runtime extension-abi` and commit the output.
|
|
22
|
+
|
|
23
|
+
### What the contract fixes
|
|
24
|
+
|
|
25
|
+
| Field | Value |
|
|
26
|
+
| --- | --- |
|
|
27
|
+
| Implementation | CPython 3.13.5 |
|
|
28
|
+
| ABI tag | `cp313` |
|
|
29
|
+
| SOABI | `cpython-313-wasm32-emscripten` |
|
|
30
|
+
| Extension suffix | `.cpython-313-wasm32-emscripten.so` |
|
|
31
|
+
| Target triple | `wasm32-unknown-emscripten` |
|
|
32
|
+
| Emscripten | 5.0.6 |
|
|
33
|
+
| Threads | pthreads, shared memory |
|
|
34
|
+
| Memory | 256 MiB initial, growth enabled |
|
|
35
|
+
| Stack | 8 MiB |
|
|
36
|
+
| Exceptions | WebAssembly EH (`-fwasm-exceptions`), longjmp lowered to wasm |
|
|
37
|
+
| Dynamic linking | CPython is `MAIN_MODULE=1`; extensions are `SIDE_MODULE=1` |
|
|
38
|
+
| Wheel tag | `cp313-cp313-emscripten_5_0_6_wasm32` |
|
|
39
|
+
|
|
40
|
+
The wheel tag is the PEP 425 platform tag CPython itself reports on
|
|
41
|
+
Emscripten, so `pip` and `packaging` accept it without a project-specific
|
|
42
|
+
patch. **Linux and macOS wheels are never accepted.** A `manylinux` wheel
|
|
43
|
+
contains ELF objects this interpreter cannot load; installing one would turn a
|
|
44
|
+
clear resolution failure into an `ImportError` inside the user's program.
|
|
45
|
+
|
|
46
|
+
### Why there is an ABI id as well as a tag
|
|
47
|
+
|
|
48
|
+
`abiId` (currently `sbxabi1-…`) is a hash of every field that affects binary
|
|
49
|
+
compatibility. It is deliberately finer-grained than the wheel tag: the
|
|
50
|
+
platform tag names only the Emscripten version, but two builds from the same
|
|
51
|
+
Emscripten with different pthread or memory settings are still incompatible.
|
|
52
|
+
|
|
53
|
+
The tag is what the packaging ecosystem understands. The id is what this
|
|
54
|
+
project caches and verifies on. Change any field that matters and the id
|
|
55
|
+
changes with it, so every previously built wheel stops matching rather than
|
|
56
|
+
being loaded against an interpreter it no longer fits.
|
|
57
|
+
|
|
58
|
+
The id is a hash rather than a hand-maintained version number because a
|
|
59
|
+
version number is bumped when someone remembers to bump it, which is not the
|
|
60
|
+
same occasion as the ABI actually changing.
|
|
61
|
+
|
|
62
|
+
## Build profiles
|
|
63
|
+
|
|
64
|
+
`--enable-wasm-dynamic-linking` is what makes extension loading possible at
|
|
65
|
+
all, and it is a separate profile rather than a flag on the existing one:
|
|
66
|
+
|
|
67
|
+
| Profile | Threads | Native extensions |
|
|
68
|
+
| --- | --- | --- |
|
|
69
|
+
| `core` | no | linked in, fixed |
|
|
70
|
+
| `threaded-fixed` | yes | linked in, fixed |
|
|
71
|
+
| `dynamic` | yes | **loaded at import** |
|
|
72
|
+
|
|
73
|
+
`dynamic` is the profile shipped in the npm package. Build it with:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
make -C python-runtime python PROFILE=dynamic
|
|
77
|
+
make -C python-runtime package PROFILE=dynamic
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`dynamic` has its own OpenSSL sysroot (`out/sysroot-dynamic`) because every
|
|
81
|
+
object linked into a `MAIN_MODULE` must be position independent.
|
|
82
|
+
|
|
83
|
+
## Adding a package
|
|
84
|
+
|
|
85
|
+
Write a recipe and build it:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
python3 python-runtime/scripts/build_extension.py path/to/recipe.json
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
A recipe names sources or a crate, and nothing else:
|
|
92
|
+
|
|
93
|
+
Pydantic is version-pinned at the native boundary. The repository carries both
|
|
94
|
+
`pydantic-core==2.23.2` for `pydantic==2.9.x` and `pydantic-core==2.46.5` for
|
|
95
|
+
newer Pydantic releases that require it. Keep each exact pair in the wheel
|
|
96
|
+
index; a newer core cannot satisfy an older Pydantic requirement.
|
|
97
|
+
|
|
98
|
+
```json
|
|
99
|
+
{
|
|
100
|
+
"name": "sbx-c-probe",
|
|
101
|
+
"version": "1.0.0",
|
|
102
|
+
"kind": "c-extension",
|
|
103
|
+
"modules": [{ "name": "sbx_c_probe", "sources": ["sbx_c_probe.c"] }]
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
```json
|
|
108
|
+
{
|
|
109
|
+
"name": "sbx-rust-probe",
|
|
110
|
+
"version": "1.0.0",
|
|
111
|
+
"kind": "pyo3",
|
|
112
|
+
"modules": [{ "name": "sbx_rust_probe", "crate": ".", "crateName": "sbx_rust_probe" }]
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
**A recipe cannot set compiler or link flags.** They come from the contract.
|
|
117
|
+
A recipe that could set its own `-pthread` could produce an artifact that
|
|
118
|
+
links and then corrupts memory, and the mismatch would be invisible in the
|
|
119
|
+
wheel it produced.
|
|
120
|
+
|
|
121
|
+
The builder refuses to produce a wheel unless the artifact is a WebAssembly
|
|
122
|
+
binary, declares a `dylink` section, and exports `PyInit_<module>`. All three
|
|
123
|
+
failures are misleading at import time, so they are caught at build time and
|
|
124
|
+
named.
|
|
125
|
+
|
|
126
|
+
## Rust and PyO3
|
|
127
|
+
|
|
128
|
+
Two properties of the Rust toolchain decide how this works, and both were
|
|
129
|
+
found as link errors rather than documented limitations.
|
|
130
|
+
|
|
131
|
+
**Rust's shipped `std` for `wasm32-unknown-emscripten` has no atomics.** It is
|
|
132
|
+
built without the `atomics` and `bulk-memory` features, so it cannot be linked
|
|
133
|
+
into a shared-memory module — and this runtime's main module has pthreads. The
|
|
134
|
+
error names an `.rcgu.o` file:
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
wasm-ld: error: --shared-memory is disallowed by …rcgu.o because it was not
|
|
138
|
+
compiled with 'atomics' or 'bulk-memory' features.
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
So `std` is rebuilt from source with those features, which needs a nightly
|
|
142
|
+
toolchain for `-Z build-std`. This is a property of the threading model, not a
|
|
143
|
+
preference: a runtime built without pthreads would link stable Rust's shipped
|
|
144
|
+
`std` unchanged.
|
|
145
|
+
|
|
146
|
+
**Rust panics unwind using the WebAssembly exception proposal.** A `cargo`
|
|
147
|
+
built extension imports a `__cpp_exception` tag, and a main module linked
|
|
148
|
+
without exception handling cannot supply one:
|
|
149
|
+
|
|
150
|
+
```
|
|
151
|
+
LinkError: WebAssembly.Instance(): Import #420 "env" "__cpp_exception":
|
|
152
|
+
tag import requires a WebAssembly.Tag
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
CPython is therefore linked with `-fwasm-exceptions` (and
|
|
156
|
+
`-sSUPPORT_LONGJMP=wasm`, since the two share a mechanism). Enabling it in the
|
|
157
|
+
main module is preferred over forcing `panic = "abort"` on every Rust
|
|
158
|
+
extension, because PyO3 turns a Rust panic into a Python exception by catching
|
|
159
|
+
the unwind — an aborting build would take the process down instead of raising.
|
|
160
|
+
|
|
161
|
+
PyO3 is cross-compiled through a generated config file rather than allowed to
|
|
162
|
+
probe, because probing describes the *build machine's* interpreter, which is
|
|
163
|
+
how a cross build silently produces a host artifact.
|
|
164
|
+
`suppress_build_script_link_lines` matters most: without it PyO3 emits
|
|
165
|
+
`-lpython3.13`, and there is no such library here — the CPython symbols come
|
|
166
|
+
from the main module at load time, which is what a side module's undefined
|
|
167
|
+
symbols are for.
|
|
168
|
+
|
|
169
|
+
## Installation
|
|
170
|
+
|
|
171
|
+
The installer resolves before it downloads, and downloads before it writes:
|
|
172
|
+
|
|
173
|
+
1. Already-installed distributions are skipped.
|
|
174
|
+
2. Dependencies are resolved with constraint propagation and conflict
|
|
175
|
+
learning (`src/runtime/python/resolver.ts`).
|
|
176
|
+
3. A compatible pure-Python wheel is preferred.
|
|
177
|
+
4. Then a wheel tagged for this ABI.
|
|
178
|
+
5. Then, if enabled, a source build.
|
|
179
|
+
6. Every download is verified against the digest the index published. An
|
|
180
|
+
artifact published without one is refused rather than installed unverified.
|
|
181
|
+
7. The whole solved graph is staged and committed as one transaction.
|
|
182
|
+
|
|
183
|
+
Resolution reads dependencies from **PEP 658 metadata sidecars**, a few
|
|
184
|
+
kilobytes each, rather than downloading whole wheels to read `METADATA`. That
|
|
185
|
+
is what removed the previous installer's arbitrary 96-candidate cap: the cap
|
|
186
|
+
existed because discovering that forty releases all needed the same
|
|
187
|
+
unavailable extension cost forty multi-megabyte downloads. What bounds the
|
|
188
|
+
search now is a deadline and a cancellation signal, which are honest limits —
|
|
189
|
+
they say the search ran out of time, not out of an arbitrary allowance.
|
|
190
|
+
|
|
191
|
+
## Security model
|
|
192
|
+
|
|
193
|
+
- Wheels are fetched only when the container's `allowOutbound` policy permits
|
|
194
|
+
it. A container that refuses `curl` cannot install packages.
|
|
195
|
+
- Every artifact is verified by SHA-256 against the index's published digest
|
|
196
|
+
before any of it is written.
|
|
197
|
+
- Extension code runs inside the same WebAssembly sandbox as the interpreter.
|
|
198
|
+
It has no more access to the host than Python does: the filesystem it sees
|
|
199
|
+
is the container's, served over the host ABI.
|
|
200
|
+
- The build toolchain is not shipped to consumers. The npm package contains
|
|
201
|
+
the runtime and the loader.
|
|
202
|
+
|
|
203
|
+
## "The ASGI callable works" is not "Uvicorn serves"
|
|
204
|
+
|
|
205
|
+
These are different claims, and only the first is proven.
|
|
206
|
+
|
|
207
|
+
**Proven.** FastAPI imports, Pydantic 2 validates a request body through the
|
|
208
|
+
compiled `pydantic_core` extension, and the application is driven through the
|
|
209
|
+
public ASGI protocol — an ordinary `await app(scope, receive, send)` — with the
|
|
210
|
+
response status and body asserted. Installing `fastapi[standard]` also installs
|
|
211
|
+
the FastAPI CLI, whose `fastapi dev main.py` and `fastapi run main.py` commands
|
|
212
|
+
are available. A synchronous endpoint is exercised, so Starlette's threadpool
|
|
213
|
+
path runs on real worker threads.
|
|
214
|
+
|
|
215
|
+
**Not proven.** `uvicorn main:app` binding a port that the container's
|
|
216
|
+
networking can route a request to. This is not merely untested; the layer is
|
|
217
|
+
missing, and the way it is missing is worth stating precisely because it looks
|
|
218
|
+
like success:
|
|
219
|
+
|
|
220
|
+
```python
|
|
221
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
222
|
+
s.bind(("127.0.0.1", 8123)); s.listen(1) # succeeds
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
`bind` and `listen` return cleanly, and `asyncio`'s event loop has
|
|
226
|
+
`create_server`. But that socket belongs to **Emscripten's own socket
|
|
227
|
+
emulation**, which is not connected to `VirtualHttpRouter` — the router that
|
|
228
|
+
already routes `curl localhost:3000` to a Node server in the same container. So
|
|
229
|
+
a server would appear to start, report itself listening, and never receive a
|
|
230
|
+
request. A test that asserted "uvicorn started" would pass while proving
|
|
231
|
+
nothing.
|
|
232
|
+
|
|
233
|
+
Closing this means bridging CPython's socket layer to the container network the
|
|
234
|
+
way `sbxfs.ts` bridges its filesystem: `listen` registers a virtual port with
|
|
235
|
+
the router, and an accepted connection becomes a socket whose reads and writes
|
|
236
|
+
cross the host ABI. It belongs with the socket layer, not with Uvicorn —
|
|
237
|
+
patching Uvicorn would make one server work and leave every other one broken.
|
|
238
|
+
|
|
239
|
+
Note also that `socket.SO_REUSEADDR` is absent from this build, which some
|
|
240
|
+
servers set unconditionally.
|
|
241
|
+
|
|
242
|
+
## A resolution behaviour worth knowing
|
|
243
|
+
|
|
244
|
+
`pip install pydantic` **succeeds** without a wheel index, by backtracking to
|
|
245
|
+
the 1.x line — which is pure Python and needs no compiled core. That is correct
|
|
246
|
+
of the resolver and is what pip does too, but it is unlikely to be what someone
|
|
247
|
+
asking for Pydantic today wants. Constrain the requirement (`pydantic>=2`) when
|
|
248
|
+
the 2.x line is what you mean; with the bound, an unavailable native dependency
|
|
249
|
+
is reported rather than worked around.
|
|
250
|
+
|
|
251
|
+
## Current limitations
|
|
252
|
+
|
|
253
|
+
- Building extensions requires Emscripten 5.0.6, a nightly Rust with
|
|
254
|
+
`rust-src`, and the CPython build tree. Consumers install prebuilt wheels.
|
|
255
|
+
- Source distributions are resolved but not built in-container; a resolution
|
|
256
|
+
that lands on one reports that no builder is configured, rather than
|
|
257
|
+
claiming an install. The optional local and remote builders described in the
|
|
258
|
+
architecture are not implemented.
|
|
259
|
+
- **Uvicorn cannot serve.** See the section above; the ASGI callable works.
|
|
260
|
+
- Emscripten documents dynamic linking with pthreads as experimental.
|
|
261
|
+
- The default package wheel index carries the ABI-matched Pydantic Core wheels
|
|
262
|
+
beside the interpreter. Consumers can override it with
|
|
263
|
+
`configurePython({ wheelIndex })` to add privately built extensions.
|
|
264
|
+
- `socket.SO_REUSEADDR` is absent from this build.
|
|
265
|
+
|
|
266
|
+
## Which tests prove what
|
|
267
|
+
|
|
268
|
+
| Claim | Test |
|
|
269
|
+
| --- | --- |
|
|
270
|
+
| A C extension imports and runs | `test/python-runtime/extensions.test.ts` |
|
|
271
|
+
| A PyO3 extension imports and runs | `test/python-runtime/extensions.test.ts` |
|
|
272
|
+
| The ABI id is derived, not hand-written | `test/python-runtime/extension-abi.test.ts` |
|
|
273
|
+
| Linux wheels are refused | `test/python-runtime/extension-abi.test.ts` |
|
|
274
|
+
| Resolution has no candidate cap | `test/python-runtime/resolver.test.ts` |
|
|
275
|
+
| Constraints propagate, conflicts explain | `test/python-runtime/resolver.test.ts` |
|
|
276
|
+
| A failed native install leaves nothing | `test/python-runtime/extensions.test.ts` |
|
|
277
|
+
| Real pydantic-core validates data | `test/python-runtime/fastapi.test.ts` |
|
|
278
|
+
| Pydantic 2 + FastAPI + ASGI + console script | `test/python-runtime/fastapi.test.ts` |
|
|
279
|
+
|
|
280
|
+
The FastAPI test needs `SANDBOXEDJS_CLEAN_NETWORK_TESTS=1`; the extension
|
|
281
|
+
tests need `make -C python-runtime extensions` to have been run, and skip with
|
|
282
|
+
a reason otherwise.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Release gates
|
|
2
|
+
|
|
3
|
+
A milestone is done when its gate passes, not when its code exists. Numbers here
|
|
4
|
+
are proposed targets, not measured results.
|
|
5
|
+
|
|
6
|
+
| Milestone | Deliverable | Gate | State |
|
|
7
|
+
|---|---|---|---|
|
|
8
|
+
| M0 | behaviour inventory | current failures reproducible and written down, integration failures told apart from interpreter failures | **done** — [baseline-inventory.md](baseline-inventory.md) |
|
|
9
|
+
| M1 | host ABI + C probe | interactive read, exact binary I/O, cancellation, no deadlock | **done** — `test/python-abi/` |
|
|
10
|
+
| M2 | source-built interpreter | scripts, CLI flags, two isolated programs concurrently | **done** — `test/python-runtime/`, and `pythoncheck.html` in the harness |
|
|
11
|
+
| M3 | files and processes | cross-runtime pipelines, correct EOF, cleanup, file consistency | descriptor layer and Emscripten FS bridge done; spawn, pipes into Python, and symlinks not started |
|
|
12
|
+
| M4 | environments | two independent venvs, offline and online wheel installs | **partial** — online wheels install transactionally into the owned interpreter and expose `console_scripts`; the package includes its ABI wheel index, but venvs, offline indexes, source builds, and per-environment site-packages remain |
|
|
13
|
+
| M5 | native packages | Pydantic core and one small extension survive repeated import/exit | **done for the current ABI** — C and PyO3 side-module wheels load through ordinary CPython imports; `pydantic==2.9.0` selects and runs with `pydantic-core==2.23.2` |
|
|
14
|
+
| M6 | local networking | echo server, concurrent connections, timeouts, port reuse | **partial** — the host-owned virtual TCP stack (`src/net/virtual-socket.ts`), its socket ABI family (`1792`), and router integration are done and cover `box.request()` to a non-JS listener plus cross-stack `EADDRINUSE` (`test/virtual-socket.test.ts`, `test/python-socket-abi.test.ts`, `test/universal-server.test.ts`); the CPython `_sbx_socket` adapter and `socket`/`selectors` patches are written but not yet built into the interpreter, so no Python process has bound a port through it |
|
|
15
|
+
| M7 | web serving | FastAPI validation, streaming, WebSocket, shutdown | **partial** — a real FastAPI/Pydantic 2 application validates and answers through ASGI; Uvicorn cannot yet serve through `box.request()` |
|
|
16
|
+
| M8 | threads | sync endpoints, AnyIO operations, thread cleanup | **partial** — pthread CPython, `asyncio.to_thread()`, and a synchronous FastAPI endpoint pass; churn/cleanup soak is outstanding |
|
|
17
|
+
| M9 | persistence | recovery after an interrupted write or install | not started |
|
|
18
|
+
| M10 | ML | correct inference, bounded memory, cancellation | not started |
|
|
19
|
+
| M11 | source builds | compile, install and import a representative C extension in-browser | not started |
|
|
20
|
+
| M12 | release | browser matrix, soak tests, rollback exercise | not started |
|
|
21
|
+
|
|
22
|
+
M6 still gates any broad FastAPI hosting claim. Importing the framework and
|
|
23
|
+
calling its public ASGI interface is useful evidence, but a publishable server
|
|
24
|
+
runtime must also route a real Uvicorn listener through `box.request()`. M5
|
|
25
|
+
still gates current Pydantic 2 releases because their Rust extension is native.
|
|
26
|
+
|
|
27
|
+
## Standing gates
|
|
28
|
+
|
|
29
|
+
Checked at every release, not once:
|
|
30
|
+
|
|
31
|
+
| Area | Gate |
|
|
32
|
+
|---|---|
|
|
33
|
+
| Process isolation | no module, global or environment leakage across independent processes |
|
|
34
|
+
| Process churn | 10,000 short launches with no growth in live resource counts |
|
|
35
|
+
| I/O | binary correctness, partial reads and writes, EOF, backpressure, Unicode terminal boundaries |
|
|
36
|
+
| Cancellation | kill succeeds during startup, blocked input, network wait, import and inference |
|
|
37
|
+
| Filesystem | rename, unlink and open semantics match the declared contract under concurrent access |
|
|
38
|
+
| Packaging | an interrupted install never reports a complete environment |
|
|
39
|
+
| Threads | repeated creation, blocking, imports and teardown do not deadlock |
|
|
40
|
+
| Storage | reload after an interrupted commit recovers a valid state |
|
|
41
|
+
| Node regression | every supported Node behaviour still passes |
|
|
42
|
+
|
|
43
|
+
Fault injection belongs in the same suite: worker crash mid-syscall, truncated
|
|
44
|
+
runtime assets, missing extension symbols, quota exhaustion, lost connections,
|
|
45
|
+
GPU device loss, cancellation during extraction, stale responses after PID
|
|
46
|
+
reuse, and teardown while children are blocked.
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
# Virtual sockets for Python servers
|
|
2
|
+
|
|
3
|
+
This is the implementation plan for making ordinary Python servers such as Uvicorn, FastAPI CLI, asyncio servers, and raw TCP servers reachable through the SandboxedJs container network.
|
|
4
|
+
|
|
5
|
+
## Status (2026-09-08)
|
|
6
|
+
|
|
7
|
+
- **Phase 0 — contract**: done. Socket ops occupy family `1792` in
|
|
8
|
+
[host-v1.json](../../python-runtime/abi/host-v1.json) /
|
|
9
|
+
[sbx_host.h](../../python-runtime/abi/sbx_host.h) /
|
|
10
|
+
[host-abi.ts](../../src/runtime/python/host-abi.ts); `AF_INET` + `SOCK_STREAM`
|
|
11
|
+
only; golden-frame coverage in [python-socket-abi.test.ts](../../test/python-socket-abi.test.ts).
|
|
12
|
+
- **Phase 1 — host virtual stream model**: done.
|
|
13
|
+
[src/net/virtual-socket.ts](../../src/net/virtual-socket.ts) holds the listener
|
|
14
|
+
backlog, duplex byte queues, EOF/half-close/reset transitions, and a shared
|
|
15
|
+
`VirtualTcpNetwork` port authority. Tests: [virtual-socket.test.ts](../../test/virtual-socket.test.ts).
|
|
16
|
+
- **Phase 2 — ABI dispatch and descriptors**: done. `SocketDescription`
|
|
17
|
+
implements `OpenFileDescription`, so `close`, `set_flags`, and `poll` reuse the
|
|
18
|
+
generic descriptor handlers; socket ops dispatch in
|
|
19
|
+
[syscall-server.ts](../../src/runtime/python/syscall-server.ts) with blocking
|
|
20
|
+
`accept`/`recv`/`send` that unpark on `whenReady()` and abort on process kill.
|
|
21
|
+
- **Phase 3 — CPython socket adapter**: written, not yet built.
|
|
22
|
+
[native/sbx/sbx_socket.c](../../python-runtime/native/sbx/sbx_socket.c), the
|
|
23
|
+
`socket.py` / `selectors.py` patches
|
|
24
|
+
([0003](../../python-runtime/patches/cpython/0003-sandboxedjs-host-sockets.patch),
|
|
25
|
+
[0004](../../python-runtime/patches/cpython/0004-sandboxedjs-selector-poll.patch)),
|
|
26
|
+
and the `build_python.py` wiring exist, but the interpreter has not been
|
|
27
|
+
recompiled with `_sbx_socket`, so no Python process has bound a port.
|
|
28
|
+
- **Phase 5 — router/client integration**: done for the non-JS path.
|
|
29
|
+
`LocalRuntimePod.request()` falls through to `requestTcp()` for ports held by
|
|
30
|
+
the socket stack, `proxy.activePorts()` merges both tables, and
|
|
31
|
+
`VirtualHttpRouter.register()` rejects a port already held by a socket
|
|
32
|
+
listener. Tests: [universal-server.test.ts](../../test/universal-server.test.ts).
|
|
33
|
+
- **Phases 4 and 6 — asyncio/Uvicorn, hardening**: not started; blocked on
|
|
34
|
+
Phase 3 build.
|
|
35
|
+
|
|
36
|
+
Next step: rebuild the dynamic CPython profile
|
|
37
|
+
(`python-runtime/scripts/build_python.py`) and add the raw-TCP echo and
|
|
38
|
+
`asyncio.start_server` acceptance tests from the list below.
|
|
39
|
+
|
|
40
|
+
## Goal
|
|
41
|
+
|
|
42
|
+
This must work without changing FastAPI, Starlette, Uvicorn, or application code:
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
pip install "fastapi[standard]"
|
|
46
|
+
fastapi run main.py
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Then the host API must reach it through the same port table used by Node:
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
await box.waitForPort(8000)
|
|
53
|
+
const response = await box.request(8000, { path: "/docs" })
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The Python process remains CPython compiled to WebAssembly. FastAPI and Uvicorn remain ordinary Python packages. Only the socket implementation and its host bridge are added.
|
|
57
|
+
|
|
58
|
+
## Current topology
|
|
59
|
+
|
|
60
|
+
```mermaid
|
|
61
|
+
flowchart LR
|
|
62
|
+
A[Python Uvicorn] --> B[CPython socket API]
|
|
63
|
+
B --> C[Emscripten socket emulation]
|
|
64
|
+
C -. not connected .-> D[VirtualHttpRouter]
|
|
65
|
+
N[Node http.createServer] --> D
|
|
66
|
+
D --> E[box.request / curl / preview]
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Node works because its HTTP implementation calls `VirtualHttpRouter.register()` directly. Python currently calls Emscripten sockets, which can appear to bind but cannot deliver connections to the SandboxedJs router.
|
|
70
|
+
|
|
71
|
+
## Target topology
|
|
72
|
+
|
|
73
|
+
```mermaid
|
|
74
|
+
flowchart LR
|
|
75
|
+
A[Python Uvicorn] --> B[CPython socket API]
|
|
76
|
+
B --> C[Emscripten/libc socket adapter]
|
|
77
|
+
C --> D[sbx_host socket operations]
|
|
78
|
+
D --> E[syscall-server.ts]
|
|
79
|
+
E --> F[NetworkStack / VirtualHttpRouter]
|
|
80
|
+
F --> G[box.request / curl / preview]
|
|
81
|
+
F --> H[accepted virtual connection]
|
|
82
|
+
H --> D
|
|
83
|
+
D --> C
|
|
84
|
+
C --> B
|
|
85
|
+
B --> A
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The host owns all shared socket state. The WASM process owns only its descriptor numbers and Python-visible socket objects.
|
|
89
|
+
|
|
90
|
+
## Design decisions
|
|
91
|
+
|
|
92
|
+
### 1. Implement virtual sockets, not a FastAPI adapter
|
|
93
|
+
|
|
94
|
+
A FastAPI-specific bridge would make one framework work while leaving every other Python server broken. The correct abstraction is POSIX-like sockets behind the existing host ABI. Uvicorn, asyncio, Flask adapters, Django, WebSockets, and raw TCP programs then use the same path.
|
|
95
|
+
|
|
96
|
+
### 2. Use the existing descriptor table
|
|
97
|
+
|
|
98
|
+
Sockets must be normal process handles in `ProcessDescriptorTable`, with `get_flags`, `set_flags`, `dup`, `dup2`, and `poll` behavior. Do not create a second socket-only handle registry that bypasses descriptor lifecycle.
|
|
99
|
+
|
|
100
|
+
### 3. Keep HTTP parsing in the Python server
|
|
101
|
+
|
|
102
|
+
The router should transport bytes/connections, not parse HTTP for Python. Uvicorn must receive a real accepted stream and remain responsible for HTTP/1.1, keep-alive, chunking, and WebSocket upgrades. `box.request()` may provide a convenience HTTP client, but it must ultimately feed the same connection abstraction.
|
|
103
|
+
|
|
104
|
+
### 4. Separate listener registration from connection delivery
|
|
105
|
+
|
|
106
|
+
`bind()` and `listen()` register a listener with the container port authority. `accept()` consumes pending connections. A listener is not considered complete merely because `bind()` returned successfully.
|
|
107
|
+
|
|
108
|
+
### 5. Preserve the blocking contract
|
|
109
|
+
|
|
110
|
+
A Python `accept()`, `recv()`, or `send()` may wait. The Python worker may block on the synchronous transport while the host event loop continues routing requests and resolving readiness. This follows the existing ABI rule documented in [abi.md](abi.md).
|
|
111
|
+
|
|
112
|
+
## Existing code to wire
|
|
113
|
+
|
|
114
|
+
### ABI and generated bindings
|
|
115
|
+
|
|
116
|
+
- [host-v1.json](../../python-runtime/abi/host-v1.json): add socket operation codes in the reserved family `1792`.
|
|
117
|
+
- [generate_abi.py](../../python-runtime/scripts/generate_abi.py): ensure generated C/TypeScript bindings include the new operations and payload definitions if the generator has schema-specific output.
|
|
118
|
+
- [sbx_host.h](../../python-runtime/abi/sbx_host.h): generated C operation constants.
|
|
119
|
+
- [host-abi.ts](../../src/runtime/python/host-abi.ts): generated `Op` constants and capability list.
|
|
120
|
+
- [protocol.ts](../../src/runtime/python/protocol.ts): reuse existing framing; only socket payload readers/writers are needed.
|
|
121
|
+
|
|
122
|
+
Initial operations:
|
|
123
|
+
|
|
124
|
+
| Operation | Purpose |
|
|
125
|
+
|---|---|
|
|
126
|
+
| `socket` | Allocate a stream socket handle with family/type/protocol. |
|
|
127
|
+
| `bind` | Bind a socket to an address and port. |
|
|
128
|
+
| `listen` | Mark a bound socket as a listener with a backlog. |
|
|
129
|
+
| `accept` | Return an accepted connection handle and peer address. |
|
|
130
|
+
| `connect` | Connect to an in-container listener or permitted outbound target. |
|
|
131
|
+
| `send` | Send bytes, allowing short writes. |
|
|
132
|
+
| `recv` | Receive bytes, allowing short reads and EOF. |
|
|
133
|
+
| `shutdown` | Close one or both directions. |
|
|
134
|
+
| `getsockname` | Return local address and port. |
|
|
135
|
+
| `getpeername` | Return remote address and port. |
|
|
136
|
+
| `setsockopt` | At minimum support `SO_REUSEADDR`, `SO_KEEPALIVE`, and TCP options Uvicorn touches. |
|
|
137
|
+
| `getsockopt` | Return values for options that libraries inspect. |
|
|
138
|
+
|
|
139
|
+
Do not add UDP, IPv6, DNS, or arbitrary outbound TCP in the first slice unless a failing acceptance test requires them. Uvicorn HTTP and WebSocket serving need TCP listener/accepted-stream semantics first.
|
|
140
|
+
|
|
141
|
+
### Host client/server
|
|
142
|
+
|
|
143
|
+
- [syscall-client.ts](../../src/runtime/python/syscall-client.ts): add typed methods for socket operations.
|
|
144
|
+
- [syscall-server.ts](../../src/runtime/python/syscall-server.ts): dispatch operations, validate process generation, and map errors to canonical errno.
|
|
145
|
+
- [descriptors.ts](../../src/kernel/descriptors.ts): confirm duplicated socket descriptions share state and close behavior.
|
|
146
|
+
- [open-file.ts](../../src/kernel/open-file.ts): add a socket description abstraction or a common readiness/close interface if sockets cannot use the current file description directly.
|
|
147
|
+
- [python/worker-entry.ts](../../src/runtime/python/worker-entry.ts): no Python-specific routing should be added here; it already owns the CPython worker and host transport lifecycle.
|
|
148
|
+
|
|
149
|
+
### Network authority
|
|
150
|
+
|
|
151
|
+
- [stack.ts](../../src/net/stack.ts): make listener registration and connection creation use the same authority as Node server ports.
|
|
152
|
+
- [virtual-http.ts](../../src/runtime/virtual-http.ts): extract or reuse a byte-stream connection primitive for request/response and upgrades. Do not force Python into `VirtualHttpServer`; Python needs accepted sockets, not Node EventEmitter semantics.
|
|
153
|
+
- [contracts.ts](../../src/runtime/contracts.ts): extend `RuntimePod`/network contracts only where the host needs a bidirectional accepted connection. Existing `serveExternal` is a possible compatibility seam but is not sufficient for general sockets.
|
|
154
|
+
- [worker-runtime-pod.ts](../../src/runtime/worker-runtime-pod.ts): preserve the existing Node worker proxy and add Python socket events/connection IDs if the Python worker is hosted in a worker that cannot call the host directly.
|
|
155
|
+
- [local-runtime-pod.ts](../../src/runtime/local-runtime-pod.ts): implement the same socket behavior for realm mode or explicitly report that Python server tests require the worker pod.
|
|
156
|
+
|
|
157
|
+
### CPython/Emscripten side
|
|
158
|
+
|
|
159
|
+
- [extension-abi.json](../../python-runtime/abi/extension-abi.json): add any required main-module exports and document the socket adapter contract.
|
|
160
|
+
- [library_sbx_posix.js](../../python-runtime/native/js/library_sbx_posix.js): add only libc calls that cannot be redirected through the normal Emscripten syscall path. Do not hide socket behavior in JavaScript globals.
|
|
161
|
+
- [0002-emscripten-pipe-socketpair.patch](../../python-runtime/patches/cpython/0002-emscripten-pipe-socketpair.patch): keep the existing socketpair workaround for local pipes; do not confuse it with network sockets.
|
|
162
|
+
- Add a CPython patch or Emscripten syscall adapter for `socket`, `bind`, `listen`, `accept`, `connect`, `send`, `recv`, `poll`, and socket options. Prefer one libc-level adapter so Python's `socket`, `selectors`, and `asyncio` all see the same semantics.
|
|
163
|
+
- [python-syscalls.ts](../../src/runtime/python-syscalls.ts): the old Pyodide backend's `create_server` refusal remains separate. Do not make the new CPython backend depend on Pyodide's asyncio patches.
|
|
164
|
+
|
|
165
|
+
## Connection protocol
|
|
166
|
+
|
|
167
|
+
Use a host-owned `SocketDescription` with these states:
|
|
168
|
+
|
|
169
|
+
```text
|
|
170
|
+
created -> bound -> listening -> closed
|
|
171
|
+
created -> connected -> open -> half-closed/closed
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Each connection needs:
|
|
175
|
+
|
|
176
|
+
- owning process generation;
|
|
177
|
+
- local and peer address/port;
|
|
178
|
+
- receive queue and byte count;
|
|
179
|
+
- send backpressure state;
|
|
180
|
+
- readable/writable/closed waiters;
|
|
181
|
+
- listener backlog queue;
|
|
182
|
+
- cancellation/teardown behavior;
|
|
183
|
+
- reference count for duplicated descriptors.
|
|
184
|
+
|
|
185
|
+
Suggested host payloads are fixed-width fields plus length-prefixed byte arrays, matching the existing `Writer`/`Reader` framing. Addresses should initially be normalized to IPv4 strings and `u16` ports. Return the accepted descriptor and peer address from `accept`.
|
|
186
|
+
|
|
187
|
+
### Incoming request path
|
|
188
|
+
|
|
189
|
+
1. `box.request(8000, init)` asks the router for port 8000.
|
|
190
|
+
2. The router finds the Python listener registered by `listen()`.
|
|
191
|
+
3. The host creates a virtual TCP connection and queues it on the listener.
|
|
192
|
+
4. A blocked Python `accept()` becomes readable through `poll`.
|
|
193
|
+
5. Python/Uvicorn accepts the descriptor and reads the serialized HTTP request bytes.
|
|
194
|
+
6. Uvicorn writes HTTP response bytes to the accepted descriptor.
|
|
195
|
+
7. The router resolves `box.request()` from the response stream.
|
|
196
|
+
8. Connection close or keep-alive is handled by the same stream state, not by a Python-specific shortcut.
|
|
197
|
+
|
|
198
|
+
### Outbound local path
|
|
199
|
+
|
|
200
|
+
For `curl localhost:port` or Python `http.client` to a Node/Python listener:
|
|
201
|
+
|
|
202
|
+
1. `connect()` resolves the address through the container network authority.
|
|
203
|
+
2. The host locates the listener in the shared port table.
|
|
204
|
+
3. It creates a pair of virtual stream endpoints.
|
|
205
|
+
4. The client descriptor and server listener's accepted descriptor reference opposite endpoints.
|
|
206
|
+
5. `send`/`recv` and `poll` operate on those queues.
|
|
207
|
+
|
|
208
|
+
### External outbound path
|
|
209
|
+
|
|
210
|
+
Do not silently map Python TCP to host TCP. Start with the existing HTTP/fetch service for outbound HTTP. Add arbitrary outbound TCP only after capability and security policy are defined. `allowOutbound`, host allowlists, cancellation, and DNS behavior must apply consistently.
|
|
211
|
+
|
|
212
|
+
## Implementation phases
|
|
213
|
+
|
|
214
|
+
### Phase 0: freeze the contract
|
|
215
|
+
|
|
216
|
+
- Define socket operation numbers and payload schemas.
|
|
217
|
+
- Define descriptor ownership, backlog, half-close, timeout, and cancellation semantics.
|
|
218
|
+
- Define supported families: initially `AF_INET` + `SOCK_STREAM`.
|
|
219
|
+
- Define unsupported behavior explicitly: UDP, IPv6, raw sockets, arbitrary outbound TCP.
|
|
220
|
+
- Add ABI version/golden-frame tests before implementation.
|
|
221
|
+
|
|
222
|
+
### Phase 1: host-side virtual stream model
|
|
223
|
+
|
|
224
|
+
- Add `SocketDescription` and listener/backlog state.
|
|
225
|
+
- Connect it to the existing port authority and router.
|
|
226
|
+
- Implement in-memory duplex queues with `whenReady()` and EOF/error transitions.
|
|
227
|
+
- Ensure teardown closes listeners and all accepted connections.
|
|
228
|
+
- Add tests for bind conflicts, backlog, short reads/writes, EOF, half-close, reset, and port reuse.
|
|
229
|
+
|
|
230
|
+
### Phase 2: ABI dispatch and descriptor integration
|
|
231
|
+
|
|
232
|
+
- Generate operation constants.
|
|
233
|
+
- Implement client methods and server dispatch.
|
|
234
|
+
- Add socket handles to descriptor tables.
|
|
235
|
+
- Implement `poll` readiness for listener-readable, connection-readable, connection-writable, hangup, and error.
|
|
236
|
+
- Test stale process generations and killed workers with blocked socket operations.
|
|
237
|
+
|
|
238
|
+
### Phase 3: CPython socket adapter
|
|
239
|
+
|
|
240
|
+
- Patch/build the dynamic CPython profile with the host socket adapter.
|
|
241
|
+
- Make `socket.socket(AF_INET, SOCK_STREAM)` allocate host handles.
|
|
242
|
+
- Implement Python-visible `fileno`, blocking/nonblocking mode, timeout, `accept`, `makefile`, `sendall`, `recv`, `shutdown`, `getsockname`, and `getpeername`.
|
|
243
|
+
- Implement enough socket constants/options for Uvicorn and asyncio, including `SO_REUSEADDR`.
|
|
244
|
+
- Verify `selectors.DefaultSelector` observes the host readiness fd model.
|
|
245
|
+
|
|
246
|
+
### Phase 4: asyncio and Uvicorn
|
|
247
|
+
|
|
248
|
+
- Run an ordinary `asyncio.start_server` echo server.
|
|
249
|
+
- Run `uvicorn main:app` and `fastapi run main.py` without adapters.
|
|
250
|
+
- Verify startup, shutdown, keep-alive, request body, response body, and malformed requests.
|
|
251
|
+
- Confirm the process remains alive while the listener is referenced and exits after close.
|
|
252
|
+
|
|
253
|
+
### Phase 5: router and client integration
|
|
254
|
+
|
|
255
|
+
- Make `box.waitForPort()` recognize Python listeners.
|
|
256
|
+
- Make `box.request()` send a real HTTP byte stream to the accepted Python socket.
|
|
257
|
+
- Make `curl localhost:port` use the same local virtual connection path.
|
|
258
|
+
- Add `box.expose()` support only after in-container request routing works.
|
|
259
|
+
- Add WebSocket upgrade tests using the same accepted stream abstraction.
|
|
260
|
+
|
|
261
|
+
### Phase 6: hardening and release
|
|
262
|
+
|
|
263
|
+
- Stress repeated server start/stop and connection churn.
|
|
264
|
+
- Test concurrent requests and backpressure.
|
|
265
|
+
- Test cancellation while blocked in `accept`, `recv`, `send`, and `poll`.
|
|
266
|
+
- Test browser worker and Node worker modes.
|
|
267
|
+
- Test cross-origin isolation requirements for browser hosts.
|
|
268
|
+
- Update M6/M7 release gates only after real `box.request()` and Uvicorn tests pass.
|
|
269
|
+
|
|
270
|
+
## Acceptance tests
|
|
271
|
+
|
|
272
|
+
The first end-to-end test should be a small Python TCP echo server. The second should be HTTP over Uvicorn:
|
|
273
|
+
|
|
274
|
+
```ts
|
|
275
|
+
const box = await createContainer({
|
|
276
|
+
workerUrl: WORKER_URL,
|
|
277
|
+
files: {
|
|
278
|
+
"/workspace/main.py": `
|
|
279
|
+
from fastapi import FastAPI
|
|
280
|
+
|
|
281
|
+
app = FastAPI()
|
|
282
|
+
|
|
283
|
+
@app.get("/health")
|
|
284
|
+
def health():
|
|
285
|
+
return {"ok": True}
|
|
286
|
+
`,
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
await box.exec('pip install "fastapi[standard]"', { timeoutMs: 300_000 });
|
|
291
|
+
const process = box.spawn("fastapi run /workspace/main.py --port 8000");
|
|
292
|
+
try {
|
|
293
|
+
expect(await box.waitForPort(8000, { timeoutMs: 60_000 })).toBe(true);
|
|
294
|
+
const response = await box.request(8000, { method: "GET", path: "/health" });
|
|
295
|
+
expect(response.status).toBe(200);
|
|
296
|
+
expect(response.json()).toEqual({ ok: true });
|
|
297
|
+
} finally {
|
|
298
|
+
process.kill();
|
|
299
|
+
box.dispose();
|
|
300
|
+
}
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
Required test groups:
|
|
304
|
+
|
|
305
|
+
- ABI encoding/decoding and capability handshake.
|
|
306
|
+
- Descriptor lifecycle and duplicated descriptors.
|
|
307
|
+
- Listener registration and port conflicts.
|
|
308
|
+
- Readiness and blocking behavior.
|
|
309
|
+
- Raw TCP echo.
|
|
310
|
+
- Node-to-Python and Python-to-Node local connections.
|
|
311
|
+
- Uvicorn/FastAPI GET and POST validation.
|
|
312
|
+
- Keep-alive and concurrent requests.
|
|
313
|
+
- WebSocket upgrade.
|
|
314
|
+
- Cancellation and teardown.
|
|
315
|
+
- Browser worker and Node worker.
|
|
316
|
+
|
|
317
|
+
## Definition of done
|
|
318
|
+
|
|
319
|
+
The feature is complete only when all of these are true:
|
|
320
|
+
|
|
321
|
+
- `fastapi run main.py` is found after installing `fastapi[standard]`.
|
|
322
|
+
- The command binds a port through the SandboxedJs socket ABI, not a fake success path.
|
|
323
|
+
- `ss -ltn` reports the Python listener with the right owner/PID metadata.
|
|
324
|
+
- `box.waitForPort()` returns true because a real request can reach the listener.
|
|
325
|
+
- `box.request()` receives a real Uvicorn response.
|
|
326
|
+
- `curl localhost:8000` reaches the same Python server.
|
|
327
|
+
- Node and Python servers share port conflict and lifecycle behavior.
|
|
328
|
+
- Killing the Python process unblocks and closes all accepted connections.
|
|
329
|
+
- No host socket or listener remains after container disposal.
|
|
330
|
+
- Browser and Node worker modes pass the same core tests.
|
|
331
|
+
- The old Pyodide backend remains unchanged and does not accidentally claim this capability.
|