# Virtual sockets for Python servers

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.

## Status (2026-09-08)

- **Phase 0 — contract**: done. Socket ops occupy family `1792` in
  [host-v1.json](../../python-runtime/abi/host-v1.json) /
  [sbx_host.h](../../python-runtime/abi/sbx_host.h) /
  [host-abi.ts](../../src/runtime/python/host-abi.ts); `AF_INET` + `SOCK_STREAM`
  only; golden-frame coverage in [python-socket-abi.test.ts](../../test/python-socket-abi.test.ts).
- **Phase 1 — host virtual stream model**: done.
  [src/net/virtual-socket.ts](../../src/net/virtual-socket.ts) holds the listener
  backlog, duplex byte queues, EOF/half-close/reset transitions, and a shared
  `VirtualTcpNetwork` port authority. Tests: [virtual-socket.test.ts](../../test/virtual-socket.test.ts).
- **Phase 2 — ABI dispatch and descriptors**: done. `SocketDescription`
  implements `OpenFileDescription`, so `close`, `set_flags`, and `poll` reuse the
  generic descriptor handlers; socket ops dispatch in
  [syscall-server.ts](../../src/runtime/python/syscall-server.ts) with blocking
  `accept`/`recv`/`send` that unpark on `whenReady()` and abort on process kill.
- **Phase 3 — CPython socket adapter**: written, not yet built.
  [native/sbx/sbx_socket.c](../../python-runtime/native/sbx/sbx_socket.c), the
  `socket.py` / `selectors.py` patches
  ([0003](../../python-runtime/patches/cpython/0003-sandboxedjs-host-sockets.patch),
  [0004](../../python-runtime/patches/cpython/0004-sandboxedjs-selector-poll.patch)),
  and the `build_python.py` wiring exist, but the interpreter has not been
  recompiled with `_sbx_socket`, so no Python process has bound a port.
- **Phase 5 — router/client integration**: done for the non-JS path.
  `LocalRuntimePod.request()` falls through to `requestTcp()` for ports held by
  the socket stack, `proxy.activePorts()` merges both tables, and
  `VirtualHttpRouter.register()` rejects a port already held by a socket
  listener. Tests: [universal-server.test.ts](../../test/universal-server.test.ts).
- **Phases 4 and 6 — asyncio/Uvicorn, hardening**: not started; blocked on
  Phase 3 build.

Next step: rebuild the dynamic CPython profile
(`python-runtime/scripts/build_python.py`) and add the raw-TCP echo and
`asyncio.start_server` acceptance tests from the list below.

## Goal

This must work without changing FastAPI, Starlette, Uvicorn, or application code:

```sh
pip install "fastapi[standard]"
fastapi run main.py
```

Then the host API must reach it through the same port table used by Node:

```ts
await box.waitForPort(8000)
const response = await box.request(8000, { path: "/docs" })
```

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.

## Current topology

```mermaid
flowchart LR
  A[Python Uvicorn] --> B[CPython socket API]
  B --> C[Emscripten socket emulation]
  C -. not connected .-> D[VirtualHttpRouter]
  N[Node http.createServer] --> D
  D --> E[box.request / curl / preview]
```

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.

## Target topology

```mermaid
flowchart LR
  A[Python Uvicorn] --> B[CPython socket API]
  B --> C[Emscripten/libc socket adapter]
  C --> D[sbx_host socket operations]
  D --> E[syscall-server.ts]
  E --> F[NetworkStack / VirtualHttpRouter]
  F --> G[box.request / curl / preview]
  F --> H[accepted virtual connection]
  H --> D
  D --> C
  C --> B
  B --> A
```

The host owns all shared socket state. The WASM process owns only its descriptor numbers and Python-visible socket objects.

## Design decisions

### 1. Implement virtual sockets, not a FastAPI adapter

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.

### 2. Use the existing descriptor table

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.

### 3. Keep HTTP parsing in the Python server

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.

### 4. Separate listener registration from connection delivery

`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.

### 5. Preserve the blocking contract

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).

## Existing code to wire

### ABI and generated bindings

- [host-v1.json](../../python-runtime/abi/host-v1.json): add socket operation codes in the reserved family `1792`.
- [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.
- [sbx_host.h](../../python-runtime/abi/sbx_host.h): generated C operation constants.
- [host-abi.ts](../../src/runtime/python/host-abi.ts): generated `Op` constants and capability list.
- [protocol.ts](../../src/runtime/python/protocol.ts): reuse existing framing; only socket payload readers/writers are needed.

Initial operations:

| Operation | Purpose |
|---|---|
| `socket` | Allocate a stream socket handle with family/type/protocol. |
| `bind` | Bind a socket to an address and port. |
| `listen` | Mark a bound socket as a listener with a backlog. |
| `accept` | Return an accepted connection handle and peer address. |
| `connect` | Connect to an in-container listener or permitted outbound target. |
| `send` | Send bytes, allowing short writes. |
| `recv` | Receive bytes, allowing short reads and EOF. |
| `shutdown` | Close one or both directions. |
| `getsockname` | Return local address and port. |
| `getpeername` | Return remote address and port. |
| `setsockopt` | At minimum support `SO_REUSEADDR`, `SO_KEEPALIVE`, and TCP options Uvicorn touches. |
| `getsockopt` | Return values for options that libraries inspect. |

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.

### Host client/server

- [syscall-client.ts](../../src/runtime/python/syscall-client.ts): add typed methods for socket operations.
- [syscall-server.ts](../../src/runtime/python/syscall-server.ts): dispatch operations, validate process generation, and map errors to canonical errno.
- [descriptors.ts](../../src/kernel/descriptors.ts): confirm duplicated socket descriptions share state and close behavior.
- [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.
- [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.

### Network authority

- [stack.ts](../../src/net/stack.ts): make listener registration and connection creation use the same authority as Node server ports.
- [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.
- [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.
- [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.
- [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.

### CPython/Emscripten side

- [extension-abi.json](../../python-runtime/abi/extension-abi.json): add any required main-module exports and document the socket adapter contract.
- [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.
- [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.
- 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.
- [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.

## Connection protocol

Use a host-owned `SocketDescription` with these states:

```text
created -> bound -> listening -> closed
created -> connected -> open -> half-closed/closed
```

Each connection needs:

- owning process generation;
- local and peer address/port;
- receive queue and byte count;
- send backpressure state;
- readable/writable/closed waiters;
- listener backlog queue;
- cancellation/teardown behavior;
- reference count for duplicated descriptors.

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`.

### Incoming request path

1. `box.request(8000, init)` asks the router for port 8000.
2. The router finds the Python listener registered by `listen()`.
3. The host creates a virtual TCP connection and queues it on the listener.
4. A blocked Python `accept()` becomes readable through `poll`.
5. Python/Uvicorn accepts the descriptor and reads the serialized HTTP request bytes.
6. Uvicorn writes HTTP response bytes to the accepted descriptor.
7. The router resolves `box.request()` from the response stream.
8. Connection close or keep-alive is handled by the same stream state, not by a Python-specific shortcut.

### Outbound local path

For `curl localhost:port` or Python `http.client` to a Node/Python listener:

1. `connect()` resolves the address through the container network authority.
2. The host locates the listener in the shared port table.
3. It creates a pair of virtual stream endpoints.
4. The client descriptor and server listener's accepted descriptor reference opposite endpoints.
5. `send`/`recv` and `poll` operate on those queues.

### External outbound path

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.

## Implementation phases

### Phase 0: freeze the contract

- Define socket operation numbers and payload schemas.
- Define descriptor ownership, backlog, half-close, timeout, and cancellation semantics.
- Define supported families: initially `AF_INET` + `SOCK_STREAM`.
- Define unsupported behavior explicitly: UDP, IPv6, raw sockets, arbitrary outbound TCP.
- Add ABI version/golden-frame tests before implementation.

### Phase 1: host-side virtual stream model

- Add `SocketDescription` and listener/backlog state.
- Connect it to the existing port authority and router.
- Implement in-memory duplex queues with `whenReady()` and EOF/error transitions.
- Ensure teardown closes listeners and all accepted connections.
- Add tests for bind conflicts, backlog, short reads/writes, EOF, half-close, reset, and port reuse.

### Phase 2: ABI dispatch and descriptor integration

- Generate operation constants.
- Implement client methods and server dispatch.
- Add socket handles to descriptor tables.
- Implement `poll` readiness for listener-readable, connection-readable, connection-writable, hangup, and error.
- Test stale process generations and killed workers with blocked socket operations.

### Phase 3: CPython socket adapter

- Patch/build the dynamic CPython profile with the host socket adapter.
- Make `socket.socket(AF_INET, SOCK_STREAM)` allocate host handles.
- Implement Python-visible `fileno`, blocking/nonblocking mode, timeout, `accept`, `makefile`, `sendall`, `recv`, `shutdown`, `getsockname`, and `getpeername`.
- Implement enough socket constants/options for Uvicorn and asyncio, including `SO_REUSEADDR`.
- Verify `selectors.DefaultSelector` observes the host readiness fd model.

### Phase 4: asyncio and Uvicorn

- Run an ordinary `asyncio.start_server` echo server.
- Run `uvicorn main:app` and `fastapi run main.py` without adapters.
- Verify startup, shutdown, keep-alive, request body, response body, and malformed requests.
- Confirm the process remains alive while the listener is referenced and exits after close.

### Phase 5: router and client integration

- Make `box.waitForPort()` recognize Python listeners.
- Make `box.request()` send a real HTTP byte stream to the accepted Python socket.
- Make `curl localhost:port` use the same local virtual connection path.
- Add `box.expose()` support only after in-container request routing works.
- Add WebSocket upgrade tests using the same accepted stream abstraction.

### Phase 6: hardening and release

- Stress repeated server start/stop and connection churn.
- Test concurrent requests and backpressure.
- Test cancellation while blocked in `accept`, `recv`, `send`, and `poll`.
- Test browser worker and Node worker modes.
- Test cross-origin isolation requirements for browser hosts.
- Update M6/M7 release gates only after real `box.request()` and Uvicorn tests pass.

## Acceptance tests

The first end-to-end test should be a small Python TCP echo server. The second should be HTTP over Uvicorn:

```ts
const box = await createContainer({
  workerUrl: WORKER_URL,
  files: {
    "/workspace/main.py": `
from fastapi import FastAPI

app = FastAPI()

@app.get("/health")
def health():
    return {"ok": True}
`,
  },
});

await box.exec('pip install "fastapi[standard]"', { timeoutMs: 300_000 });
const process = box.spawn("fastapi run /workspace/main.py --port 8000");
try {
  expect(await box.waitForPort(8000, { timeoutMs: 60_000 })).toBe(true);
  const response = await box.request(8000, { method: "GET", path: "/health" });
  expect(response.status).toBe(200);
  expect(response.json()).toEqual({ ok: true });
} finally {
  process.kill();
  box.dispose();
}
```

Required test groups:

- ABI encoding/decoding and capability handshake.
- Descriptor lifecycle and duplicated descriptors.
- Listener registration and port conflicts.
- Readiness and blocking behavior.
- Raw TCP echo.
- Node-to-Python and Python-to-Node local connections.
- Uvicorn/FastAPI GET and POST validation.
- Keep-alive and concurrent requests.
- WebSocket upgrade.
- Cancellation and teardown.
- Browser worker and Node worker.

## Definition of done

The feature is complete only when all of these are true:

- `fastapi run main.py` is found after installing `fastapi[standard]`.
- The command binds a port through the SandboxedJs socket ABI, not a fake success path.
- `ss -ltn` reports the Python listener with the right owner/PID metadata.
- `box.waitForPort()` returns true because a real request can reach the listener.
- `box.request()` receives a real Uvicorn response.
- `curl localhost:8000` reaches the same Python server.
- Node and Python servers share port conflict and lifecycle behavior.
- Killing the Python process unblocks and closes all accepted connections.
- No host socket or listener remains after container disposal.
- Browser and Node worker modes pass the same core tests.
- The old Pyodide backend remains unchanged and does not accidentally claim this capability.
