diff --git a/crates/yaak-web/Cargo.toml b/crates/yaak-web/Cargo.toml index fc13fa53..02e83860 100644 --- a/crates/yaak-web/Cargo.toml +++ b/crates/yaak-web/Cargo.toml @@ -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 } diff --git a/crates/yaak-web/pkg/yaak_web_bg.wasm b/crates/yaak-web/pkg/yaak_web_bg.wasm index f4680c64..347a3236 100644 Binary files a/crates/yaak-web/pkg/yaak_web_bg.wasm and b/crates/yaak-web/pkg/yaak_web_bg.wasm differ diff --git a/crates/yaak-web/src/lib.rs b/crates/yaak-web/src/lib.rs index d768b735..e2a764d3 100644 --- a/crates/yaak-web/src/lib.rs +++ b/crates/yaak-web/src/lib.rs @@ -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; diff --git a/packages/platform/src/web/connection.ts b/packages/platform/src/web/connection.ts index b43d8580..ceeba8b2 100644 --- a/packages/platform/src/web/connection.ts +++ b/packages/platform/src/web/connection.ts @@ -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(); diff --git a/packages/platform/src/web/protocol.ts b/packages/platform/src/web/protocol.ts index 71ad16f0..67623f0f 100644 --- a/packages/platform/src/web/protocol.ts +++ b/packages/platform/src/web/protocol.ts @@ -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"; diff --git a/packages/platform/src/web/worker.ts b/packages/platform/src/web/worker.ts index daa72f5d..cf3384bf 100644 --- a/packages/platform/src/web/worker.ts +++ b/packages/platform/src/web/worker.ts @@ -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 { + 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((resolve, reject) => { + navigator.locks + .request(DB_LOCK_NAME, { signal: AbortSignal.timeout(LOCK_TIMEOUT_MS) }, () => { + resolve(); + return new Promise(() => {}); + }) + .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 { +function bootOnce(): Promise { 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((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(() => {}); - }); - }); - 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 { } } -function attach(port: MessagePort, isShared: boolean): void { +function attach(port: MessagePort): void { ports.add(port); port.onmessage = (e: MessageEvent) => 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); }