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: Behaviours worth knowing before changing anything:
- **The worker is a `SharedWorker`**, which is what makes "one database, many - **The worker is a `SharedWorker`, and only that.** The browser hands every
tabs" true by construction — and makes the browser look like the desktop: tab on the origin the same one, which is what makes "one database owner"
one process holds the data, every window talks to it, it pushes writes to true without anyone coordinating — and makes the browser look like the
all of them. Where `SharedWorker` is missing (Android Chrome) or its script desktop: one process holds the data, every window talks to it, it pushes
can't be fetched (some embedded browsers), the connection falls back to a writes to all of them. It still takes a Web Lock before opening, for the one
dedicated worker that takes a Web Lock; a second tab then gets a clear overlap the browser doesn't rule out (a reloading tab's dying predecessor).
"already open in another tab" instead of a second SQLite over the same pages. 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 - **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.
+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. * high is a user staring at a blank page, so err low.
*/ */
const HELLO_TIMEOUT_MS = 400; const HELLO_TIMEOUT_MS = 400;
/** Shared-worker connects to try before settling for a dedicated worker. */ /** Connects to try before concluding the worker can't be started here. */
const MAX_SHARED_ATTEMPTS = 3; 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 = { type Pending = {
resolve: (value: unknown) => void; resolve: (value: unknown) => void;
@@ -36,7 +39,7 @@ type Pending = {
}; };
export class WorkerConnection { export class WorkerConnection {
private port: MessagePort | Worker; private port: MessagePort | null;
private readonly pending = new Map<number, Pending>(); private readonly pending = new Map<number, Pending>();
private readonly listeners = new Map<string, Set<(payload: unknown) => void>>(); private readonly listeners = new Map<string, Set<(payload: unknown) => void>>();
private nextId = 1; private nextId = 1;
@@ -52,106 +55,84 @@ export class WorkerConnection {
*/ */
readonly label = `tab_${crypto.randomUUID().slice(0, 8)}`; readonly label = `tab_${crypto.randomUUID().slice(0, 8)}`;
/** Whether the database is shared with other tabs, or this tab holds it alone. */ /** True once the worker has said anything at all; after that, no reconnects. */
shared: boolean;
/** True once the worker has said anything at all; after that, no fallback. */
private heard = false; private heard = false;
/** How many times a shared worker was tried before giving up on sharing. */ private attempts = 0;
private sharedAttempts = 0;
constructor() { constructor() {
if (typeof SharedWorker !== "undefined") { // Both are required and neither is faked. Without a shared worker every
this.port = this.connectShared(); // tab would need its own SQLite over the same pages; without Web Locks
this.shared = true; // nothing can promise there is only one even so. Every current desktop
} else { // browser has both; the ones that don't get told, not corrupted.
// No SharedWorker (Android Chrome). One tab owns the database; the if (typeof SharedWorker === "undefined" || typeof navigator.locks === "undefined") {
// worker takes a lock and a second tab is told so. this.port = null;
this.port = this.connectDedicated(); this.bootError = UNSUPPORTED;
this.shared = false; showBootError(UNSUPPORTED);
return;
} }
// Let the worker forget this port. Not load-bearing — a SharedWorker port this.port = this.connect();
// that never says goodbye is a leaked entry in a Set — but tidy.
// 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" })); window.addEventListener("pagehide", () => this.post({ type: "goodbye" }));
} }
/* /**
* `new URL("./worker.ts", import.meta.url)` is written out inline at each * Connect to the origin's one database worker.
* 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 * The browser hands every tab the same SharedWorker for this name and URL,
* becomes an asset URL and ships as raw TypeScript. * 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 connect(): MessagePort {
private connectShared(): MessagePort { this.attempts += 1;
this.sharedAttempts += 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,
}); });
// A SharedWorker whose script fails to load fires `error` on the // A worker whose script fails to load fires `error` on the SharedWorker
// SharedWorker object and nothing else — the port just goes quiet. Some // object and nothing else — the port just goes quiet.
// embedded browsers can't fetch shared-worker scripts at all.
worker.onerror = () => { 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); this.expectHello(worker.port);
return 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 * The worker says hello synchronously on connect. If it doesn't, this port
* attached to nothing that will ever answer — most often a shared worker * is attached to nothing that will ever answer — a worker caught
* caught mid-teardown, which is what a tab reloading itself hands to the * mid-teardown, which is what a tab reloading itself can hand to the next
* next document — and the only move is to connect again. * 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 // Passed in rather than read from `this.port`, which the constructor has
// not assigned yet the first time this runs. // not assigned yet the first time this runs.
setTimeout(() => { 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); }, HELLO_TIMEOUT_MS);
} }
/** private reconnect(why: string): void {
* Replace a worker that never answered. Tries sharing again a few times — this.port?.close();
* a torn-down shared worker is gone by then and a fresh one comes up — and if (this.attempts >= MAX_ATTEMPTS) {
* only then gives up on sharing and takes a dedicated worker. const message = `The database worker could not be started (${why}). ${UNSUPPORTED}`;
*/ this.failEverything(message);
private replaceWorker(why: string): void { return;
// 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;
} }
console.warn(`Reconnecting to the database worker (${why})`);
this.port = this.connect();
// Whatever was posted to the dead port never arrived. Bodies were copied, // Whatever was posted to the dead port never arrived. Bodies were copied,
// not transferred, precisely so they can be re-sent from here. // not transferred, precisely so they can be re-sent from here.
for (const p of this.pending.values()) { 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 { private post(message: ToWorker, transfer: Transferable[] = []): void {
this.port.postMessage(message, transfer); this.port?.postMessage(message, transfer);
} }
private receive(message: FromWorker): void { private receive(message: FromWorker): void {
@@ -170,15 +160,10 @@ export class WorkerConnection {
case "ready": case "ready":
return; return;
case "boot_error": case "boot_error":
this.bootError = message.message;
// Nothing will ever answer, and the app cannot render without an // 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 // answer, so say what happened where the user can see it. This is the
// page's whole content at this point. // page's whole content at this point.
showBootError(message.message); this.failEverything(message.message);
for (const [id, p] of this.pending) {
this.pending.delete(id);
p.reject(new Error(message.message));
}
return; return;
case "result": { case "result": {
const p = this.pending.get(message.id); const p = this.pending.get(message.id);
-5
View File
@@ -160,11 +160,6 @@ export function createWebPlatform(): Platform {
// without reading the source. // without reading the source.
(window as unknown as Record<string, unknown>).__YAAK_WEB__ = { (window as unknown as Record<string, unknown>).__YAAK_WEB__ = {
label: db.label, 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, capabilities,
commands: commandSupport, commands: commandSupport,
}; };
+4 -7
View File
@@ -38,13 +38,10 @@ export type FromWorker =
export const WORKER_NAME = "yaak-db"; 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 browser already guarantees one SharedWorker per origin for this name.
* the same one — but it is not the *guarantee*. A tab that gave up waiting for * The lock covers the one overlap it doesn't rule out: a tab reloading itself,
* a slow shared worker and fell back to a dedicated one must not find the * whose old worker may still be letting go while the new one comes up.
* 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.
*/ */
export const DB_LOCK_NAME = "yaak-db"; 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 * `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. * 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 * It is a SharedWorker, and only that: the browser hands every tab on the
* every tab the same worker. Where it doesn't (Android Chrome), it runs as a * origin the same one, which is what makes "one database owner" true without
* dedicated worker instead. Either way it takes a Web Lock before opening the * anyone coordinating. It still takes a Web Lock before opening the database,
* database, and that lock — not the worker kind — is what guarantees there is * for the one overlap the browser doesn't rule out — a tab reloading itself,
* exactly one SQLite over these pages: a second worker, however it came to * whose old worker may still be letting go while the new one comes up.
* exist, fails loudly rather than opening another.
*/ */
import { DB_LOCK_NAME, type FromWorker, type ToWorker } from "./protocol"; 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 LOCK_TIMEOUT_MS = 3000;
const ALREADY_OPEN = 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. * Take the lock, or explain why not.
@@ -70,18 +69,8 @@ const ALREADY_OPEN =
* waited out but a live one is reported. * waited out but a live one is reported.
*/ */
function acquireDatabaseLock(): Promise<void> { function acquireDatabaseLock(): Promise<void> {
if (typeof navigator.locks === "undefined") { // The tab checked for Web Locks before it ever connected; a worker without
// Without Web Locks there is no way to promise a second tab won't open a // them would be a browser lying about its own features.
// 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.",
),
);
}
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
navigator.locks navigator.locks
.request(DB_LOCK_NAME, { signal: AbortSignal.timeout(LOCK_TIMEOUT_MS) }, () => { .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 // Each tab arrives as a connect event with its own port.
// *is* its one tab's port. (self as unknown as SharedWorkerGlobalScope).onconnect = (e: MessageEvent) => {
const scope = self as unknown as { onconnect?: unknown }; attach(e.ports[0]!);
if ("onconnect" in scope) { };
(self as unknown as SharedWorkerGlobalScope).onconnect = (e: MessageEvent) => {
attach(e.ports[0]!);
};
} else {
attach(self as unknown as MessagePort);
}