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.
Files changed (43) hide show
  1. package/README.md +16 -9
  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,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.