diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-20 03:53:17 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-09-20 03:53:17 -0300 |
| commit | f1b53c1533539aecbf16ad19fd9156deae091f92 (patch) | |
| tree | 38ff00810ef5e0ad271c26e218cf6af4578c71ce /docs | |
| parent | ba7ec40782ba7020d82a56896a5eb1b52578d6aa (diff) | |
| download | cloud9-f1b53c1533539aecbf16ad19fd9156deae091f92.tar.gz cloud9-f1b53c1533539aecbf16ad19fd9156deae091f92.zip | |
9web: multiplexer, HTTP view of the tree, live streams, richer page
One upstream 9P connection now serves any number of browser WebSocket
sessions and, with --serve, plain 9P clients over TCP or Unix (a 9pserve-
style frame remux: tags and fids remapped, Tflush forwarded, reconnect on
upstream loss). /fs/<path> maps HTTP onto the tree: GET file or directory
(JSON or HTML), Range, HEAD with 9P headers, PUT (create, truncate, append,
trailing slash makes a directory), DELETE, and ?follow=1 or text/event-stream
turning a blocking read into server-sent events with Tflush on disconnect.
The page gains a lazy tree, stat panel, create, rename, delete, upload and
follow mode. --probe embeds 9proc for self-introspection. New mux-test and
http-fs-test steps; e2e still passes.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/design.md | 47 | ||||
| -rw-r--r-- | docs/http.md | 99 |
2 files changed, 133 insertions, 13 deletions
diff --git a/docs/design.md b/docs/design.md index 3ad9338..f34a3ea 100644 --- a/docs/design.md +++ b/docs/design.md @@ -173,6 +173,53 @@ that waits on another thread must stop waiting once `stop()` has begun. Unix socket paths are the application's: the runner neither unlinks, chmods nor removes them. +# Multiplexer (9web) + +`9web` (`web/`) is a gateway and a 9P multiplexer. It keeps **one** upstream +connection — a single `cloud9.Client`, all sixteen tags — and fans it out to +any number of downstream sessions: the browser WASM client over a WebSocket at +`/_cloud9/9p`, plain 9P clients over an optional `--serve` TCP/Unix listener, +and an HTTP view of the tree under `/fs/`. `web/mux.zig` owns the shared +upstream; `web/httpfs.zig` is the HTTP view; `web/probe.zig` embeds 9proc under +`--probe`. + +The mux is a **frame-level remux**, not an `fs.Server`/`serve.Runner` backend. +Each downstream request is forwarded upstream on a remapped tag with its fids +remapped into the shared upstream fid space, and each upstream reply is routed +back by tag. `Tversion` is answered locally (the upstream is negotiated once at +connect); `Tattach` is forwarded, so each downstream gets its own upstream tree +root; `Tflush` is forwarded as `Tflush`. Downstream fid spaces are isolated by +construction — two downstreams never share an upstream fid. The choice is +deliberate: the file-server engine re-decomposes each request into filesystem +operations and re-encodes directories with synthetic stats and an entry-index +cursor, which would lose the upstream's real directory stats, iounit and qids +and turn the readdir byte offset into an index mapping. Forwarding frames +unchanged is exactly the "forward each request upstream" contract, the shape +plan9port's 9pserve has, and it keeps the upstream's bytes intact. `serve.Runner` +remains the right tool for a *server* whose backend is a real filesystem; a +transparent *proxy* is not that. + +The shared upstream runs one reader task and any number of downstream forwarder +tasks under one mutex; the tag, fid and pending tables are comptime-sized and +nothing allocates per request. Sockets are only written under the mutex with the +client's own output buffer (disjoint from the stream writer's), and replies are +encoded into per-tag buffers and sent to downstreams outside the lock so a slow +downstream cannot stall the client. The WebSocket and `--serve` downstreams +drive the mux the same way — each reads whole frames (WebSocket binary messages +or `transport.readFrame`) and calls `forward`; the runner's socket-only listener +is not used for them because WebSocket framing and the HTTP `/fs` view do not fit +it. When all sixteen upstream tags are in flight, a further downstream request +waits for one to free (Tflush keeps its reserved seventeenth tag). On an upstream +I/O failure the reader fails every outstanding request, reconnects, bumps a +generation, and answers any downstream request that still names a pre-reconnect +fid with EIO until it re-attaches. + +The HTTP `/fs` view uses the same shared upstream at the fid level through a +blocking `rpc`: it allocates upstream fids from the mux pool and walks, stats, +reads and writes directly, so `GET`/`PUT`/`DELETE`, directory JSON, `Range` and +`?follow=1` server-sent events all ride the one upstream connection. A parked +`follow` read is cancelled with an upstream `Tflush` when its client goes away. + # Related programs Programs built on the library ship from this repository as `cloud9/<name>/`, diff --git a/docs/http.md b/docs/http.md index 4e92e5e..5b51e2f 100644 --- a/docs/http.md +++ b/docs/http.md @@ -4,14 +4,84 @@ cloud9 provides a reusable HTTP transport and a standalone browser gateway. The transport carries unchanged base 9P2000 messages. Filesystem permissions, mounting, UART configuration, and board drivers remain with their applications. +`9web` is a **multiplexer**: it holds one upstream 9P connection (a single +`cloud9.Client`, all sixteen tags) and fans it out to any number of downstream +sessions — the browser WASM client over a WebSocket, plain 9P clients over an +optional TCP/Unix listener (a mux like plan9port's 9pserve), and an HTTP view +of the tree under `/fs/`. Each downstream keeps its own fid space; two +downstreams never share an upstream fid. On an upstream failure the gateway +reconnects and invalidates downstream fids (their next request answers EIO +until they re-attach). + ```mermaid flowchart LR - Browser[Browser: cloud9 WASM client] -->|WebSocket / HTTPS| Gateway[HTTP gateway] + Browser[Browser: cloud9 WASM client] -->|WebSocket| Gateway[9web mux] Native[Native cloud9 client] -->|WebSocket / HTTPS| Gateway - Gateway -->|TCP or Unix stream| Server[9P server] - Gateway -->|Serial byte stream| Board[9P firmware / ESP32] + Curl[curl / fetch / SSE] -->|HTTP /fs| Gateway + P9[9ns / plan9port 9p] -->|TCP / Unix 9P| Gateway + Gateway -->|one shared TCP / Unix / serial connection| Server[9P server] +``` + +## The multiplexer + +Downstream requests are forwarded upstream on remapped tags with their fids +remapped into the shared upstream fid space; upstream replies are routed back to +the originating downstream by tag. `Tversion` is answered locally (the upstream +session is negotiated once at connect); `Tattach` is forwarded so every +downstream gets its own upstream tree root; `Tflush` is forwarded as `Tflush`. +Frames are forwarded unchanged, so directory reads, qids, iounits and stat +fields keep the upstream's exact values — the mux does not re-derive them. + +This is deliberately a frame-level remux rather than an `fs.Server`/`serve.Runner` +backend: the file-server engine re-decomposes each request into filesystem +operations and re-encodes directories with synthetic stats and an entry-index +cursor, which would lose the upstream's real directory stats and complicate the +readdir byte offset. Forwarding requests unchanged is exactly the "one upstream, +many downstreams" contract. See [design.md](design.md#multiplexer-9web). + +The tables are comptime-sized and nothing allocates per request: the shared +upstream has one reader task and any number of downstream forwarder tasks +serialized by one mutex, sixteen ordinary tags plus one flush tag, a fixed +upstream fid pool, and a bounded downstream-connection table. When all sixteen +upstream tags are in flight, further downstream requests wait for one to free. + +## The HTTP view of the tree + +`/fs/<path>` maps HTTP onto the 9P tree — file operations, not verbs: + +| Request | 9P | Result | +| --- | --- | --- | +| `GET /fs/<file>` | walk, open, read | bytes; `Content-Type` sniffed (`text/plain` or `application/octet-stream`); `Range` honored (206) | +| `GET /fs/<file>?follow=1` | walk, open, blocking reads | `text/event-stream`; each read that returns data is one SSE `data:` event; also triggered by `Accept: text/event-stream` | +| `GET /fs/<dir>` | walk, open, read | JSON array of `{name, dir, length, mode, mtime, qid}`; an HTML listing with `Accept: text/html` | +| `HEAD /fs/<path>` | walk, stat | headers: `content-length`, `x-9p-qid`, `x-9p-mode`, `x-9p-mtime` | +| `PUT /fs/<file>` | walk or create, open, write | write; creates if missing, truncates unless `?append=1` | +| `PUT /fs/<dir>/` | walk, create | create a directory (trailing slash) | +| `DELETE /fs/<path>` | walk, remove | remove | + +A 9P `Rerror` maps to a status with the ename in the body: *no such +file* → 404, *permission* → 403, *exists* / *not empty* / *is a directory* → +409, otherwise 500. Path components are percent-decoded once; encoded slashes, +NULs and malformed escapes are rejected. `..` is refused. + +Verify against a running `9proc-demo` upstream: + +```sh +9proc-demo --unix /tmp/up.sock & +9web --listen 127.0.0.1:8080 --upstream unix:/tmp/up.sock +curl -s localhost:8080/fs/ # directory JSON +curl -s localhost:8080/fs/runtime/fn/now # a live file +curl -s -X PUT --data-binary hi localhost:8080/fs/scratch/x # write +curl -N localhost:8080/fs/self/log # follow a blocking file (Pardes) ``` +## Self-introspection + +`--probe unix:PATH` (off by default) embeds [9proc](../9proc/README.md) and +serves the gateway's own live counters as files — `/runtime/fn/{connections, +downstreams,upstream,reconnects,negotiated}` — plus `/threads`, on a private 9P +listener. Read-only; serve it on a Unix socket or loopback. + ## Run ```sh @@ -71,21 +141,24 @@ Options: | Option | Default | Meaning | | --- | --- | --- | | `--listen IP:PORT` | `127.0.0.1:8080` | HTTP listener; IPv4 or bracketed IPv6 | -| `--upstream tcp:IP:PORT` | `tcp:127.0.0.1:564` | A new 9P connection per WebSocket | -| `--upstream unix:PATH` | — | A new Unix connection per WebSocket | -| `--upstream file:PATH` | — | An already configured duplex device, one session at a time | +| `--upstream tcp:IP:PORT` | `tcp:127.0.0.1:564` | The one shared upstream 9P connection (TCP) | +| `--upstream unix:PATH` | — | The one shared upstream 9P connection (Unix) | +| `--upstream file:PATH` | — | An already configured duplex device (serial), shared by all downstreams | +| `--serve tcp:IP:PORT\|unix:PATH` | — | Also serve plain 9P downstreams (for 9ns / plan9port clients) | +| `--probe unix:PATH\|tcp:IP:PORT` | — | Embed 9proc for self-introspection (off by default) | | `--origin SCHEME://HOST[:PORT]` | Listener origin | Public origin when a reverse proxy serves the gateway | | `--user NAME` | `user` | Browser's 9P attach user (`uname`), at most 256 bytes | | `--tree NAME` | empty | Browser's named export (`aname`), at most 256 bytes | -| `--timeout-ms N` | `300000` | Maximum connection lifetime, including HTTP headers; 0 disables it | +| `--timeout-ms N` | `300000` | Maximum HTTP/WebSocket connection lifetime; 0 disables it | -The gateway bounds concurrent HTTP/WebSocket connections at 32, HTTP headers at +The gateway bounds concurrent downstream connections at 64, HTTP headers at 8 KiB, and 9P frames at 64 KiB. Excess connections close. A connection ending or -expiring cancels both relay directions before releasing buffers and descriptors. -Each network connection has a separate upstream fid/tag space. A device lease -prevents multiple clients from mixing transactions on one physical UART stream. -A new serial session starts with Tversion; the device owner is responsible for -link reset/recovery if an interrupted physical link leaves stale bytes in transit. +expiring drops the session and reclaims its upstream fids. Downstream fid spaces +are isolated by the mux; the one shared upstream connection carries them all, +which also means one serial link is multiplexed rather than leased — the mux +serializes transactions over it. The upstream session starts with a single +Tversion at connect; the device owner is responsible for link reset/recovery if +an interrupted physical link leaves stale bytes in transit. For a configured serial device: |
