Drop the dedicated-worker fallback

Every review finding on the browser host was in the same forty lines: the
fallback from a SharedWorker to a per-tab worker. Two kinds of worker that
can both come up is a race, and each fix moved it rather than removed it.

The fallback existed for a browser we don't target (Android Chrome, no
SharedWorker) and a tooling limitation. Without it the invariant holds by
browser guarantee — one SharedWorker per origin — with the Web Lock covering
the one overlap the browser doesn't rule out, a reloading tab's dying
predecessor. Reconnect-on-silence stays, but reconnects to the same kind.
Browsers without SharedWorker or Web Locks get a clear message.

Verified in production builds: 8/8 reloads render, two tabs coherent,
unsupported browsers see the message.
This commit is contained in:
Gregory Schier
2026-08-15 16:43:57 -07:00
parent a5c0eb4c1d
commit 2e01bb7ce2
5 changed files with 92 additions and 129 deletions
+10 -7
View File
@@ -46,13 +46,16 @@ isn't one, so a desktop `npm run bootstrap` never depends on it.
Behaviours worth knowing before changing anything:
- **The worker is a `SharedWorker`**, which is what makes "one database, many
tabs" true by construction — and makes the browser look like the desktop:
one process holds the data, every window talks to it, it pushes writes to
all of them. Where `SharedWorker` is missing (Android Chrome) or its script
can't be fetched (some embedded browsers), the connection falls back to a
dedicated worker that takes a Web Lock; a second tab then gets a clear
"already open in another tab" instead of a second SQLite over the same pages.
- **The worker is a `SharedWorker`, and only that.** The browser hands every
tab on the origin the same one, which is what makes "one database owner"
true without anyone coordinating — and makes the browser look like the
desktop: one process holds the data, every window talks to it, it pushes
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).
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`
(Android Chrome) aren't a target for an API client. Those, and any without
Web Locks, get a clear "unsupported browser" message.
- **Every write is stamped with the calling tab's `label`** as
`UpdateSource::Window`, exactly like a desktop window label, so the frontend
store's echo handling is unchanged.
+66 -81
View File
@@ -24,8 +24,11 @@ import { type FromWorker, type ToWorker, WORKER_NAME } from "./protocol";
* high is a user staring at a blank page, so err low.
*/
const HELLO_TIMEOUT_MS = 400;
/** Shared-worker connects to try before settling for a dedicated worker. */
const MAX_SHARED_ATTEMPTS = 3;
/** Connects to try before concluding the worker can't be started here. */
const MAX_ATTEMPTS = 3;
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.";
type Pending = {
resolve: (value: unknown) => void;
@@ -36,7 +39,7 @@ type Pending = {
};
export class WorkerConnection {
private port: MessagePort | Worker;
private port: MessagePort | null;
private readonly pending = new Map<number, Pending>();
private readonly listeners = new Map<string, Set<(payload: unknown) => void>>();
private nextId = 1;
@@ -52,106 +55,84 @@ export class WorkerConnection {
*/
readonly label = `tab_${crypto.randomUUID().slice(0, 8)}`;
/** Whether the database is shared with other tabs, or this tab holds it alone. */
shared: boolean;
/** True once the worker has said anything at all; after that, no fallback. */
/** True once the worker has said anything at all; after that, no reconnects. */
private heard = false;
/** How many times a shared worker was tried before giving up on sharing. */
private sharedAttempts = 0;
private attempts = 0;
constructor() {
if (typeof SharedWorker !== "undefined") {
this.port = this.connectShared();
this.shared = true;
} else {
// No SharedWorker (Android Chrome). One tab owns the database; the
// worker takes a lock and a second tab is told so.
this.port = this.connectDedicated();
this.shared = false;
// 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
// nothing can promise there is only one even so. Every current desktop
// browser has both; the ones that don't get told, not corrupted.
if (typeof SharedWorker === "undefined" || typeof navigator.locks === "undefined") {
this.port = null;
this.bootError = UNSUPPORTED;
showBootError(UNSUPPORTED);
return;
}
// Let the worker forget this port. Not load-bearing — a SharedWorker port
// that never says goodbye is a leaked entry in a Set — but tidy.
this.port = this.connect();
// Let the worker forget this port. Not load-bearing — a port that never
// says goodbye is a leaked entry in a Set — but tidy.
window.addEventListener("pagehide", () => this.post({ type: "goodbye" }));
}
/*
* `new URL("./worker.ts", import.meta.url)` is written out inline at each
* constructor on purpose: that exact syntax is what the bundler pattern-
* matches to know it must bundle a worker entry. Hoisted into a variable it
* becomes an asset URL and ships as raw TypeScript.
/**
* Connect to the origin's one database worker.
*
* The browser hands every tab the same SharedWorker for this name and URL,
* which is what makes "one database owner" true without anyone coordinating.
*
* `new URL("./worker.ts", import.meta.url)` is written out inline on purpose:
* that exact syntax is what the bundler pattern-matches to know it must
* bundle a worker entry. Hoisted into a variable it becomes an asset URL and
* ships as raw TypeScript.
*/
private connectShared(): MessagePort {
this.sharedAttempts += 1;
private connect(): MessagePort {
this.attempts += 1;
this.heard = false;
const worker = new SharedWorker(new URL("./worker.ts", import.meta.url), {
type: "module",
name: WORKER_NAME,
});
// A SharedWorker whose script fails to load fires `error` on the
// SharedWorker object and nothing else — the port just goes quiet. Some
// embedded browsers can't fetch shared-worker scripts at all.
// A worker whose script fails to load fires `error` on the SharedWorker
// object and nothing else — the port just goes quiet.
worker.onerror = () => {
if (!this.heard) this.replaceWorker("script failed to load");
if (!this.heard) this.reconnect("script failed to load");
};
this.attach(worker.port);
worker.port.onmessage = (e: MessageEvent<FromWorker>) => this.receive(e.data);
worker.port.start();
this.expectHello(worker.port);
return worker.port;
}
private connectDedicated(): Worker {
const worker = new Worker(new URL("./worker.ts", import.meta.url), {
type: "module",
name: WORKER_NAME,
});
this.attach(worker);
this.expectHello(worker);
return worker;
}
private attach(port: MessagePort | Worker): void {
this.heard = false;
port.onmessage = (e: MessageEvent<FromWorker>) => this.receive(e.data);
if (port instanceof MessagePort) port.start();
}
/**
* The worker says hello synchronously on connect. If it doesn't, the port is
* attached to nothing that will ever answer — most often a shared worker
* caught mid-teardown, which is what a tab reloading itself hands to the
* next document — and the only move is to connect again.
* The worker says hello synchronously on connect. If it doesn't, this port
* is attached to nothing that will ever answer — a worker caught
* mid-teardown, which is what a tab reloading itself can hand to the next
* document — and the only move is to connect again. The browser resolves
* that to the same worker if it is alive, or a fresh one if it is gone;
* either way there is only ever one.
*/
private expectHello(port: MessagePort | Worker): void {
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(() => {
if (!this.heard && this.port === port) this.replaceWorker("no reply from worker");
if (!this.heard && this.port === port) this.reconnect("no reply from worker");
}, HELLO_TIMEOUT_MS);
}
/**
* Replace a worker that never answered. Tries sharing again a few times —
* a torn-down shared worker is gone by then and a fresh one comes up — and
* only then gives up on sharing and takes a dedicated worker.
*/
private replaceWorker(why: string): void {
// Let go of the port that never answered. If a slow shared worker does
// come up later, it will find its port closed and — since it takes the
// same lock the replacement takes — cannot open the database beneath us.
if (this.port instanceof MessagePort) this.port.close();
else this.port.terminate();
if (this.sharedAttempts < MAX_SHARED_ATTEMPTS) {
console.warn(`Reconnecting to the database worker (${why})`);
this.port = this.connectShared();
this.shared = true;
} else {
console.warn(`Falling back to a dedicated database worker (${why})`);
this.port = this.connectDedicated();
this.shared = false;
private reconnect(why: string): void {
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()) {
@@ -159,8 +140,17 @@ export class WorkerConnection {
}
}
private failEverything(message: string): void {
this.bootError = message;
showBootError(message);
for (const [id, p] of this.pending) {
this.pending.delete(id);
p.reject(new Error(message));
}
}
private post(message: ToWorker, transfer: Transferable[] = []): void {
this.port.postMessage(message, transfer);
this.port?.postMessage(message, transfer);
}
private receive(message: FromWorker): void {
@@ -170,15 +160,10 @@ export class WorkerConnection {
case "ready":
return;
case "boot_error":
this.bootError = message.message;
// Nothing will ever answer, and the app cannot render without an
// answer, so say what happened where the user can see it. This is the
// page's whole content at this point.
showBootError(message.message);
for (const [id, p] of this.pending) {
this.pending.delete(id);
p.reject(new Error(message.message));
}
this.failEverything(message.message);
return;
case "result": {
const p = this.pending.get(message.id);
-5
View File
@@ -160,11 +160,6 @@ export function createWebPlatform(): Platform {
// without reading the source.
(window as unknown as Record<string, unknown>).__YAAK_WEB__ = {
label: db.label,
// A getter: the connection may fall back from shared to dedicated after
// this object is built.
get sharedWorker() {
return db.shared;
},
capabilities,
commands: commandSupport,
};
+4 -7
View File
@@ -38,13 +38,10 @@ export type FromWorker =
export const WORKER_NAME = "yaak-db";
/**
* The Web Lock every worker takes before opening the database, shared or not.
* The Web Lock the worker takes before opening the database.
*
* A SharedWorker is the *preferred* owner because the browser gives all tabs
* the same one — but it is not the *guarantee*. A tab that gave up waiting for
* a slow shared worker and fell back to a dedicated one must not find the
* shared worker starting up behind it and opening a second SQLite over the
* same pages. The lock is what makes "one database owner per origin" true no
* matter how the workers arrived.
* The browser already guarantees one SharedWorker per origin for this name.
* The lock covers the one overlap it doesn't rule out: a tab reloading itself,
* whose old worker may still be letting go while the new one comes up.
*/
export const DB_LOCK_NAME = "yaak-db";
+12 -29
View File
@@ -10,12 +10,11 @@
* `model_writes` out to every port — so two tabs are coherent for the same
* reason two desktop windows are, not because of a side channel.
*
* It runs as a SharedWorker where the browser has one, which is what gives
* every tab the same worker. Where it doesn't (Android Chrome), it runs as a
* dedicated worker instead. Either way it takes a Web Lock before opening the
* database, and that lock — not the worker kind — is what guarantees there is
* exactly one SQLite over these pages: a second worker, however it came to
* exist, fails loudly rather than opening another.
* It is a SharedWorker, and only that: the browser hands every tab on the
* origin the same one, which is what makes "one database owner" true without
* anyone coordinating. It still takes a Web Lock before opening the database,
* for the one overlap the browser doesn't rule out — a tab reloading itself,
* whose old worker may still be letting go while the new one comes up.
*/
import { DB_LOCK_NAME, type FromWorker, type ToWorker } from "./protocol";
@@ -59,7 +58,7 @@ function errorMessage(err: unknown): string {
const LOCK_TIMEOUT_MS = 3000;
const ALREADY_OPEN =
"Yaak is already open in another tab, and this browser can't share a database between tabs. Close the other tab, or use it instead.";
"Yaak's database is held by another worker that isn't letting go. Close Yaak's other tabs and reload.";
/**
* Take the lock, or explain why not.
@@ -70,18 +69,8 @@ const ALREADY_OPEN =
* waited out but a live one is reported.
*/
function acquireDatabaseLock(): Promise<void> {
if (typeof navigator.locks === "undefined") {
// Without Web Locks there is no way to promise a second tab won't open a
// second SQLite over the same pages, and a hopeful open is a corrupted
// workspace waiting to happen. Refuse. This is iOS Safari before 15.4 and
// Android Chrome before 69 — browsers the rest of the app has already
// left behind.
return Promise.reject(
new Error(
"This browser can't keep Yaak's data safe when more than one tab is open (it has no Web Locks). Please use a newer browser.",
),
);
}
// The tab checked for Web Locks before it ever connected; a worker without
// them would be a browser lying about its own features.
return new Promise<void>((resolve, reject) => {
navigator.locks
.request(DB_LOCK_NAME, { signal: AbortSignal.timeout(LOCK_TIMEOUT_MS) }, () => {
@@ -196,13 +185,7 @@ function attach(port: MessagePort): void {
);
}
// A SharedWorker sees each tab arrive as a connect event; a dedicated worker
// *is* its one tab's port.
const scope = self as unknown as { onconnect?: unknown };
if ("onconnect" in scope) {
(self as unknown as SharedWorkerGlobalScope).onconnect = (e: MessageEvent) => {
attach(e.ports[0]!);
};
} else {
attach(self as unknown as MessagePort);
}
// Each tab arrives as a connect event with its own port.
(self as unknown as SharedWorkerGlobalScope).onconnect = (e: MessageEvent) => {
attach(e.ports[0]!);
};