sandboxedjs 0.2.10 → 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.
Files changed (43) hide show
  1. package/README.md +96 -63
  2. package/assets/logo.png +0 -0
  3. package/bin/sandboxedjs-egress.mjs +25 -10
  4. package/dist/index.cjs +1 -1
  5. package/dist/index.js +1 -1
  6. package/dist/service-worker.js +3 -2
  7. package/docs/agent/COMMANDS.md +85 -0
  8. package/docs/agent/DECISION-TREE.md +84 -0
  9. package/docs/agent/INVARIANTS.md +40 -0
  10. package/docs/agent/LAUNCH-PROMPT.md +37 -0
  11. package/docs/agent/LOOP.md +84 -0
  12. package/docs/agent/README.md +77 -0
  13. package/docs/agent/ROADMAP.md +37 -0
  14. package/docs/agent/STATE.md +93 -0
  15. package/docs/agent/tasks/00-verify-inherited-work.md +36 -0
  16. package/docs/agent/tasks/01-authoritative-metadata.md +34 -0
  17. package/docs/agent/tasks/02-native-dependencies.md +35 -0
  18. package/docs/agent/tasks/03-reproducible-inputs.md +27 -0
  19. package/docs/agent/tasks/04-build-frontends.md +27 -0
  20. package/docs/agent/tasks/05-registry-integration.md +27 -0
  21. package/docs/agent/tasks/06-package-cohorts.md +42 -0
  22. package/docs/agent/tasks/07-build-on-miss-boundary.md +31 -0
  23. package/docs/browser-runtime-architecture.md +142 -0
  24. package/docs/compatibility-implementation-plan.md +98 -0
  25. package/docs/developer-tool-packs.md +134 -0
  26. package/docs/frontend-automation.md +49 -0
  27. package/docs/fullstack-deployment.md +163 -0
  28. package/docs/handoff.md +275 -0
  29. package/docs/original-x64.md +39 -0
  30. package/docs/platform-hardening.md +49 -0
  31. package/docs/python/abi.md +97 -0
  32. package/docs/python/architecture.md +94 -0
  33. package/docs/python/baseline-inventory.md +54 -0
  34. package/docs/python/build-on-miss.md +198 -0
  35. package/docs/python/compatibility.md +206 -0
  36. package/docs/python/cross-build.md +354 -0
  37. package/docs/python/extensions.md +282 -0
  38. package/docs/python/release-gates.md +46 -0
  39. package/docs/python/virtual-sockets-plan.md +331 -0
  40. package/docs/runtime-lifecycle-fixes.md +39 -0
  41. package/docs/server-previews.md +268 -0
  42. package/docs/virtual-browser.md +120 -0
  43. package/package.json +5 -3
@@ -0,0 +1,354 @@
1
+ # Cross-building native extensions
2
+
3
+ The pipeline that turns a package's own source into a SandboxedJs wheel, and
4
+ the list of things it still assumes about a package — so each of them can be
5
+ removed deliberately rather than discovered by a failure.
6
+
7
+ ## What it does
8
+
9
+ `python-runtime/scripts/build_extension.py` takes a recipe and produces a
10
+ wheel tagged `cp313-cp313-emscripten_5_0_6_wasm32` with a `Build-ABI` line
11
+ naming the `abiId` computed from `abi/extension-abi.json`. Three recipe kinds
12
+ exist:
13
+
14
+ | kind | source of truth for *what* to build | covers |
15
+ | --- | --- | --- |
16
+ | `setuptools` | the package's own `setup.py` / `pyproject.toml` | plain C extensions, Cython, anything setuptools builds |
17
+ | `pep517` | the package's declared build backend and hooks | pyproject-only projects, in-tree backends |
18
+ | `meson` | the project's `meson.build`, via meson-python | Meson projects, including NumPy |
19
+ | `pyo3` | the crate's `Cargo.toml` | Rust extensions |
20
+ | `c-extension` | the recipe's own `sources` list | probes only — see the assumptions below |
21
+
22
+ `kind` selects a backend explicitly; nothing is inferred from which files are
23
+ present. A project with both a `setup.py` and a `pyproject.toml` can be built
24
+ either way, and guessing would silently change how a package is built the day
25
+ upstream adds a file. Recipes are schema-validated before anything is fetched
26
+ or compiled, so a malformed one costs a second rather than a Rust build.
27
+
28
+ In every case the *how* — compiler, target, PIC, threads, exceptions,
29
+ extension suffix, wheel tag — comes from `abi/extension-abi.json` and only
30
+ from there. A recipe cannot contribute a compile or link flag. This is not
31
+ tidiness: a recipe that could set its own `-pthread` could produce an artifact
32
+ that links against a main module with a different memory model and then
33
+ corrupts memory, and the mismatch would be invisible in the wheel.
34
+
35
+ ## How the setuptools path works
36
+
37
+ `sysconfig` loads its table of build variables from a module named by the
38
+ `_PYTHON_SYSCONFIGDATA_NAME` environment variable, and
39
+ `distutils.command.build_ext` — which setuptools vendors — takes the compiler,
40
+ its flags and the extension suffix from that table. So the package's own
41
+ `setup.py build` runs on the *build machine's* interpreter with `sysconfig`
42
+ answering for the *target*. This is the mechanism CPython uses for its own
43
+ cross builds and the one `crossenv` automates; nothing about it is specific to
44
+ this project.
45
+
46
+ The CPython cross build already writes the target's table
47
+ (`_sysconfigdata__emscripten_wasm32-emscripten.py`). `scripts/crossenv.py`
48
+ copies it and overrides exactly three groups of values, all from the ABI
49
+ contract:
50
+
51
+ * `INCLUDEPY` / `CONFINCLUDEPY` — the shipped table names
52
+ `$prefix/include/python3.13`, where the headers would be if the interpreter
53
+ had been installed. It has not been; they are in the source tree.
54
+ * `LDSHARED` / `BLDSHARED` / `LDCXXSHARED` — the shipped link line has no
55
+ `-sSIDE_MODULE=1`, because CPython adds it in a rule outside `LDSHARED`.
56
+ Without it the artifact is a complete program and `dlopen` cannot load it.
57
+ * `CCSHARED` — the ABI's `-pthread -fwasm-exceptions -fPIC`, which must match
58
+ the main module exactly. `CCSHARED` rather than `CFLAGS` so the flags apply
59
+ to extension objects and not to host tools a package builds during setup.
60
+
61
+ Cython needs no support at all: `cythonize()` runs inside the package's
62
+ `setup.py` on the build machine and hands generated C to the same `build_ext`.
63
+ The only difference between the plain and Cython fixtures is a pinned
64
+ `buildRequires`.
65
+
66
+ ## Validation
67
+
68
+ Three checks, in order of how misleading their failure would otherwise be:
69
+
70
+ 1. `verify_toolchain` refuses an Emscripten other than the pinned one. A side
71
+ module built by a different Emscripten links and then fails at run time.
72
+ 2. `verify_side_module` checks the artifact is a WebAssembly binary, has a
73
+ `dylink` section, and exports `PyInit_<last component of the module name>`.
74
+ A missing dylink section fails at `dlopen` with a message about the file; a
75
+ missing init symbol fails with "dynamic module does not define module
76
+ export function", which reads like a source error and is in fact a link
77
+ setting.
78
+ 3. `test/python-runtime/extensions.test.ts` installs each wheel from an HTTP
79
+ index with the container's own `pip` and imports it with CPython's ordinary
80
+ import machinery. This is the only check that proves the ABI, because the
81
+ first two can pass on a module compiled against the wrong headers.
82
+
83
+ ## Package-specific assumptions, and how to remove each
84
+
85
+ Each of these is a place the pipeline knows, or requires a recipe to state,
86
+ something a package already states about itself.
87
+
88
+ **1. Target dependencies are not resolved.** *Resolved.* Native target
89
+ dependencies are declared by `dependency.json` manifests under
90
+ `python-runtime/native-deps/` (real libraries) and
91
+ `python-runtime/fixtures/native/` (test ones), built by
92
+ `scripts/native_deps.py` into the profile's sysroot with the ABI's own flags,
93
+ and reached by extensions through the generated cross configuration — so
94
+ `libraries=["yaml"]` in a package's `setup.py` resolves with no path or flag
95
+ in the recipe. A recipe names what it needs in `nativeRequires`, which is a
96
+ separate namespace from `requires` (Python) and `buildRequires` (build
97
+ machine) so that a build-machine library can never satisfy a target link.
98
+
99
+ Reuse is decided by a stamp recording the source digest, profile, exact
100
+ compiler flags and the digest of every installed output — not by whether a
101
+ file exists, which is what let an archive built for one profile satisfy
102
+ another. What remains: only `make` and `configure` build systems are
103
+ supported, and CMake and Meson libraries will need a third; and the libraries
104
+ CPython itself links are still built by the older hand-written
105
+ `scripts/build_dependencies.py` rather than through manifests.
106
+
107
+ **2. Wheel metadata is synthesized from the recipe on the `pyo3` path only.**
108
+ *Resolved for `setuptools`.* That path now runs the package's own `dist_info`
109
+ under the cross environment and ships the `METADATA` it generates, so markers,
110
+ extras and `Requires-Python` are upstream's and a recipe has no `requires`
111
+ field to drift from them. Each wheel records which of the two it used in its
112
+ `WHEEL` file as `Metadata-Source: package|recipe`, so the remaining cases are
113
+ visible in the artifacts rather than only here.
114
+
115
+ A recipe may still *remove* a requirement through `dependencyOverrides`, which
116
+ must carry a `because`, must name a requirement the package actually has, and
117
+ is recorded in the built wheel as `Build-Dropped-Requirement`. *Remove the
118
+ remaining `recipe` cases by:* reading `[project]` from the crate's
119
+ `pyproject.toml` for maturin-built extensions, or by driving maturin itself
120
+ under the cross environment.
121
+
122
+ **3. `pythonRoots` exists only because the `pyo3` path has no `build_py`.** It
123
+ is a hand-written statement of which directory holds the package's Python
124
+ half; the pydantic-core recipe encodes maturin's `python/` convention. *Remove
125
+ by:* reading `[tool.maturin] python-source` from the crate's `pyproject.toml`,
126
+ or by driving maturin itself under the cross environment.
127
+
128
+ **4. The `pyo3` path declares module layout.** `crateName` and `package` state
129
+ where the artifact must sit for `from ._x import ...` to work. The setuptools
130
+ path derives the same thing from the build tree. *Remove by:* same as 3.
131
+
132
+ **5. The `pyo3` recipe hard-codes an unpacked source path.**
133
+ `recipes/pydantic-core/recipe.json` names
134
+ `../../out/ports/pydantic_core-2.46.5`, duplicating the version that appears
135
+ three other times in the same file. *Remove by:* deriving the crate path from
136
+ `source.unpackTo` and the archive's own stem, which `fetch_source` already
137
+ computes.
138
+
139
+ **6. `kind: "c-extension"` is a transcription.** The recipe lists sources the
140
+ package's build system already lists, so the two can disagree silently. It is
141
+ retained only for `fixtures/sbx_c_probe`, which deliberately has no build
142
+ system. *Remove by:* giving the fixture a `setup.py` and deleting the kind and
143
+ `compile_c_module` with it.
144
+
145
+ **7. `setup.py` is assumed to be cross-safe.** *Mitigated, not removable.* A
146
+ `setup.py` that probes the build machine (compiles a test program, runs the
147
+ extension it is building, reads `platform.machine()`) will describe the build
148
+ machine. Nothing detects this; the build succeeds and produces a wrong
149
+ artifact. It is a property of the package, so it cannot be fixed generically —
150
+ but it now has one designated place to be fixed in. A recipe declares
151
+ `patches: {"dir": …}`, and the patch set states the package, the version, the
152
+ digest of the source tree it was written against, and per patch a reason, the
153
+ target fact being substituted, and the test that proves it. Patches apply to a
154
+ staged copy with `git apply` at exact context; a drifted source is refused
155
+ rather than fuzzily patched. `fixtures/sbx_patched_probe` is exactly this
156
+ failure — `platform.machine()` declaring 64-bit pointers for a 32-bit target —
157
+ and its patch is the worked example.
158
+
159
+ **8. Build tools come from PyPI at first build.** *Resolved.*
160
+ `build-tools.lock` pins every build tool — including `setuptools` itself, which
161
+ was previously taken from whatever the build machine had — and records the
162
+ sha256 of every distribution published for that version, so one lock serves any
163
+ build machine. Installation uses `--require-hashes`, which also refuses a tool
164
+ that grows an unpinned dependency. Regenerate with
165
+ `scripts/lock_build_tools.py`; a build reads the lock and never refreshes it.
166
+
167
+ **9. One ABI, no matrix.** `abiId` covers exactly one configuration; there is
168
+ no way to build the same recipe for a second profile. *Remove by:* taking the
169
+ profile from the command line into the wheel's local version segment, once a
170
+ second profile exists to want it.
171
+
172
+ **10. `--build-lib` is assumed to be honoured.** Projects with a custom
173
+ `build` command that ignores it would stage nowhere the collector looks; the
174
+ build "succeeds" and produces no modules. This is caught — the empty-module
175
+ case raises — but only after the fact.
176
+
177
+ ## Native target dependencies
178
+
179
+ Three kinds of dependency exist and are deliberately kept in three namespaces,
180
+ because a single list would let a build machine's library satisfy a target
181
+ link — producing a module that links cleanly and then faults:
182
+
183
+ | recipe field | what it is | where it goes |
184
+ | --- | --- | --- |
185
+ | `requires` | Python runtime dependency | installed in the container, imported |
186
+ | `buildRequires` | build-machine tool (Cython, setuptools_scm) | a per-recipe venv on this machine |
187
+ | `nativeRequires` | C library cross compiled for wasm32 | `out/sysroot-<profile>`, linked into the side module |
188
+
189
+ A native dependency is declared by a `dependency.json`:
190
+
191
+ ```json
192
+ {
193
+ "name": "yaml", "version": "0.2.5",
194
+ "license": "MIT", "licenseFiles": ["License"],
195
+ "provenance": { "url": "…", "sha256": "…", "unpackDir": "yaml-0.2.5" },
196
+ "profiles": ["dynamic"], "dependsOn": [],
197
+ "build": { "system": "configure", "configureArgs": ["--disable-shared"] },
198
+ "outputs": { "libraries": ["libyaml.a"], "headers": ["yaml.h"] }
199
+ }
200
+ ```
201
+
202
+ `name` must be the name a linker is given (`-lyaml`), not the project's title:
203
+ a manifest called `libyaml` would build the right library and leave the
204
+ extension unable to ask for it.
205
+
206
+ `outputs` is checked after the build and recorded in the stamp. A build system
207
+ that quietly produces nothing — a `configure` that disabled the library, a
208
+ `make` that built only tools — otherwise looks like a success until an
209
+ extension fails to link with a message about a missing symbol.
210
+
211
+ Provenance is either `local` (source in this repository, still digested so an
212
+ edit rebuilds) or a pinned `url` and `sha256`, verified before anything is
213
+ unpacked. These are kept out of `sources.lock` on purpose: that file pins what
214
+ the *interpreter* links, and a library that exists for packages should not
215
+ become something the runtime build has to download.
216
+
217
+ `build.host` exists because autotools packages vendor a `config.sub` frozen at
218
+ their release date. libyaml 0.2.5's is from 2018 and rejects
219
+ `wasm32-unknown-emscripten` outright; it accepts `wasm32-unknown-none`, which
220
+ is sufficient because `--host` only tells `configure` that this is a cross
221
+ build — `emconfigure` has already supplied the tools. That is a property of
222
+ the package, so it is declared per dependency rather than worked around for
223
+ everybody.
224
+
225
+ ## Reproducible inputs, patches and provenance
226
+
227
+ Builds never run in the source tree. The project is copied to a staging
228
+ directory first, then patched, then built. Two failures follow from doing
229
+ otherwise: a patch applied in place leaves the checkout modified, so a second
230
+ build starts from different source and the patch no longer applies; and a build
231
+ leaves generated files behind — Cython's `.c`, `.egg-info` — which then become
232
+ inputs to the next one.
233
+
234
+ Every input is pinned and verified before use:
235
+
236
+ | input | pinned by | verified by |
237
+ | --- | --- | --- |
238
+ | interpreter and its libraries | `sources.lock` | sha256 before unpacking |
239
+ | build tools (setuptools, Cython) | `build-tools.lock` | `pip --require-hashes` |
240
+ | native target libraries | `dependency.json` provenance | sha256 before unpacking |
241
+ | package source | recipe `source`, or in-repo | sha256, or tree digest |
242
+ | patches | `patches.json` `sourceDigest` | exact-context `git apply` |
243
+
244
+ Wheels are byte-reproducible: every zip member is stamped with the zip epoch
245
+ rather than the build time and RECORD is sorted, so two builds from the same
246
+ inputs produce identical files. This is stronger than the "documented timestamp
247
+ normalization" the task allowed, and it is what makes "did anything actually
248
+ change?" answerable by comparing digests. Confirmed by rebuilding every wheel
249
+ and diffing digests, and by `test/python-runtime/reproducible-builds.test.ts`.
250
+
251
+ Each wheel carries `dist-info/sandboxedjs-provenance.json`, which answers, for
252
+ an artifact held by someone without this repository: which source tree digest
253
+ produced it, which build tools and native libraries went into it, which ABI id
254
+ and recipe revision were used, and which patches were applied. The index
255
+ summarises those fields so they can be compared across wheels without
256
+ downloading any.
257
+
258
+ ## Build backends
259
+
260
+ `scripts/backends.py` is the only place a package's build system is known
261
+ about. The orchestrator verifies inputs, selects a backend by the name the
262
+ recipe stated, hands it a `BuildRequest`, and packages the `BuildResult` that
263
+ comes back. It decides nothing else — which is what keeps a package-name check
264
+ from appearing in it, since once an orchestrator is already making build
265
+ decisions, one more looks harmless.
266
+
267
+ A backend receives verified staged source, the locked build-tool interpreter,
268
+ the cross environment, the declared native dependencies, a staging directory
269
+ and a read-only view of the ABI. It returns a wheel-layout tree, the package's
270
+ own `.dist-info`, and evidence recorded in the wheel's provenance. It cannot
271
+ contribute a compiler flag, and the orchestrator digests the ABI contract
272
+ before and after the build — a backend that mutated it would produce a
273
+ correctly-tagged wheel built for a different ABI.
274
+
275
+ The `pep517` backend calls the package's declared hooks the way a frontend
276
+ does, including resolving an in-tree backend through `backend-path`. It does
277
+ **not** use `python -m build`, whose isolation installs whatever versions
278
+ upstream publishes on the day of the build — the unpinned input the rest of
279
+ this pipeline refuses. The environment is assembled from `build-tools.lock`,
280
+ and a build requirement absent from the lock is refused rather than fetched.
281
+ The wheel the hooks produce is unpacked rather than shipped: it carries
282
+ whatever tag the backend chose, and this pipeline tags a wheel for what it
283
+ actually is.
284
+
285
+ Build tools run on an interpreter whose feature version matches the target's.
286
+ Most of the cross build does not care, because `sysconfig` answers for the
287
+ target either way — but `bdist_wheel` composes a tag from the *running*
288
+ interpreter's version and the *target's* ABI tag. Building for 3.13 from 3.14
289
+ yields `('cp314', 'cp313', 'emscripten_5_0_6_wasm32')`, which fails an
290
+ assertion inside setuptools rather than anywhere that names the cause.
291
+
292
+ ## Meson
293
+
294
+ `kind: "meson"` drives a project through meson-python's PEP 517 hooks. It is
295
+ its own backend rather than a `pep517` recipe with extra settings because a
296
+ cross file is backend mechanics, not a package quirk — a recipe that passed its
297
+ own `--cross-file` could describe a different target than the wheel is tagged
298
+ for.
299
+
300
+ Meson does not read `sysconfig`; it is told about a target by a file. So the
301
+ same facts the cross table states are stated again in Meson's format, generated
302
+ from `abi/extension-abi.json` by `write_meson_cross_file`. Three settings are
303
+ supplied by the backend because they are facts about the target, not choices:
304
+
305
+ - `needs_exe_wrapper = true`, or Meson believes it can run what it builds and
306
+ every compile-and-run check silently tests the build machine.
307
+ - `--wrap-mode=nodownload`. Meson otherwise resolves a missing subproject by
308
+ cloning it mid-build — an unpinned input arriving over the network at the one
309
+ moment nothing is watching.
310
+ - `-Ddefault_library=static`. There is no such thing as a shared library here;
311
+ `ld.wasm` refuses, and Meson reports it at configure time as a message about
312
+ the linker rather than about the default that reached it.
313
+ - `longdouble_format`, derived by asking the pinned compiler for its
314
+ `__LDBL_MANT_DIG__` and byte order rather than being written down. Meson
315
+ cannot run a program on the target to find out, and a project that inspects
316
+ float layouts — NumPy does — will not configure without it. Deriving it
317
+ matters more than the convenience: a hand-written value that disagreed with
318
+ the toolchain would produce a library that builds, imports, and computes
319
+ wrong answers.
320
+
321
+ A project whose subprojects were previously downloaded supplies them through
322
+ `vendoredSources`: hash-verified archives unpacked at declared paths inside the
323
+ staged tree, recorded in the wheel's provenance because they are compiled into
324
+ it. They cannot escape the staged tree.
325
+
326
+ ### Two things that were failures first
327
+
328
+ **Split headers.** A cross build leaves `Include/` in the source tree and the
329
+ generated `pyconfig.h` in the build directory; an *installed* interpreter has
330
+ them in one place, and tools assume the installed shape. `Python.h` includes
331
+ `"pyconfig.h"` in quotes, so the compiler looks beside `Python.h` and then walks
332
+ the `-I` list — and finds the *build machine's* `pyconfig.h` from whichever host
333
+ include directory a tool added. The build then fails with `LONG_BIT definition
334
+ appears wrong for platform`, which reads like a broken toolchain and is in fact
335
+ a 64-bit header describing a 32-bit target. Meson adds such a directory;
336
+ setuptools happens not to. Both now compile against one staged directory
337
+ holding every target header.
338
+
339
+ **Paths in compiled output.** This one recurred three times before the rule was
340
+ clear: *nothing a compiler sees may live at a per-build path.*
341
+
342
+ First the staged include directory, created inside the temporary build
343
+ directory — the compile line carries `-g`, so the varying path reached debug
344
+ information. Then Meson's own `.mesonpy-<random>` build directory, which also
345
+ has to be pinned together with the cross file, because Meson caches the cross
346
+ file's path inside the build directory and a stale one fails on the next run
347
+ with a `FileNotFoundError` about a file nobody asked for.
348
+
349
+ Then the staged *source* directory, which was the subtlest: Cython writes the
350
+ `.pyx` path into its generated C so a traceback can name a line. NumPy exposed
351
+ it — 869 of the 874 files in its wheel matched between builds, and the five
352
+ that differed were its Cython-generated modules, all at identical sizes. The
353
+ staged source is now a stable path too, and `reproducible-builds.test.ts`
354
+ checks that the generated cross configuration carries no temporary path.
@@ -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.