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.
- package/README.md +96 -63
- 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,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.
|