feat(standalone): support multiple tabs via leader election - #10914
Conversation
|
🖥️ App preview is ready! 🔗 Preview URL: https://pr-10914.trilium-app.pages.dev ✅ All checks passed This preview will be updated automatically with new commits. |
Greptile SummaryThe PR enables multi-tab standalone operation by electing one database-owning tab and routing follower requests through it. Major changes:
Confidence Score: 5/5The PR appears safe to merge because no blocking failure remains. No blocking failure remains.
|
| Filename | Overview |
|---|---|
| apps/standalone/src/leader_election.ts | Elects one tab with an exclusive Web Lock and holds ownership for the tab lifetime. |
| apps/standalone/src/sw.ts | Discovers and caches the leader, preserves request bodies across retries, and bounds stale-leader recovery. |
| apps/standalone/src/local-bridge.ts | Gates worker ownership on leadership and relays entity-change messages across tabs. |
| apps/standalone/src/lightweight/db_lock.ts | Serializes exclusive asynchronous route work while retaining a synchronous shared fast path. |
| apps/standalone/e2e/multi_tab.spec.ts | Exercises shared database access, follower writes, cross-tab updates, and leader failover. |
Sequence Diagram
sequenceDiagram
participant F as Follower tab
participant SW as Service worker
participant L as Leader tab
participant DB as Dedicated DB worker
F->>SW: API request
SW->>L: LOCAL_FETCH
L->>DB: Forward request
DB-->>L: Response and entity changes
L-->>SW: LOCAL_FETCH_RESPONSE
SW-->>F: API response
L-->>F: Broadcast entity changes
Reviews (3): Last reviewed commit: "fix(standalone): re-send request body wh..." | Re-trigger Greptile
Bundle ReportChanges will increase total bundle size by 1.89kB (0.0%) ⬆️. This is within the configured threshold ✅ Detailed changes
Affected Assets, Files, and Routes:view changes for bundle: client-esmAssets Changed:
view changes for bundle: standalone-esmAssets Changed:
Files in
Files in
Files in
|
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Standalone keeps its database in the browser on the OPFS SAHPool VFS, whose sync access handles are exclusive to one dedicated worker per origin. Every tab spawned its own worker, so a second tab could not open the database at all: installSahPool() threw, the error was swallowed as "SAHPool VFS not available", and the tab silently fell back to an empty in-memory database. Because the service worker routed API traffic to an arbitrary window, that empty database could capture the first tab too. A Web Lock now elects a single leader tab. It alone starts a worker; the other tabs proxy their API calls to it through the service worker, and the leader relays entity changes back over a BroadcastChannel so every tab stays in sync. When the leader closes, the browser releases the lock and a waiting tab is promoted with no heartbeat or timeout involved. The service worker tracks which client is the leader, probes for it when that is unknown (it is evicted when idle), and retries elsewhere if a tab answers NOT_LEADER. Followers never start a worker even if asked. Also serialises async routes against the single SQLite connection. createAsyncRoute holds a transaction open across awaits, so a second async route would BEGIN inside it and a synchronous route would be folded in as a SAVEPOINT — meaning a failed import could roll back an unrelated request's writes. Latent before, but far likelier with several tabs. Co-Authored-By: Claude <noreply@anthropic.com>
The service worker read the request body directly, so the NOT_LEADER retry path forwarded an already-consumed Request and any body-bearing mutation failed with "Body has already been read" instead of reaching the current leader. Read from a clone so the original stays intact. Also bound the retry to a single attempt: leadership can flip between a tab answering WHO_IS_LEADER and that same tab serving the fetch, which could otherwise ping-pong between two tabs indefinitely. The spec's mock Request permitted unlimited body reads, which is why a POST test would have passed regardless; it now models real consumption semantics so both paths are actually covered. Co-Authored-By: Claude <noreply@anthropic.com>
befcb94 to
d3268eb
Compare
The problem
Standalone keeps its database in the browser on the OPFS SAHPool VFS, whose sync access handles are held exclusively by one dedicated worker per origin. But every tab spawned its own worker (
local-bridge.ts, frommain.ts), so with two tabs open:installSahPool()threwNoModificationAllowedError— tab 1 holds the handles.local-server-worker.tscaught it and logged it as "SAHPool VFS not available", conflating "another tab has it" with "this browser has no OPFS".loadFromMemory()— a fresh, empty database.sw.tsrouted API traffic tomatchAll()[0], so once tab 2 was focused both tabs could end up on that empty database.So it wasn't "the second tab fails" — the second tab silently came up blank, hijacked routing, and anything written there evaporated on close.
The fix
A Web Lock elects a single leader tab (
leader_election.ts). It alone starts a worker; the others proxy through the service worker, and the leader relays entity changes back over aBroadcastChannelso all tabs stay in sync. When the leader closes, the browser releases the lock and a queued tab is promoted — no heartbeat or timeout to get wrong.The service worker caches which client is the leader, probes for it when unknown (it is evicted when idle), and retries elsewhere if a tab answers
NOT_LEADER. Followers refuse to start a worker even if asked, so a stale route can never open a second database.The obvious design is a SharedWorker owning the database. It cannot work, proven against Chrome 151:
createSyncAccessHandleis exposed only inDedicatedWorkerGlobalScope, and the SAHPool VFS is built entirely on it. The obvious escape hatch — have the SharedWorker nest a dedicated worker — also fails:Note that SharedWorker itself is well supported (caniuse/caniwebview: iOS WKWebView 16.5+, Android WebView 148+). The blocker is the OPFS API it would need, which no support table covers. This was only caught by driving a real browser — the full unit suite passed against mocked ports.
Also included
db_lock.tsserialises async routes against the single SQLite connection.createAsyncRouteholds a transaction open acrossawaits, so a second async route wouldBEGIN IMMEDIATEinside it, and a synchronous route would be folded in as a SAVEPOINT (see the nesting branch atsql_provider.ts:546) — meaning a failed import could roll back an unrelated request's writes. Latent before (one tab already issues concurrent requests), much likelier with several. Shared work keeps a synchronous fast path and only becomes async while an exclusive holder is active.Testing
NOT_LEADERretry.Notes for review
componentIdwas investigated and needs no change:server.ts:187overrides the header per call and component ids are already${className}-${randomString(8)}(component.ts:27), so they're unique per tab.glob.componentId: ""is only an unused fallback.🤖 Generated with Claude Code