mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-18 01:15:12 +02:00
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:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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]!);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user