From 5bbd508cdaef4768f93915f7227a6aa09442cfff Mon Sep 17 00:00:00 2001 From: Claas Date: Sun, 14 Dec 2025 20:47:49 +0100 Subject: [PATCH] Simplify leader election with Lock API --- app/src/socketProxy.ts | 148 +++++++----------------------------- app/src/workers/saveFile.js | 7 +- 2 files changed, 32 insertions(+), 123 deletions(-) diff --git a/app/src/socketProxy.ts b/app/src/socketProxy.ts index 12961f7..d500c66 100644 --- a/app/src/socketProxy.ts +++ b/app/src/socketProxy.ts @@ -4,6 +4,7 @@ * supported by Chrome on Android. But central state management and decryption is still done on the service worker * because it needs to be able to decrypt push notifications and there should only be one single instance that manages * the client state to avoid synchronization issues. + * See more [Leader election](https://en.wikipedia.org/wiki/Leader_election) */ import { messagesUrl } from "./messagesUrl"; @@ -33,137 +34,42 @@ async function setUpWebsocket() { return socket; } -const channel = new BroadcastChannel("websocket-tab-coordination"); - -type InitializedMessage = { - type: "initializing socket"; -}; - -type Message = - | { - type: "is socket alive?"; - } - | { type: "socket is alive"; } - | InitializedMessage; - -type TimedOut = { - type: "timed out"; - value?: never; -}; - -type TimeoutResult = - | TimedOut - | { - type: "success"; - value: T; - }; - - -function timeout(milliseconds: number) { - return new Promise((resolve) => - setTimeout(() => resolve(), milliseconds) - ); -} - -function withTimeout( - promise: Promise, - milliseconds: number -): Promise> { - const awaitPromise = async () => { - const result = await promise; - return { type: "success", value: result } as const; - }; - - return Promise.race([ - awaitPromise(), - timeout(milliseconds).then(() => ({ type: "timed out" } as const)), - ]); -} - -async function isMainTabAlive() { - channel.postMessage({ - type: "is socket alive?" - } satisfies Message); - - const keepAliveResponse = new Promise((resolve) => { - channel.addEventListener("message", (message: MessageEvent) => { - if (message.data.type !== "socket is alive") return; - resolve(); - }) - }) - - const initializedMessage = new Promise((resolve) => { - channel.addEventListener("message", (message: MessageEvent) => { - if (message.data.type !== "initializing socket") return; - resolve(message.data); - }) - }) - - const result = await Promise.race([initializedMessage, withTimeout(keepAliveResponse, 10_000)]) - - // Is either initialized or got a keep alive response - return result.type === "initializing socket" || result.type === "success"; -} - - -function handleKeepAlive(signal: AbortSignal){ - channel.addEventListener( - "message", - (event: MessageEvent) => { - switch (event.data.type) { - case "is socket alive?": { - console.debug("[Websocket] Keep alive received", event.data); - channel.postMessage({ - type: "socket is alive", - } satisfies Message); - return; - } - } - }, - { - signal, - } - ); -} - -async function runMainTab(){ - // Notify other tabs that we are taking over as main tab and they should not take over - channel.postMessage({ type: "initializing socket" } satisfies Message); - +async function runSocket() { const closeController = new AbortController(); // Respond to keep alive messages from other tabs - handleKeepAlive(closeController.signal); const socket = await setUpWebsocket(); - socket.addEventListener("error", (event) => { - console.error("[Websocket] Closing after error:", event); - socket.close(); - }, { once: true, signal: closeController.signal }); + socket.addEventListener( + "error", + (event) => { + console.error("[Websocket] Closing after error:", event); + socket.close(); + }, + { once: true, signal: closeController.signal } + ); // Don't close on window unload because it is not reliable and the websocket will be closed by the browser anyway - return await new Promise((resolve) => { - socket.addEventListener( - "close", - () => { - closeController.abort(); - resolve() - }, - { once: true } - ) - }); + const { promise, resolve } = Promise.withResolvers(); + socket.addEventListener( + "close", + () => { + console.debug("[Websocket] Closing socket"); + closeController.abort(); + resolve(); + }, + { once: true } + ); + return promise; } export async function runSocketProxy() { while (true) { - const isAlive = await isMainTabAlive(); - console.debug("[Websocket] Main tab alive:", isAlive); - if (isAlive) { - await timeout(10_000); - continue; - } - - // Take over main tab responsiblities - await runMainTab(); + console.debug("[Websocket] requesting lock"); + await navigator.locks.request("socket-proxy", async () => { + console.debug("[Websocket] acquired lock"); + await runSocket(); + console.debug("[Websocket] socket closed. Releasing lock"); + }); } } diff --git a/app/src/workers/saveFile.js b/app/src/workers/saveFile.js index ac4a341..a6c14fa 100644 --- a/app/src/workers/saveFile.js +++ b/app/src/workers/saveFile.js @@ -1,5 +1,8 @@ -// A worker to persist files on the origin private file system in Safari -// Safari does not support the FileSystemHandle.createWritable() method +/** + * A worker to persist files on the origin private file system in Safari + * Safari does not support the FileSystemHandle.createWritable() method. + * This is used to cache photos for preview and to avoid crashes + */ addEventListener("message", async (event) => { if ( !(event.data instanceof SharedArrayBuffer) && -- 2.51.2