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:
+52
-10
@@ -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);
|
||||
|
||||
@@ -112,6 +112,43 @@ export function gridPlan(model) {
|
||||
return { cornerSpan, groups, levels: model.col_levels, body };
|
||||
}
|
||||
|
||||
/** What the worker just posted. A protocol frame is a STRING and a shell
|
||||
* notice is an OBJECT; nothing else crosses that port (clients.md). */
|
||||
export function classifyWorkerMessage(data) {
|
||||
if (typeof data === "string") return { kind: "frame", text: data };
|
||||
if (data && typeof data === "object" && typeof data.kind === "string") {
|
||||
return { kind: "notice", notice: data };
|
||||
}
|
||||
return { kind: "unknown", data };
|
||||
}
|
||||
|
||||
/** What a boot or save notice READS AS. The kinds are decided in Rust
|
||||
* (`improvise_worker_server::boot`); only the wording is the page's. */
|
||||
export function noticeText(notice) {
|
||||
const where = notice.document ? ` — ${notice.document}` : "";
|
||||
const why = notice.detail ? ` (${notice.detail})` : "";
|
||||
switch (notice.kind) {
|
||||
case "ReadOnly":
|
||||
return `read-only${where}: another tab or window has it open. Close it and reload to edit.`;
|
||||
case "BootFailed":
|
||||
return `the in-page worker could not start${where}${why}`;
|
||||
case "SeedMissing":
|
||||
return `started an EMPTY document${where}: its starting copy could not be fetched${why}`;
|
||||
case "SaveFailed":
|
||||
return `NOT SAVED${where}${why}`;
|
||||
// A kind this page does not know is still a failure, and saying so beats
|
||||
// saying nothing — the reason it carries is the point (improvise-1wft).
|
||||
default:
|
||||
return `the in-page worker reported ${notice.kind}${where}${why}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a notice means the session is unusable, as opposed to degraded.
|
||||
* Only `BootFailed` is fatal: read-only, empty and unsaved all still paint. */
|
||||
export function isFatal(notice) {
|
||||
return notice.kind === "BootFailed";
|
||||
}
|
||||
|
||||
/** The page's two status facts and the handshake watchdog, which share a home
|
||||
* because the one rule between them is that a *failure disarms the timer*. */
|
||||
export function createShell({ onStatus, timeoutMs, setTimer = setTimeout, clearTimer = clearTimeout }) {
|
||||
|
||||
@@ -123,6 +123,7 @@ test("every worker call web/worker.js makes is declared, with that arity", () =>
|
||||
`server.${call.name} takes ${arity[0]}..${arity[1]}; worker.js passes ${call.args}`,
|
||||
);
|
||||
}
|
||||
// `start` is a free function rather than a method, and takes the seed.
|
||||
assert.deepEqual(declared.get("start"), [1, 2]);
|
||||
// `start` is a free function rather than a method, and takes the seed AND
|
||||
// why it is missing, so an empty document can say so (improvise-iobc).
|
||||
assert.deepEqual(declared.get("start"), [1, 3]);
|
||||
});
|
||||
|
||||
@@ -5,12 +5,15 @@ import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
classifyWorkerMessage,
|
||||
composeStatus,
|
||||
createShell,
|
||||
documentTitle,
|
||||
extentOf,
|
||||
frameCostOf,
|
||||
indicator,
|
||||
isFatal,
|
||||
noticeText,
|
||||
socketUrl,
|
||||
} from "../shell.js";
|
||||
|
||||
@@ -175,3 +178,76 @@ test("the title bar spells the document the way the TUI's own title does", () =>
|
||||
// arrives as `null`, and an empty pair of parens would be a lie about storage.
|
||||
assert.equal(documentTitle("Untitled", true, null), "Untitled [+]");
|
||||
});
|
||||
|
||||
test("a protocol frame is a string and a shell notice is an object", () => {
|
||||
assert.deepEqual(classifyWorkerMessage('{"Commands":[]}'), {
|
||||
kind: "frame",
|
||||
text: '{"Commands":[]}',
|
||||
});
|
||||
const notice = { kind: "ReadOnly", document: "a.improv", detail: "held" };
|
||||
assert.deepEqual(classifyWorkerMessage(notice), { kind: "notice", notice });
|
||||
// The discriminating case: a notice serialized as a STRING would be fed to
|
||||
// the fold, which is exactly the confusion the two shapes exist to prevent.
|
||||
assert.equal(classifyWorkerMessage(JSON.stringify(notice)).kind, "frame");
|
||||
});
|
||||
|
||||
test("a message that is neither is named rather than silently dropped", () => {
|
||||
for (const junk of [null, undefined, 42, {}, { detail: "no kind" }]) {
|
||||
assert.equal(
|
||||
classifyWorkerMessage(junk).kind,
|
||||
"unknown",
|
||||
`classified ${JSON.stringify(junk)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("every notice reads as a reason, and names its document", () => {
|
||||
const of = (kind) => noticeText({ kind, document: "books.improv", detail: "404" });
|
||||
// improvise-i0v9: the page must say what is wrong, not blame the browser.
|
||||
assert.match(of("ReadOnly"), /read-only/);
|
||||
assert.match(of("ReadOnly"), /another tab/);
|
||||
// improvise-iobc: an empty document must not pass for an intentional one.
|
||||
assert.match(of("SeedMissing"), /EMPTY/);
|
||||
assert.match(of("SeedMissing"), /404/);
|
||||
// improvise-zre1: "not saved" must not read like "saving".
|
||||
assert.match(of("SaveFailed"), /NOT SAVED/);
|
||||
assert.match(of("BootFailed"), /could not start/);
|
||||
for (const kind of ["ReadOnly", "SeedMissing", "SaveFailed", "BootFailed"]) {
|
||||
assert.match(of(kind), /books\.improv/, `${kind} did not name the document`);
|
||||
}
|
||||
});
|
||||
|
||||
test("a notice kind this page has never heard of still carries its reason", () => {
|
||||
const text = noticeText({ kind: "SomethingNew", document: "a.improv", detail: "because" });
|
||||
assert.match(text, /SomethingNew/);
|
||||
assert.match(text, /because/);
|
||||
});
|
||||
|
||||
test("only a failed boot is fatal — read-only, empty and unsaved all still paint", () => {
|
||||
assert.equal(isFatal({ kind: "BootFailed" }), true);
|
||||
for (const kind of ["ReadOnly", "SeedMissing", "SaveFailed"]) {
|
||||
assert.equal(isFatal({ kind }), false, `${kind} was treated as fatal`);
|
||||
}
|
||||
});
|
||||
|
||||
test("a notice DISARMS the watchdog rather than being replaced by it", () => {
|
||||
const clock = fakeClock();
|
||||
const seen = [];
|
||||
const shell = createShell({
|
||||
onStatus: (t) => seen.push(t),
|
||||
timeoutMs: 8000,
|
||||
setTimer: clock.setTimer,
|
||||
clearTimer: clock.clearTimer,
|
||||
});
|
||||
// The order improvise-vep8 puts them in: armed first, then the reason.
|
||||
shell.arm("the in-page worker did not answer (see console)");
|
||||
assert.equal(shell.timesArmed(), 1, "the denial below is vacuous if it was never armed");
|
||||
shell.fail(noticeText({ kind: "ReadOnly", document: "a.improv", detail: "" }));
|
||||
clock.fire();
|
||||
assert.match(seen.at(-1), /read-only/);
|
||||
assert.equal(
|
||||
seen.some((t) => t.includes("did not answer")),
|
||||
false,
|
||||
`the watchdog spoke over a precise reason: ${JSON.stringify(seen)}`,
|
||||
);
|
||||
});
|
||||
|
||||
+31
-9
@@ -10,15 +10,15 @@ const DOC = params.get("doc") || "demo.improv";
|
||||
// and WRITTEN through the VFS, never injected past storage.
|
||||
const SEED = params.get("seed") || "demo.improv";
|
||||
|
||||
// Fetch the seed if there is one to fetch. A missing seed is not an error:
|
||||
// with none, the worker creates an empty model under DOC instead.
|
||||
// Fetch the seed. A failure is NOT decided here: the bytes and the reason both
|
||||
// go to Rust, which knows whether the document needed a first copy at all.
|
||||
async function seedBytes() {
|
||||
try {
|
||||
const res = await fetch(SEED);
|
||||
if (!res.ok) return undefined;
|
||||
return new Uint8Array(await res.arrayBuffer());
|
||||
} catch (_) {
|
||||
return undefined;
|
||||
if (!res.ok) return { error: `${SEED} answered ${res.status} ${res.statusText}` };
|
||||
return { bytes: new Uint8Array(await res.arrayBuffer()) };
|
||||
} catch (err) {
|
||||
return { error: `${SEED} could not be fetched: ${err?.message ?? err}` };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,20 +28,42 @@ function post(replies) {
|
||||
for (const reply of replies) self.postMessage(reply);
|
||||
}
|
||||
|
||||
/** A shell notice, posted as an OBJECT so the page can tell it from a protocol
|
||||
* frame without parsing one. See `shell.js::classifyWorkerMessage`. */
|
||||
function notify(notice) {
|
||||
self.postMessage(notice);
|
||||
}
|
||||
|
||||
async function handle(data) {
|
||||
const replies = server.on_message(data);
|
||||
post(replies);
|
||||
await server.flush();
|
||||
// A persist nobody asked for that did not happen (improvise-zre1). Drained
|
||||
// per message, so one failure is reported once.
|
||||
const raised = server.take_notice();
|
||||
if (raised) notify(JSON.parse(raised));
|
||||
}
|
||||
|
||||
// Startup, and the head of the chain: frames arrive before this settles, and
|
||||
// chaining onto it holds them in arrival order with no queue to drain.
|
||||
const started = (async () => {
|
||||
await init();
|
||||
server = await start(DOC, await seedBytes());
|
||||
const seed = await seedBytes();
|
||||
server = await start(DOC, seed.bytes, seed.error);
|
||||
// Read once, straight after start: read-only, or an empty document whose
|
||||
// seed never arrived. Both are open sessions, so this is not a failure.
|
||||
const boot = server.boot_notice();
|
||||
if (boot) notify(JSON.parse(boot));
|
||||
})();
|
||||
started.catch((err) => {
|
||||
console.error("improvise worker: failed to start", err);
|
||||
// A worker that rejects in `start()` fires NO error on the main thread, so
|
||||
// without this the page has only a watchdog and no reason (improvise-1wft).
|
||||
notify({
|
||||
kind: "BootFailed",
|
||||
document: DOC,
|
||||
detail: String(err?.message ?? err),
|
||||
});
|
||||
});
|
||||
|
||||
// One frame at a time, in arrival order, and the listener is deliberately NOT
|
||||
@@ -49,8 +71,8 @@ started.catch((err) => {
|
||||
const onFrame = serialize(
|
||||
started,
|
||||
async (data) => {
|
||||
// `start()` failed. There is nothing to answer with, and the main
|
||||
// thread's handshake watchdog is what tells the user.
|
||||
// `start()` failed, and the page has already been told why by the
|
||||
// `BootFailed` notice above. There is nothing to answer with.
|
||||
if (!server) return;
|
||||
await handle(data);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user