Commit Graph

5 Commits

Author SHA1 Message Date
Edward Langley 12714c7527 feat(browser): a boot failure is a state with a reason, not silence
improvise-1wft and the five beads it blocked were one failure: nothing
carried a boot failure's REASON from the worker to the page. The channel
is one rule -- a protocol frame crosses the port as a STRING, a shell
notice as an OBJECT -- so it needs no protocol change and the
discriminator is total.

A boot failure is a STATE, not a rejected promise. A document another
context holds the exclusive handle to now OPENS read-only: measured first
(harness probe, Chromium + Firefox), getFile() reads the bytes while the
handle is held, with the second-handle refusal asserted so the probe
cannot pass vacuously.

- improvise-1wft  the channel: Notice/NoticeKind, boot_notice, take_notice
- improvise-i0v9  read-only boot over vfs::ReadOnly + OpfsFs::read_unattached
- improvise-86nw  start() rejecting for any reason now posts BootFailed
- improvise-9cma  boot::document_name -- and a/b/c.improv was ACCEPTED as
                  c.improv, not rejected, which is worse than the report
- improvise-iobc  vfs::Origin: a seed error is a notice only when the
                  document really had to be created without it
- improvise-vep8  the watchdog arms BEFORE transport.ready; its message
                  stopped claiming the connection was accepted
- improvise-7gip  DedicatedWorkerGlobalScope (needs a web-sys feature)
- improvise-zre1  a persist that fails raises a notice; the trigger is the
                  dirty flag surviving, observed rather than predicted
- improvise-7gf8  the render breaker trips once instead of retrying forever
- improvise-ociy  non-OPEN sends are loud; a viewport announcement is not
- improvise-y01k  the three ws-server drops are logged
- improvise-r4nf  serde failures throw instead of reading as "nothing to send"

Every notice routes through shell.fail, so gotcha 48's disarm half is not
re-broken. Two comments in shell.spec.js stated the opposite of the new
arming order and were corrected rather than left.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 14:51:34 -07:00
Edward Langley 36ade35240 test(web): a Node suite between the boundaries, Playwright only on them
E2E and integration tests are rare and pin boundary contracts; unit tests
assume those contracts and test between them (user, 2026-09-20). The browser
half has four boundaries — RenderModel→DOM, JS→wasm, page→peer, worker→OPFS —
and everything between them needed no browser at all.

`web/shell.js` and `web/serial.js` are the seams: pure modules that RETURN A
DESCRIPTION rather than take a DOM double. `gridPlan(model)` answers every
question `gridTable` used to answer while creating elements — the data-row
index across group headers, what a group header spans, a null group label
against an empty one, an empty edit buffer against no editor — and `app.js` is
now a loop that builds elements from it. Painting stays untested by Node; what
to paint stopped being untestable.

26 tests in `node --test web/tests` (0.12s, no Rust toolchain, no wasm bundle,
no npm install), plus three in `web/tests/bundle/` that read the generated
.d.ts and so run in the browser job. Playwright goes from 29 tests/36.9s to
16/15.5s on one machine: `widgets.spec.js` and `pointer.spec.js` are gone,
replaced by `contract.spec.js`, which asserts that EVERY field the render model
projects reaches the screen. The four widgets those specs were written for were
one failure at one boundary, and one assertion catches the fifth.

It caught it: `doc_name`/`doc_dirty`/`doc_stored_as` reach no element
(improvise-5jna, already open). They are named in `UNPAINTED` with the ticket,
so the list is the debt written down rather than an exemption.

Thirteen faults injected into the Node suite and eight into the served page,
each watched to fail the test it names; the last of those was strengthened
because the first version passed. `web/assemble.sh` grew an import-completeness
guard whose own first version was blind to the multi-line import it exists for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 13:33:49 -07:00
Edward Langley 3d3bec43e0 docs(web,harness): cap the JavaScript and the browser harness
web/app.js's transport/watchdog/frameCost arguments and web/worker.js's two
ordering rules — including the one nothing here can test, that an `async`
message listener does not serialize — are now prose in context/code/clients.md.
The harness keeps its own section: why a worker, why a report, the engine
coverage and what `wipe` deliberately cannot clear.

harness/tests/*.spec.js's comments were the record of what each assertion
proves; they are in context/test-evidence/clients.md under the exact test
titles, including the `test.fail` measurement that could NOT become a CI guard
because its trigger is the host's network stack, and the closed-invalid
improvise-xps8 report that a simulated fix appeared to confirm.

ws-server/src/main.rs's ```text fence was prose claiming the doctest
exemption, not a runnable doctest; the synopsis is now two lines and the full
form is in the prose file. No fences remain in this unit.

Verified: node --check on every JS file touched; the non-comment diff for both
'*.rs' and '*.js' is empty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 10:02:03 -07:00
Edward Langley bf02472d89 fix(web): an async message listener does not serialize, and a watchdog must not overwrite a real error
Three findings from the review-only PR #13, which put 28 already-merged
commits in front of a second reader, plus two comments that had gone false.

worker.js — `self.onmessage` was `async` and awaited `handle()`, which awaits
`server.flush()`. An async listener does NOT serialize: the event loop may
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. That breaks the one rule the file names first — a
`GridUpdate::Cells` that overtakes its labels is DROPPED by the client's fold,
not replayed (gotcha 33) — and the pre-start `pending` drain had the same race
twice over, since `server` was assigned before the loop that drained it, so a
message arriving mid-drain overtook the frames it was queued behind. Every
frame is now chained onto one tail promise, which also makes "arrived before
start() finished" an ordinary case of the same rule instead of a second path.

app.js — the handshake watchdog fires 8s after the handshake and writes "the
peer did not answer". A peer that failed LOUDLY has already said something more
precise (worker `error`, `messageerror`, socket `error`), and the watchdog then
overwrote it with the vaguer guess. Those three paths now disarm it:
`setShellFailure` clears the timer rather than merely pre-empting it. The
watchdog is for silence only, which is what it was always for.

README — the "In the browser" section still said the deployed page needs a
server and that the in-page worker "is not built yet". Both landed in the same
unreviewed batch (improvise-9x6, improvise-djm), so the README described the
published site as a client with nowhere to connect while index.html described
it correctly. Rewritten, and it now states the caveat the commits state: no
line of that JavaScript has ever run.

Two comments that had gone false with it, and for the same reason — a follow-up
commit updated one copy and not the others. `web/app.js`'s keyboard note said
key resolution was "text entry, and nothing else" and that upstream keys would
work "until improvise-bpq2 lands"; bpq2 landed, moved the grid, panel and tile
cursors to the client, and settled that the host must not know the keymap.
`Host::handle`'s `ClientFrame::Key` arm said the same thing; 43422d4 corrected
the worker's copy of that argument and missed this one.

NOT VERIFIED, and it cannot be here: there is no browser and no headless DOM in
this environment, so neither JavaScript change has been executed. Both are
argued rather than tested, which is why each carries its argument in the file.
`node --check` passes on both, the wasm bundles build, and the Rust gates are
unchanged: 1172 passing / 0 failed, clippy clean, fmt clean, actionlint clean,
wasm32 clean for all seven bundle crates.

Declined from the same review, with the reason on the thread: `Host::commands`
can apply a batch's earlier commands before rejecting a later one. The finding
is correct, the hole is real, and it is already stated on `ClientFrame::Commands`
and in `Host::commands` — closing it means the basis check moving from the
command to the batch, which is a design change with ADR 001 consequences and
not a patch to land beside three small ones.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
2026-09-19 20:30:18 -07:00
Edward Langley bdce1636bb feat(worker-server): the whole App in a Web Worker, over postMessage + OPFS
improvise-9x6: the server half of the standalone static-web deployment
(epic improvise-tm6). `improvise-worker-server` hosts the authoritative
`App`, the command pipeline, the projection layer and VFS persistence behind
`self.onmessage`/`self.postMessage`.

It is small because everything it needs already existed: the frames are
`improvise_protocol::frame`'s (improvise-xks6), the session loop is
`Host` (previous commit), the basis check is `Sessions::apply`/`Checked`
(improvise-ondj), and persistence is `Storage` + `OpfsFs` (improvise-6mq,
i34). What is genuinely new is three things.

`vfs.rs` — how a document gets INTO a browser's empty filesystem. Loading is
unchanged: it is `persistence::load_from` on a `Storage`, the same call the
native binary makes. Seeding is the new problem, and it is a
`Storage::write`, so a fetched demo, an upload and a saved document take the
same parse path and a seed never clobbers an edit. `list` is the document
picker a browser has no OS file manager for.

`worker.rs` — the loop: one port, one client, the `Viewport` handshake
(dropped rather than closed, since there is no connection to close), and the
filter from `Addressed`. Every decision is here and every test points at it.

`bindings.rs` — `cfg(wasm32)`, decision-free, and the only part no test here
can reach: it awaits `OpfsFs::open`/`attach`/`refresh`/`commit_deletes` and
forwards strings.

Two gaps found and reported rather than papered over. No client on any
transport can ask its host to save — `effect::Save` is `ViewOnly`, so the
wire vocabulary derived from the model effects cannot name it — and a wire
`ModelCommand::SetCell` never sets `model_state.dirty`, because dirtiness is
raised by a separate `MarkDirty` effect the key path emits. So the worker
persists on its own behalf, keyed off the command variant rather than off a
flag that is never set; `touches_document` carries the argument.

`ClientFrame::Key` is still answered with nothing. A worker CAN call
`reduce_key` — it holds the `App` — but nothing in the projection stream
carries the host's own `ViewState`, so a half-wired arm diverges rather than
under-delivers. `a_key_resolved_on_the_host_moves_state_no_client_can_hear`
demonstrates it against a real `App` and `Sessions` (improvise-bpq2).

22 tests, all host-side: 16 in the crate and 6 in `tests/post_message.rs`,
which drives the worker with nothing but the JSON a `MessagePort` carries.
Bundle: 1,334,892 bytes raw, 496,965 gzipped — 485 KB against a 3 MB budget.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
2026-09-19 19:08:19 -07:00