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:
Gregory Schier
2026-08-15 16:07:41 -07:00
parent 0a7a9f7a2b
commit 6f49d7be00
6 changed files with 84 additions and 45 deletions
+11 -4
View File
@@ -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.
+5
View File
@@ -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;
+6
View File
@@ -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();
+8 -3
View File
@@ -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";
+54 -38
View File
@@ -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);
}