mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-16 16:42:01 +02:00
Gate yaak-web to wasm32 and make the DB lock cover every worker
CI runs `cargo test --all`, which compiled yaak-web for the host and failed in sqlite-wasm-rs's C shim. The crate is now `#![cfg(target_arch = "wasm32")]` with its browser-only dependencies target-scoped, so it is empty natively. Review caught a race in the worker fallback: a shared worker that starts after the tab has given up on it and taken a dedicated worker would open the database with no lock. The Web Lock now guards every worker, shared or not — requested with a short timeout so a reloading tab's dying predecessor is waited out, then reported. The tab also closes the port it abandoned. Verified in production builds: 8/8 shared and 3/3 dedicated reloads render, and a second tab in dedicated mode is told the database is in use.
This commit is contained in:
@@ -16,15 +16,22 @@ wasm-opt = false # Matches yaak-templates; wasm-opt has caused errors in CI
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
# The whole crate is `#![cfg(target_arch = "wasm32")]`: on a native target it
|
||||
# is empty, so a workspace-wide `cargo test` neither builds SQLite's wasm shim
|
||||
# for the host (which fails) nor links a browser-only runtime. Everything that
|
||||
# only exists for wasm is a target-scoped dependency for the same reason.
|
||||
|
||||
[dependencies]
|
||||
console_error_panic_hook = "0.1"
|
||||
js-sys = "0.3"
|
||||
log = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde-wasm-bindgen = "0.6.5"
|
||||
serde_json = { workspace = true }
|
||||
yaak-models = { workspace = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
console_error_panic_hook = "0.1"
|
||||
js-sys = "0.3"
|
||||
serde-wasm-bindgen = "0.6.5"
|
||||
sqlite-wasm-rs = "0.5"
|
||||
sqlite-wasm-vfs = "0.2"
|
||||
wasm-bindgen = "0.2.100"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
yaak-models = { workspace = true }
|
||||
|
||||
Binary file not shown.
@@ -15,6 +15,11 @@
|
||||
//! its model store coherent, and blob storage. Sending, plugins, git, sync and
|
||||
//! everything else with a socket or a filesystem behind it lives elsewhere.
|
||||
|
||||
// Nothing in here means anything off wasm32, and building it there would drag
|
||||
// SQLite's wasm C shim into a native compile. So on any other target the crate
|
||||
// is empty — a workspace-wide `cargo test` passes through it.
|
||||
#![cfg(target_arch = "wasm32")]
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::sync::mpsc;
|
||||
|
||||
|
||||
@@ -137,6 +137,12 @@ export class WorkerConnection {
|
||||
* 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();
|
||||
|
||||
@@ -38,8 +38,13 @@ export type FromWorker =
|
||||
export const WORKER_NAME = "yaak-db";
|
||||
|
||||
/**
|
||||
* The Web Lock a dedicated (non-shared) worker takes so a second tab cannot
|
||||
* open a second SQLite over the same pages. Shared workers don't need it: the
|
||||
* browser guarantees one of them.
|
||||
* The Web Lock every worker takes before opening the database, shared or not.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export const DB_LOCK_NAME = "yaak-db";
|
||||
|
||||
@@ -10,10 +10,12 @@
|
||||
* `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 makes
|
||||
* "one database, many tabs" true by construction. Where it doesn't (Android
|
||||
* Chrome), it runs as a dedicated worker and takes a Web Lock so a second tab
|
||||
* fails to open loudly instead of opening a second SQLite over the same pages.
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { DB_LOCK_NAME, type FromWorker, type ToWorker } from "./protocol";
|
||||
@@ -47,43 +49,57 @@ function errorMessage(err: unknown): string {
|
||||
return String(err);
|
||||
}
|
||||
|
||||
/**
|
||||
* How long to wait for the database lock before concluding someone else has it
|
||||
* for good. The wait exists for one case: a tab reloading itself. Its old
|
||||
* worker still holds the lock while it is torn down, and the new worker only
|
||||
* needs it to let go — which takes milliseconds, not seconds. Anything longer
|
||||
* is a live worker in another tab, and the honest answer is to say so.
|
||||
*/
|
||||
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.";
|
||||
|
||||
/**
|
||||
* Take the lock, or explain why not.
|
||||
*
|
||||
* Held for the life of the worker: the callback's promise never settles, so
|
||||
* the browser keeps the lock until this worker is gone. Requested with a
|
||||
* timeout rather than `ifAvailable`, so a dying predecessor's brief hold is
|
||||
* waited out but a live one is reported.
|
||||
*/
|
||||
function acquireDatabaseLock(): Promise<void> {
|
||||
if (typeof navigator.locks === "undefined") {
|
||||
// No way to guarantee exclusivity; proceed and hope. This is old browsers
|
||||
// only, and they will get one tab working.
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
navigator.locks
|
||||
.request(DB_LOCK_NAME, { signal: AbortSignal.timeout(LOCK_TIMEOUT_MS) }, () => {
|
||||
resolve();
|
||||
return new Promise<void>(() => {});
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const name = (err as { name?: string } | null)?.name;
|
||||
reject(name === "AbortError" || name === "TimeoutError" ? new Error(ALREADY_OPEN) : err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the database, once, for everyone.
|
||||
*
|
||||
* In a dedicated worker this also takes the lock. `ifAvailable` returns null
|
||||
* rather than queueing, because queueing would mean a second tab silently
|
||||
* hangs until the first closes — worse than telling it what is going on.
|
||||
* Lock first, then load the model layer, then open — in that order, so a
|
||||
* worker that will never own the database also never downloads and compiles
|
||||
* the wasm for it.
|
||||
*/
|
||||
function bootOnce(isShared: boolean): Promise<void> {
|
||||
function bootOnce(): Promise<void> {
|
||||
if (booted != null) return booted;
|
||||
|
||||
booted = (async () => {
|
||||
if (!isShared) {
|
||||
if (typeof navigator.locks === "undefined") {
|
||||
// No way to guarantee exclusivity; proceed and hope. This is old
|
||||
// browsers only, and they will get one tab working.
|
||||
const loaded = await import("@yaakapp-internal/web");
|
||||
await loaded.boot();
|
||||
engine = loaded;
|
||||
return;
|
||||
}
|
||||
const held = await new Promise<boolean>((resolve) => {
|
||||
void navigator.locks.request(DB_LOCK_NAME, { ifAvailable: true }, (lock) => {
|
||||
if (lock == null) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
resolve(true);
|
||||
// Hold the lock for as long as this worker lives
|
||||
return new Promise<void>(() => {});
|
||||
});
|
||||
});
|
||||
if (!held) {
|
||||
throw new Error(
|
||||
"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.",
|
||||
);
|
||||
}
|
||||
}
|
||||
await acquireDatabaseLock();
|
||||
const loaded = await import("@yaakapp-internal/web");
|
||||
await loaded.boot();
|
||||
engine = loaded;
|
||||
@@ -159,7 +175,7 @@ async function handle(port: MessagePort, message: ToWorker): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function attach(port: MessagePort, isShared: boolean): void {
|
||||
function attach(port: MessagePort): void {
|
||||
ports.add(port);
|
||||
port.onmessage = (e: MessageEvent<ToWorker>) => void handle(port, e.data);
|
||||
port.start?.();
|
||||
@@ -167,7 +183,7 @@ function attach(port: MessagePort, isShared: boolean): void {
|
||||
// Proof of life, before boot: the tab is timing this.
|
||||
send(port, { type: "hello" });
|
||||
|
||||
bootOnce(isShared).then(
|
||||
bootOnce().then(
|
||||
() => send(port, { type: "ready" }),
|
||||
(err) => send(port, { type: "boot_error", message: errorMessage(err) }),
|
||||
);
|
||||
@@ -178,8 +194,8 @@ function attach(port: MessagePort, isShared: boolean): void {
|
||||
const scope = self as unknown as { onconnect?: unknown };
|
||||
if ("onconnect" in scope) {
|
||||
(self as unknown as SharedWorkerGlobalScope).onconnect = (e: MessageEvent) => {
|
||||
attach(e.ports[0]!, true);
|
||||
attach(e.ports[0]!);
|
||||
};
|
||||
} else {
|
||||
attach(self as unknown as MessagePort, false);
|
||||
attach(self as unknown as MessagePort);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user