diff --git a/README.md b/README.md index ae669a87..0d6fc25d 100644 --- a/README.md +++ b/README.md @@ -183,27 +183,38 @@ and published from `main` by the `pages-build` / `pages-deploy` jobs in `.github/workflows/ci.yml`. A deploy only happens after `cargo test` and the wasm32 check have passed. -**It needs a server, and a static host is not one.** The page is a thin client: -it holds no model, and every question about a cell — its text, whether it may -be typed into — is answered by a server and arrives already decided. So the -published page paints an empty grid until it is pointed at one: +**It needs no server.** The page is still a thin client — it holds no model, +and every question about a cell, its text and whether it may be typed into, is +answered by a server and arrives already decided — but the server it talks to +is an in-page Web Worker hosting the whole `App` over the browser's private +storage (OPFS). It is seeded on first load from `web/demo.improv`, so a visitor +lands on a document, and edits persist across a reload without leaving the +machine. `?doc=` and `?seed=` name another document and the file it is first +seeded from. + +To run the same page locally: + +```sh +./web/build.sh # wasm-pack -> web/{pkg,worker-pkg} +python3 -m http.server 8080 --directory web +``` + +To open a document hosted by a separate process instead — which is how a +document open in the TUI is shared — start the websocket server and pass +`?ws=`: ```sh cargo run -p improvise-ws-server -- examples/demo.improv --port 8787 ``` -then open the page with `?ws=ws://127.0.0.1:8787`. Over https a `ws://` -address is blocked as mixed content, so for now a local server is most easily -paired with a local page: +then reload with `?ws=ws://127.0.0.1:8787`. From an https origin a `ws://` +address is blocked as mixed content, so that pairing is most easily done with +the local page above. -```sh -./web/build.sh # wasm-pack -> web/pkg (gitignored) -python3 -m http.server 8080 --directory web -``` - -Running with no separate process is the in-page worker, improvise-9x6, which -is not built yet. Until it lands, what is deployed is the delivery pipeline, -not a working demo. +Caveat worth stating plainly: **no line of the browser JavaScript has ever +run.** There is no browser and no headless DOM in the development environment, +so `web/app.js`, `web/worker.js` and the OPFS backend are compile-checked, +syntax-checked and read, never executed. ## What's interesting about the architecture diff --git a/crates/improvise-command/src/ui/host.rs b/crates/improvise-command/src/ui/host.rs index 4140b44c..82779e6d 100644 --- a/crates/improvise-command/src/ui/host.rs +++ b/crates/improvise-command/src/ui/host.rs @@ -143,15 +143,19 @@ impl Host { match msg { ClientFrame::Viewport { rows, cols } => self.resize(id, rows, cols), ClientFrame::Commands(cmds) => self.commands(id, &cmds), - // Key forwarding over this socket is improvise-bpq2 and is not - // built: resolving a keypress needs `reduce_key` against the - // authoritative `App`, and the `Dirty`/broadcast plumbing that - // `command` gets from `Sessions::apply` has no counterpart there - // yet. This arm answers nothing, which is exactly what the - // socket did before the envelopes were shared — the frame used - // to fail to parse and be skipped by `next_client_msg`. The - // difference is that the gap is now a line of code with a ticket - // number on it rather than a variant that does not exist. + // This arm answers nothing, and since improvise-bpq2 that is the + // settled answer rather than a gap. Resolving a keypress here + // needs `reduce_key` against the authoritative `App`, which emits + // no `Command` a client could fold — so the two would diverge, + // which `improvise-worker-server`'s + // `a_key_resolved_on_the_host_moves_state_no_client_can_hear` + // demonstrates. bpq2 closed that by moving resolution to the + // client, so a key a client can answer never arrives as a `Key` + // frame at all, and one that does is a binding nothing on this + // side could answer without diverging. Growing the set is a + // matter of enriching the projection until the client can + // compose the command itself — see `improvise-wasm-client::keys`, + // which carries the list of what is still upstream. ClientFrame::Key { .. } => Vec::new(), } } diff --git a/web/app.js b/web/app.js index 2d61b88a..e0f0c44e 100644 --- a/web/app.js +++ b/web/app.js @@ -110,6 +110,16 @@ function armWatchdog(message) { watchdog = setTimeout(() => setShellStatus(message), HANDSHAKE_TIMEOUT_MS); } +// A peer that failed *loudly* has already said something more precise than +// the watchdog's guess, so the watchdog must be disarmed and not merely +// pre-empted: it fires later and would overwrite the better message with +// "did not answer" (PR #13 review). The watchdog is for silence only. +function setShellFailure(text) { + clearTimeout(watchdog); + watchdog = null; + setShellStatus(text); +} + /** One frame, one fold, in arrival order. Never batch or reorder these. */ function onFrame(text) { clearTimeout(watchdog); @@ -136,10 +146,10 @@ function workerTransport() { const worker = new Worker(`./worker.js?${query}`, { type: "module" }); worker.addEventListener("message", (event) => onFrame(event.data)); worker.addEventListener("messageerror", () => - setShellStatus("the worker sent a frame this page could not read"), + setShellFailure("the worker sent a frame this page could not read"), ); worker.addEventListener("error", (event) => { - setShellStatus(`worker failed to start: ${event.message ?? "see console"}`); + setShellFailure(`worker failed to start: ${event.message ?? "see console"}`); console.error("improvise: worker error", event); }); setShellStatus(`in-page worker · ${DOC}`); @@ -161,7 +171,7 @@ function socketTransport() { // Without this, a connection that never opens left #status blank and // "server not running" read exactly like "still connecting" (improvise-hwpi). socket.addEventListener("error", () => - setShellStatus(`cannot reach ${WS_URL} — is improvise-ws-server running?`), + setShellFailure(`cannot reach ${WS_URL} — is improvise-ws-server running?`), ); socket.addEventListener("close", () => setShellStatus("disconnected")); setShellStatus(`connecting to ${WS_URL}…`); @@ -293,11 +303,13 @@ function categoryList(model) { // ── the keyboard ───────────────────────────────────────────────────────── // // `Client::on_key_event` classifies the event and folds it locally where this -// client may answer it (text entry, and nothing else — see keys.rs); anything -// else becomes a `ClientFrame::Key` for the peer to resolve against the real -// App. Until improvise-bpq2 lands, `Host::handle` answers that frame with an -// empty vec, so an upstream key is sent and correctly does nothing yet. That -// is a gap in the peer, not in this listener. +// client may answer it — text entry, and since improvise-bpq2 the grid, panel +// and tile cursors too, which is why `sendViewport` must call `set_viewport` +// and not `Client.viewport` (keys.rs carries the list, and what is still +// upstream). Anything it cannot answer becomes a `ClientFrame::Key`, which +// `Host::handle` answers with an empty vec: those keys are sent and do +// nothing. That is settled rather than unfinished — the host must not know +// the keymap — and closing one means teaching this client, not that arm. window.addEventListener("keydown", (e) => { // The inline editor owns the keyboard while it is open. It stops diff --git a/web/worker.js b/web/worker.js index d27b72cd..039222c2 100644 --- a/web/worker.js +++ b/web/worker.js @@ -16,6 +16,17 @@ // 2. Await `flush()` after each message. `Storage::delete` is visible to // `read`/`list` immediately but only reaches OPFS here (gotcha 28). // +// Rule 1 is about frames within one call's replies AND about the calls +// themselves, which is why every message is chained onto one tail promise +// below rather than handled by an `async` listener: an `async` listener does +// not serialize. The event loop is free to start the next `message` while an +// earlier one is suspended at an `await`, so two `on_message`/`flush` pairs +// interleave and a later frame's replies can be posted before an earlier +// frame's. The chain is also what makes "frames that arrived before start() +// finished" an ordinary case of the same rule instead of a second code path +// with its own drain loop — and that loop had the race in it twice over, +// since `server` was set before it ran. +// // The document is named in this worker's own URL — `new // Worker('worker.js?doc=demo.improv')` — the analogue of the socket server // naming it in the ws:// path. It is deliberately not a `ClientFrame`: the @@ -44,10 +55,6 @@ async function seedBytes() { } let server = null; -// Frames that arrived before `start()` finished. The client sends its -// `Viewport` as soon as it loads, and losing it would leave the page showing -// an empty grid forever. -let pending = []; function post(replies) { for (const reply of replies) self.postMessage(reply); @@ -59,27 +66,35 @@ async function handle(data) { await server.flush(); } -self.onmessage = async (event) => { - if (!server) { - pending.push(event.data); - return; - } - try { - await handle(event.data); - } catch (err) { - // An unhandled rejection inside a `message` listener reaches the devtools - // console and nothing else. Say which frame it was; the main thread cannot - // see this and has no way to ask. - console.error("improvise worker: frame rejected", event.data, err); - } -}; - -(async () => { +// Startup, and the head of the chain. The client sends its `Viewport` as soon +// as it loads, so frames arrive before this settles; chaining onto it is what +// holds them, in arrival order, without a queue to drain. +const started = (async () => { await init(); server = await start(DOC, await seedBytes()); - const queued = pending; - pending = []; - for (const data of queued) await handle(data); -})().catch((err) => { +})(); +started.catch((err) => { console.error("improvise worker: failed to start", err); }); + +// One frame at a time, in arrival order: the listener is deliberately NOT +// `async`. It appends to the chain and returns, so the ordering does not +// depend on when the event loop chooses to resume a suspended listener. +let tail = started.catch(() => {}); + +self.onmessage = (event) => { + tail = tail + .then(async () => { + // `start()` failed. There is nothing to answer with, and the main + // thread's handshake watchdog is what tells the user. + if (!server) return; + await handle(event.data); + }) + .catch((err) => { + // An unhandled rejection inside a `message` listener reaches the + // devtools console and nothing else. Say which frame it was; the main + // thread cannot see this and has no way to ask. Caught here rather than + // left on the chain so one bad frame does not poison every later one. + console.error("improvise worker: frame rejected", event.data, err); + }); +};