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
This commit is contained in:
Edward Langley
2026-09-21 14:51:34 -07:00
parent 4f194e9f26
commit 12714c7527
25 changed files with 1225 additions and 84 deletions
+52 -10
View File
@@ -6,12 +6,15 @@ import {
COL_PX,
HANDSHAKE_TIMEOUT_MS,
ROW_PX,
classifyWorkerMessage,
createShell,
documentTitle,
extentOf,
frameCostOf,
gridPlan,
indicator,
isFatal,
noticeText,
socketUrl,
} from "./shell.js";
@@ -64,7 +67,18 @@ function sendViewport() {
const { rows, cols } = extentOf(box, frameCost, metrics);
// `set_viewport` RECORDS the window as well as framing it; the frame-only
// twin beside it is deleted, so there is nothing to call by mistake (seik).
transport.send(client.set_viewport(rows, cols));
// QUIET: an extent announcement is not a user edit, and a `ResizeObserver`
// firing after a drop must not overwrite the precise close message.
transport.send(client.set_viewport(rows, cols), { loud: false });
}
/** What the worker said that was not a frame: a boot or save failure carrying
* its REASON, which is the channel improvise-1wft exists to add. */
function onNotice(notice) {
// A notice is a LOUD failure, so it disarms the watchdog rather than being
// replaced by it eight seconds later (gotcha 48).
shell.fail(noticeText(notice));
if (isFatal(notice)) console.error("improvise: boot failed", notice);
}
/** One frame, one fold, in arrival order. Never batch or reorder these. */
@@ -90,7 +104,14 @@ function workerTransport() {
// `type: "module"` is not optional: worker.js is an ES module (it `import`s
// the worker bundle), and a classic worker would fail to parse it.
const worker = new Worker(`./worker.js?${query}`, { type: "module" });
worker.addEventListener("message", (event) => onFrame(event.data));
worker.addEventListener("message", (event) => {
// A protocol frame is a string; a shell notice is an object. The rule is
// `shell.js`'s and is tested there.
const message = classifyWorkerMessage(event.data);
if (message.kind === "frame") onFrame(message.text);
else if (message.kind === "notice") onNotice(message.notice);
else console.error("improvise: worker sent an unreadable message", event.data);
});
worker.addEventListener("messageerror", () =>
shell.fail("the worker sent a frame this page could not read"),
);
@@ -129,8 +150,21 @@ function socketTransport() {
);
shell.setShell(`connecting to ${WS_URL}…`);
return {
send: (text) => {
if (socket.readyState === WebSocket.OPEN) socket.send(text);
// CLOSING can THROW and CONNECTING/CLOSED drop silently, which loses an
// edit with nothing on screen to say so (improvise-ociy).
send: (text, { loud = true } = {}) => {
if (socket.readyState !== WebSocket.OPEN) {
if (loud) {
shell.fail(`not sent — ${opened ? "the connection dropped" : "not connected yet"}`);
}
return;
}
try {
socket.send(text);
} catch (err) {
if (loud) shell.fail("not sent — the connection is closing");
console.error("improvise: send failed", err);
}
},
ready,
};
@@ -142,14 +176,20 @@ const transport = WS_URL ? socketTransport() : workerTransport();
let painted = -1;
let editing = null; // {row, col} while an input is open, so a repaint cannot steal it
// A render that threw is not retried: `painted` is unchanged on that path, so
// every later event re-ran the same failure forever (improvise-7gf8).
let renderBroken = false;
function repaint() {
if (editing) return;
if (editing || renderBroken) return;
let model;
try {
model = JSON.parse(client.render_model());
} catch (err) {
shell.setShell("cannot render the current state (see console)");
// Tripped, not counted: a render model that cannot be built is a permanent
// state, and a reload is the only recovery this page has.
renderBroken = true;
shell.fail("cannot render the current state — reload the page (see console)");
console.error("improvise: render failed", err);
return;
}
@@ -486,11 +526,13 @@ window.addEventListener("keydown", (e) => {
// Start.
await transport.ready;
sendViewport(); // the handshake: the snapshot is built at this extent
// ARMED FIRST. `transport.ready` never resolves for a socket that never
// opens, so arming after it left a hang with no watchdog at all (improvise-vep8).
shell.arm(
WS_URL
? `${WS_URL} accepted the connection but sent no document`
: "the in-page worker did not answer — this browser may not allow it (see console)",
? `${WS_URL} never answered — is improvise-ws-server running, and did it send a document?`
: "the in-page worker did not answer (see console)",
);
await transport.ready;
sendViewport(); // the handshake: the snapshot is built at this extent
new ResizeObserver(sendViewport).observe(el.grid);