- WS: a dedicated broker process owns HTTP_PORT + every connection; it forwards renders to the worker pool over uce.sock (non-blocking) and applies ws_* command batches flushed back at workspace teardown. Removes the now-dead per-worker websocket executor (-509 lines). - Dispatch: unify CLI / WebSocket / serve_http / page render through one serve_via_wasm(entry_unit, handler) path; handler string -> __uce_<handler> export symbol. - W7d: rewrite zip.uce to the membrane return-value error contract (no C++ try/catch), error-reporting.uce to genuine wasm traps instead of throw, and sharedunit.uce to unit_info(); empty the native-only token gate. - Membrane: wire ls / mkdir / file_mtime through new uce_host_file_list / uce_host_file_mkdir / uce_host_file_mtime hostcalls (resolve_guest_file gains directory support). Fixes /doc/index.uce listing nothing; adds a regression assertion that the index enumerates items. - Docs: add docs/wasm-runtime-architecture.md; record the W7e staged native- deletion plan in WASM-PROPOSAL.md. Verified: scripts/run_cli_tests.sh --include-wasm-kill -> 87 passed, 0 failed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
319 lines
15 KiB
Markdown
319 lines
15 KiB
Markdown
# UCE WASM Runtime Architecture
|
||
|
||
Status: current as of the W7 cutover (June 2026). This document describes the
|
||
**runtime architecture as built** — the process topology, the wasm membrane,
|
||
the unified request dispatch, and the central WebSocket broker. For the
|
||
historical motivation and the phased migration plan, see
|
||
[`WASM-PROPOSAL.md`](../WASM-PROPOSAL.md); this file is the steady-state
|
||
reference that proposal points at.
|
||
|
||
The guiding principle: **request code never shares an address space or an
|
||
allocator with the runtime.** Every unit runs as a WebAssembly module inside a
|
||
per-request workspace behind a narrow host membrane. Long-lived connection
|
||
state (WebSocket connections, listening HTTP sockets) lives in dedicated native
|
||
broker processes that own no unit code — they hold the connection and forward
|
||
the actual unit invocation to a clean-engine worker, exactly like a normal
|
||
page render.
|
||
|
||
---
|
||
|
||
## 1. Process topology
|
||
|
||
A single native binary (`uce_fastcgi`) forks into a small set of long-lived
|
||
roles. None of them is special-cased per request mode; the differences are
|
||
purely *which socket a process listens on* and *which function inside a unit
|
||
gets invoked*.
|
||
|
||
```
|
||
┌────────────────────────────┐
|
||
nginx ──FastCGI──► worker pool (N processes) │ /run/uce.sock
|
||
(port 80 etc.) │ uniform unit renderers │ (FastCGI + CLI)
|
||
└─────────────▲──────────────┘
|
||
│ forward render (FastCGI, uce.sock)
|
||
│
|
||
browser ──raw HTTP / WS──► ┌────────┴─────────┐
|
||
(HTTP_PORT 8080) │ WS broker │ owns HTTP_PORT + every
|
||
│ (1 process) │ WS connection; renders
|
||
└────────▲─────────┘ nothing itself
|
||
│ ws_* command flush
|
||
│ (FastCGI, ws-broker.sock)
|
||
on-demand serve_http ──► ┌────────┴─────────┐
|
||
(per bind addr) │ custom-server │ owns one serve_http
|
||
│ dispatcher(s) │ bind addr; forwards to pool
|
||
└──────────────────┘
|
||
|
||
parent process: spawns/respawns all of the above + the proactive compiler.
|
||
```
|
||
|
||
| Process | Owns | Renders units? | Source |
|
||
|---|---|---|---|
|
||
| **Parent** | nothing; supervises children | no | `main()`, `init_base_process()` |
|
||
| **Worker** (×`WORKER_COUNT`) | `FCGI_SOCKET_PATH` (`/run/uce.sock`) + `CLI_SOCKET_PATH` | **yes** — the only processes that run wasm | `listen_for_connections()` |
|
||
| **WS broker** (×1) | `HTTP_PORT` + every live WS connection + `WS_BROKER_SOCKET_PATH` | no — forwards to the pool | `run_ws_broker()` |
|
||
| **serve_http dispatcher** (×bind) | one custom-server bind address | no — forwards to the pool | `custom_server_http_dispatcher_loop()` |
|
||
| **Proactive compiler** | nothing; pre-compiles units | no | `run_proactive_compiler()` |
|
||
|
||
The key invariant: **only workers instantiate Wasmtime and run unit code.**
|
||
Every connection-owning process (broker, serve_http dispatcher) forwards the
|
||
real invocation back to a worker over `/run/uce.sock` using the minimal
|
||
FastCGI client in [`src/lib/fcgi_forward.h`](../src/lib/fcgi_forward.h). This is
|
||
forced by Wasmtime: an `Engine`/`Store` cannot be safely re-created across
|
||
`fork()`, and the brokers fork from the parent that already touched the
|
||
runtime. So the brokers hold the long-lived connection and units respond to
|
||
events the same way they respond to a page request — through a clean worker.
|
||
|
||
---
|
||
|
||
## 2. The membrane and the DValue ABI
|
||
|
||
Units are compiled to WebAssembly and linked against a host import surface (the
|
||
"membrane"). Host and guest exchange structured values as **UCEB** (a compact
|
||
binary `DValue` encoding — `ucb_encode`/`ucb_decode`); strings cross as
|
||
`std::string` on the native side throughout (no raw `char*` ownership across
|
||
the boundary).
|
||
|
||
- `DValue` is the universal value type: scalars plus an ordered child map
|
||
(`_map`, a `std::map<String,DValue>`). `operator[]` is non-const (creates);
|
||
`.key(k)` is the const probe returning `const DValue*`; `.each(fn)` iterates
|
||
children yielding `const DValue&`.
|
||
- The request context (`params`/`get`/`post`/`cookies`/`session`, the raw body
|
||
`in`, and—for WS—the connection context) is marshalled into a single `ctx`
|
||
DValue, UCEB-encoded, and handed to the workspace. The response (body,
|
||
headers, status, and any `meta` such as `ws_commands`) comes back the same
|
||
way.
|
||
|
||
See [`docs/wasm-phase1-dvalue-abi.md`](wasm-phase1-dvalue-abi.md) for the wire
|
||
format details.
|
||
|
||
---
|
||
|
||
## 3. Units, handlers, and export naming
|
||
|
||
A `.uce` unit compiles to a wasm module that exports one symbol per **handler**.
|
||
There is no per-mode machinery — a "CLI unit", "WebSocket unit", and "page" are
|
||
the same compiled artifact invoked at different exports. The dispatcher passes a
|
||
**handler string**; the host maps it to an export symbol:
|
||
|
||
```
|
||
__uce_<base>[_<sanitize(suffix)>]
|
||
```
|
||
|
||
`handler_export_symbol()` (`src/wasm/worker.cpp`) splits on the first `:`:
|
||
|
||
| Handler string | Export symbol |
|
||
|---|---|
|
||
| `render` | `__uce_render` |
|
||
| `cli` | `__uce_cli` |
|
||
| `websocket` | `__uce_websocket` |
|
||
| `once` | `__uce_once` (optional; absence is not an error) |
|
||
| `serve_http` | `__uce_serve_http` |
|
||
| `serve_http:named` | `__uce_serve_http_named` |
|
||
| `component:CARD` | `__uce_component_CARD` |
|
||
| `exists` | probe only — resolves the unit, loads nothing |
|
||
|
||
`sanitize_symbol_suffix()` keeps `[A-Za-z0-9_]` (mirrors `ascii_safe_name`).
|
||
`wasm_resolve_target(unit, handler)` (`src/wasm/core.cpp`) resolves the source
|
||
path and looks up the export's funcref slot; `exists` lets callers probe a unit
|
||
without instantiating it.
|
||
|
||
`wasm_backend_should_handle(request, entry_unit)` gates whether the wasm path
|
||
takes the request (vs. the legacy native compiler path, still selectable while
|
||
the native pipeline is retired). With `WASM_BACKEND_ENABLED=1` this is the
|
||
default for all unit execution.
|
||
|
||
---
|
||
|
||
## 4. The workspace runtime
|
||
|
||
Each request gets a fresh **workspace** — a per-request wasm instance tree with
|
||
the membrane wired in. `wasm_worker_serve(worker, ctx, entry_unit, handler)`
|
||
(`src/wasm/worker.cpp`) is the single entry point for *every* mode:
|
||
|
||
1. Birth a workspace (CoW-snapshot-based where available).
|
||
2. Resolve `entry_unit` + `handler` to an export; components referenced at
|
||
runtime are resolved on demand via the `uce_host_component_resolve` hostcall
|
||
(`component_resolve()` → `__uce_<...>` slot), loading dependency modules
|
||
lazily and recording resolve counts/timings.
|
||
3. Invoke the export. Unit code calls host functions (filesystem, sqlite, regex,
|
||
markdown, time, tasks, `ws_*`, …) across the membrane.
|
||
4. Collect the response (`WasmResponse`: body, headers, status, `meta`).
|
||
|
||
Because the workspace owns no process-lifetime state, a unit crash or trap is
|
||
contained: it fails the one request and the worker stays healthy. Workers
|
||
suspend the native SIGSEGV/SIGILL recovery handler around the wasm call so that
|
||
Wasmtime's own trap signals are not escalated into a native fatal signal (see
|
||
`serve_via_wasm` in `handle_complete`).
|
||
|
||
---
|
||
|
||
## 5. Request dispatch (`handle_complete`)
|
||
|
||
`handle_complete()` (`src/linux_fastcgi.cpp`) is the worker's single dispatch
|
||
point. It selects a handler string from request params and calls the same
|
||
`serve_via_wasm(entry_unit, handler)` lambda for all of them:
|
||
|
||
```
|
||
UCE_WS == "1" → serve_via_wasm(entry_unit, "websocket")
|
||
request.resources.is_cli → serve_via_wasm(entry_unit, "cli")
|
||
UCE_SERVE_HTTP == "1" → serve_via_wasm(entry_unit, "serve_http"[:fn])
|
||
otherwise (page) → serve_via_wasm(entry_unit, "render")
|
||
```
|
||
|
||
The `UCE_*` params are set by whichever broker forwarded the request:
|
||
|
||
- **Page render**: nginx → `/run/uce.sock` directly; no `UCE_*` flags → `render`.
|
||
- **CLI**: the CLI socket sets `is_cli`.
|
||
- **serve_http**: the custom-server dispatcher sets `UCE_SERVE_HTTP=1` plus
|
||
`UCE_SERVE_HTTP_FUNCTION` and rewrites `SCRIPT_FILENAME` to the configured
|
||
unit (`custom_server_http_complete`).
|
||
- **WebSocket**: the WS broker sets `UCE_WS=1` and carries the connection
|
||
identity as `UCE_WS_*` params (see §6). `handle_complete` rebuilds
|
||
`request.resources.websocket_*` and `request.connection` from them before
|
||
invoking `__uce_websocket`.
|
||
|
||
Each branch has a native-compiler fallback (`compiler_invoke*`) used only when
|
||
`wasm_backend_should_handle` declines — the holdout path during native
|
||
retirement.
|
||
|
||
---
|
||
|
||
## 6. The central WebSocket broker
|
||
|
||
The broker is the architectural centerpiece. **One process owns the HTTP port
|
||
and every WebSocket connection**, so any unit's `ws_*` call can reach any or all
|
||
connections, and a unit-code crash (which happens in a worker) never drops live
|
||
connections.
|
||
|
||
### 6.1 Why central, and why it forwards
|
||
|
||
Earlier designs gave each worker its connections and captured `ws_*` output to
|
||
return inline. That was wrong: connections couldn't outlive a worker, and one
|
||
unit could only talk to connections it happened to own. The broker fixes both —
|
||
it is the sole connection registry and data broker between connected clients and
|
||
the units that handle them. It renders nothing itself; like every other
|
||
connection owner it forwards unit invocation to the worker pool.
|
||
|
||
### 6.2 Inbound: a WS frame → a worker render (non-blocking)
|
||
|
||
`ws_broker_ws_message(request, message, opcode)` fires when a complete
|
||
(reassembled) WS message arrives on a connection. It does **not** block the
|
||
broker loop:
|
||
|
||
1. Build FastCGI params: `SCRIPT_FILENAME`, `REQUEST_METHOD=GET`, a
|
||
`REQUEST_URI` (required — `handle_request()` rejects requests without one
|
||
*before* `on_complete` runs), `UCE_WS=1`, and the connection context as
|
||
`UCE_WS_CONNECTION_ID / SCOPE / OPCODE / BINARY / CONNECTIONS / STATE`.
|
||
2. The message rides as `UCE_WS_MESSAGE` (base64) with an **empty STDIN body** —
|
||
a non-empty STDIN makes the FastCGI transport flush a premature response
|
||
before `on_complete` ever runs.
|
||
3. Connect to `/run/uce.sock` (non-blocking) and queue the encoded request in
|
||
`ws_broker_outbound[fd]`.
|
||
|
||
`ws_broker_drain_outbound()` runs after every `process(50)` tick: it finishes
|
||
writing each queued request, then drains and discards the reply (the unit's
|
||
output comes back via the command socket, not this reply), closing the fd when
|
||
the worker closes its end.
|
||
|
||
### 6.3 Outbound: `ws_*` commands flushed back to the broker
|
||
|
||
Any unit code — not just WebSocket handlers — may call `ws_send` / `ws_send_to`
|
||
/ `ws_close`. In the workspace these **record dispatch commands** rather than
|
||
touching a socket (the workspace owns no connections); `wasm-core`'s `ws_*`
|
||
(`src/lib/sys.cpp`) append to `websocket_dispatch_commands`, and
|
||
`finish_response_meta` (`src/wasm/core.cpp`) emits them as `ws_commands` (plus
|
||
`ws_connection_state` if the handler mutated per-connection state).
|
||
|
||
`wasm_backend_serve` (`src/wasm/backend.cpp`) flushes that batch at workspace
|
||
teardown — in **any** scenario, not just WS handlers — to the broker's command
|
||
socket (`WS_BROKER_SOCKET_PATH`, `/run/uce/ws-broker.sock`) via
|
||
`fcgi_forward_request` with `UCE_WS_DISPATCH=1`. This is the only path WS data
|
||
takes out of a workspace.
|
||
|
||
`ws_broker_apply_commands()` decodes the batch and applies each command against
|
||
the full registry it owns: `broadcast` (by scope), `send_to` (by connection id),
|
||
`close`. If the batch carries `connection_state`, it persists that onto the
|
||
matching live connection's `websocket_state`.
|
||
|
||
### 6.4 Un-upgraded HTTP on the WS port
|
||
|
||
The WS port can also receive ordinary (non-Upgrade) HTTP requests.
|
||
`ws_broker_complete()` routes by param: `UCE_WS_DISPATCH=1` → apply commands;
|
||
otherwise → `forward_request_to_worker()` — the *same* shared facility the
|
||
serve_http dispatcher uses, so there is no duplicated request-forwarding code.
|
||
|
||
### 6.5 The broker loop
|
||
|
||
`run_ws_broker()` drops the worker listeners it inherited
|
||
(`close_inherited_server_sockets`), installs permissive `on_request`/`on_data`
|
||
handlers (it renders nothing, so it accepts every request straight through to
|
||
`on_complete`), wires `on_complete=ws_broker_complete` and
|
||
`on_websocket_message=ws_broker_ws_message`, listens on `HTTP_PORT` and the
|
||
command socket, and loops `process(50)` + `drain_outbound()`. The design is
|
||
**non-blocking outbound dispatch + async command-socket flush, all in the
|
||
broker's single epoll loop** — the broker never blocks on a worker.
|
||
|
||
The parent respawns the broker if it dies (`ws_broker_alive` / `ensure_ws_broker`
|
||
in `main()`). A broker restart loses live connections (acceptable: crashes
|
||
happen in workers, so the broker stays up in practice), but no unit state is at
|
||
risk because the broker holds none.
|
||
|
||
---
|
||
|
||
## 7. The serve_http facility
|
||
|
||
`serve_http` units are reachable on their own bind address via a custom-server
|
||
dispatcher (`custom_server_http_dispatcher_loop`). The dispatcher owns the bind
|
||
socket and, on each request, sets `UCE_SERVE_HTTP=1` + the configured unit/
|
||
function and calls `forward_request_to_worker()`. The worker then runs
|
||
`serve_via_wasm(entry_unit, "serve_http"[:fn])`. This is the same
|
||
hold-connection-forward-render model as the WS broker, sharing
|
||
`forward_request_to_worker` and `fcgi_forward.h`.
|
||
|
||
---
|
||
|
||
## 8. Build / object layout
|
||
|
||
The native side is split into separately-compiled objects so editing the wasm
|
||
runtime does not recompile the whole TU (`scripts/build_linux.sh`):
|
||
|
||
| Object | Contents |
|
||
|---|---|
|
||
| `bin/sqlite3.o` | sqlite amalgamation (cached) |
|
||
| `bin/wasm.o` | `src/wasm/wasm_module.cpp` (backend.cpp + worker.cpp + wasmtime) |
|
||
| `bin/main.o` | `src/linux_fastcgi.cpp` (includes `lib/uce_lib.cpp`) |
|
||
|
||
Linked into one `-rdynamic` binary. ODR hazards across the objects are handled
|
||
deliberately: `context` and `my_pid`/`parent_pid` are `extern` (guarded for the
|
||
wasm core vs. unit builds), `operator new`/`delete` live in `types.cpp`, and
|
||
header free-functions are `inline`. The wasm backend exposes only declarations
|
||
(`src/wasm/backend.h`) to `main.o`.
|
||
|
||
---
|
||
|
||
## 9. Configuration keys
|
||
|
||
| Key | Default | Meaning |
|
||
|---|---|---|
|
||
| `WASM_BACKEND_ENABLED` | `1` | Route unit execution through the wasm path. |
|
||
| `WASM_BACKEND_VERBOSE` | `0` | Emit `X-UCE-Wasm-*` workspace timing headers (benchmark only). |
|
||
| `FCGI_SOCKET_PATH` | `/run/uce.sock` | Worker pool FastCGI socket (brokers forward here). |
|
||
| `CLI_SOCKET_PATH` | `/run/uce/cli.sock` | Worker CLI socket. |
|
||
| `HTTP_PORT` | `8080` | Raw HTTP + WebSocket port — owned by the WS broker. |
|
||
| `WS_BROKER_SOCKET_PATH` | `/run/uce/ws-broker.sock` | Broker command socket for `ws_*` flushes. |
|
||
| `WORKER_COUNT` | `4` | Number of uniform worker processes. |
|
||
|
||
---
|
||
|
||
## 10. Testing
|
||
|
||
- **Regression gate**: `scripts/run_cli_tests.sh --include-wasm-kill` runs the
|
||
in-runtime CLI suite (`site/tests/cli_runner.uce`) plus the site test pages.
|
||
Current baseline: **86 passed, 0 failed**.
|
||
- **WebSocket end-to-end**: a headless client performs a raw WS handshake to
|
||
`:HTTP_PORT` with path `/site/tests/websockets.ws.uce` (self-resolving
|
||
`SCRIPT_FILENAME`) and asserts the `hello-ack` frame — exercising the full
|
||
broker → worker → broker → client chain across process boundaries.
|
||
|
||
All builds, runs, and installs happen on the dev host (`k-uce` / uce-dev) over
|
||
SSH; the local checkout is edit-only.
|