Simplify the worker connection

Drop the reconnect-and-retry loop. The one thing it recovered from — a
module worker missing connect events during a top-level-await import — is
fixed at the source by importing the wasm lazily, and it has not fired since.
A worker that stays silent past a generous timeout now shows a message
rather than being retried; a reload is the right remedy anyway. Pending
requests no longer keep copies for replay, and blob bodies are transferred.

Also correct the browser-support wording: every current browser, desktop
and mobile, has SharedWorker and Web Locks (Chrome for Android since 148),
so this is not a targeting decision — only browsers years out of date are
told they can't run Yaak.
This commit is contained in:
Gregory Schier
2026-08-15 16:58:42 -07:00
parent 2e01bb7ce2
commit cf25133dbb
2 changed files with 32 additions and 63 deletions
+4 -3
View File
@@ -53,9 +53,10 @@ Behaviours worth knowing before changing anything:
writes to all of them. It still takes a Web Lock before opening, for the one writes to all of them. It still takes a Web Lock before opening, for the one
overlap the browser doesn't rule out (a reloading tab's dying predecessor). overlap the browser doesn't rule out (a reloading tab's dying predecessor).
There is deliberately no fallback to a per-tab worker: two kinds of worker There is deliberately no fallback to a per-tab worker: two kinds of worker
that can both come up is a race, and the browsers without `SharedWorker` that can both come up is a race. Every current browser, desktop and mobile,
(Android Chrome) aren't a target for an API client. Those, and any without has `SharedWorker` and Web Locks (Chrome for Android since 148, April 2026);
Web Locks, get a clear "unsupported browser" message. older ones get a clear "unsupported browser" message rather than a second
SQLite over the same pages.
- **Every write is stamped with the calling tab's `label`** as - **Every write is stamped with the calling tab's `label`** as
`UpdateSource::Window`, exactly like a desktop window label, so the frontend `UpdateSource::Window`, exactly like a desktop window label, so the frontend
store's echo handling is unchanged. store's echo handling is unchanged.
+28 -60
View File
@@ -18,25 +18,17 @@ import { type FromWorker, type ToWorker, WORKER_NAME } from "./protocol";
* *
* A live worker answers in the same turn it is connected — the worker script * A live worker answers in the same turn it is connected — the worker script
* is tiny and imports the model layer lazily, so this measures liveness, not * is tiny and imports the model layer lazily, so this measures liveness, not
* load time. The one thing that can push it past this is a slow first fetch of * load time. It only has to be longer than a cold fetch of that small script;
* the script itself, and the cost of a false alarm there is a second connect * a worker silent past this is not coming, and a message beats a blank page.
* that the browser resolves to the same, now-live worker. The cost of guessing
* high is a user staring at a blank page, so err low.
*/ */
const HELLO_TIMEOUT_MS = 400; const HELLO_TIMEOUT_MS = 3000;
/** Connects to try before concluding the worker can't be started here. */
const MAX_ATTEMPTS = 3; const WORKER_FAILED = "Yaak's database worker could not be started. Reload the page to try again";
const UNSUPPORTED = const UNSUPPORTED =
"This browser can't run Yaak: it needs shared workers and Web Locks to keep your data safe across tabs. Every current desktop browser has both."; "This browser can't run Yaak: it needs shared workers and Web Locks to keep your data safe across tabs. Every current browser has both.";
type Pending = { type Pending = { resolve: (value: unknown) => void; reject: (reason: Error) => void };
resolve: (value: unknown) => void;
reject: (reason: Error) => void;
/** Kept so the request can be re-sent if the worker has to be replaced. */
message: ToWorker;
transfer: Transferable[];
};
export class WorkerConnection { export class WorkerConnection {
private port: MessagePort | null; private port: MessagePort | null;
@@ -55,16 +47,15 @@ export class WorkerConnection {
*/ */
readonly label = `tab_${crypto.randomUUID().slice(0, 8)}`; readonly label = `tab_${crypto.randomUUID().slice(0, 8)}`;
/** True once the worker has said anything at all; after that, no reconnects. */ /** True once the worker has said anything at all. */
private heard = false; private heard = false;
private attempts = 0;
constructor() { constructor() {
// Both are required and neither is faked. Without a shared worker every // Both are required and neither is faked. Without a shared worker every
// tab would need its own SQLite over the same pages; without Web Locks // tab would need its own SQLite over the same pages; without Web Locks
// nothing can promise there is only one even so. Every current desktop // nothing can promise there is only one even so. Every current browser,
// browser has both; the ones that don't get told, not corrupted. // desktop and mobile, has both (Chrome for Android since 148); the ones
// that don't get told, not corrupted.
if (typeof SharedWorker === "undefined" || typeof navigator.locks === "undefined") { if (typeof SharedWorker === "undefined" || typeof navigator.locks === "undefined") {
this.port = null; this.port = null;
this.bootError = UNSUPPORTED; this.bootError = UNSUPPORTED;
@@ -91,8 +82,6 @@ export class WorkerConnection {
* ships as raw TypeScript. * ships as raw TypeScript.
*/ */
private connect(): MessagePort { private connect(): MessagePort {
this.attempts += 1;
this.heard = false;
const worker = new SharedWorker(new URL("./worker.ts", import.meta.url), { const worker = new SharedWorker(new URL("./worker.ts", import.meta.url), {
type: "module", type: "module",
name: WORKER_NAME, name: WORKER_NAME,
@@ -100,44 +89,22 @@ export class WorkerConnection {
// A worker whose script fails to load fires `error` on the SharedWorker // A worker whose script fails to load fires `error` on the SharedWorker
// object and nothing else — the port just goes quiet. // object and nothing else — the port just goes quiet.
worker.onerror = () => { worker.onerror = () => {
if (!this.heard) this.reconnect("script failed to load"); if (!this.heard) this.failEverything(`${WORKER_FAILED} (its script failed to load).`);
}; };
worker.port.onmessage = (e: MessageEvent<FromWorker>) => this.receive(e.data); worker.port.onmessage = (e: MessageEvent<FromWorker>) => this.receive(e.data);
worker.port.start(); worker.port.start();
this.expectHello(worker.port);
return worker.port;
}
/** // The worker says hello synchronously on connect. Silence past the timeout
* The worker says hello synchronously on connect. If it doesn't, this port // means this port is attached to nothing that will ever answer, and the
* is attached to nothing that will ever answer — a worker caught // user should see that rather than a blank page. It is not retried: the
* mid-teardown, which is what a tab reloading itself can hand to the next // one way this used to happen (a module worker missing connects during a
* document — and the only move is to connect again. The browser resolves // top-level-await import) is fixed at the source by importing the wasm
* that to the same worker if it is alive, or a fresh one if it is gone; // lazily, and a reload is the right remedy for anything else.
* either way there is only ever one.
*/
private expectHello(port: MessagePort): void {
// Passed in rather than read from `this.port`, which the constructor has
// not assigned yet the first time this runs.
setTimeout(() => { setTimeout(() => {
if (!this.heard && this.port === port) this.reconnect("no reply from worker"); if (!this.heard) this.failEverything(`${WORKER_FAILED} (it never answered).`);
}, HELLO_TIMEOUT_MS); }, HELLO_TIMEOUT_MS);
}
private reconnect(why: string): void { return worker.port;
this.port?.close();
if (this.attempts >= MAX_ATTEMPTS) {
const message = `The database worker could not be started (${why}). ${UNSUPPORTED}`;
this.failEverything(message);
return;
}
console.warn(`Reconnecting to the database worker (${why})`);
this.port = this.connect();
// Whatever was posted to the dead port never arrived. Bodies were copied,
// not transferred, precisely so they can be re-sent from here.
for (const p of this.pending.values()) {
this.post(p.message, p.transfer);
}
} }
private failEverything(message: string): void { private failEverything(message: string): void {
@@ -186,10 +153,9 @@ export class WorkerConnection {
private request<T>(build: (id: number) => ToWorker, transfer: Transferable[] = []): Promise<T> { private request<T>(build: (id: number) => ToWorker, transfer: Transferable[] = []): Promise<T> {
if (this.bootError != null) return Promise.reject(new Error(this.bootError)); if (this.bootError != null) return Promise.reject(new Error(this.bootError));
const id = this.nextId++; const id = this.nextId++;
const message = build(id);
return new Promise<T>((resolve, reject) => { return new Promise<T>((resolve, reject) => {
this.pending.set(id, { resolve: resolve as (v: unknown) => void, reject, message, transfer }); this.pending.set(id, { resolve: resolve as (v: unknown) => void, reject });
this.post(message, transfer); this.post(build(id), transfer);
}); });
} }
@@ -203,12 +169,14 @@ export class WorkerConnection {
} }
blobPut(blobId: string, bytes: Uint8Array): Promise<void> { blobPut(blobId: string, bytes: Uint8Array): Promise<void> {
// Copied rather than transferred: transferring would detach the caller's // Copied so the caller's buffer isn't detached out from under it, then
// buffer, and would leave nothing to re-send if the worker is replaced. // transferred so the copy isn't copied again crossing to the worker.
// Bodies are small enough that the copy is cheaper than the bookkeeping.
const copy = new Uint8Array(bytes.byteLength); const copy = new Uint8Array(bytes.byteLength);
copy.set(bytes); copy.set(bytes);
return this.request<void>((id) => ({ type: "blob_put", id, blobId, bytes: copy.buffer })); return this.request<void>(
(id) => ({ type: "blob_put", id, blobId, bytes: copy.buffer }),
[copy.buffer],
);
} }
blobDelete(blobId: string): Promise<void> { blobDelete(blobId: string): Promise<void> {