mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-28 22:27:21 +02:00
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:
@@ -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.
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
Reference in New Issue
Block a user