atagen

malice

public
Star0
Watch0

Commit d9be72fdb393

tarball

atagen <boss@atagen.co>2026-10-26 09:38 UTC

12 files changed+227-10
web: let a newer build's engine supersede a stale device worker

A content-hashed worker URL means an upgrade spawns a second SharedWorker while the old one still holds the IndexedDB stores, and both event caches then write the same rows uncoordinated. Workers now announce their build over BroadcastChannel and the older one shuts its Host down and closes, while store_in_use accounts retry on release or with backoff for workers that predate this protocol.
Modified file: flake.nix+1 additions
@@ -86,6 +86,7 @@
8686 inherit pname postPatch;
8787 version = "0.1.0";
8888 src = cleanSrc ./web;
89+ MALICE_BUILD = "${toString self.lastModified}-${self.shortRev or self.dirtyShortRev or "dirty"}";
8990 npmDeps = webNodeModules pkgs;
9091 disallowedReferences = [
9192 (webNodeModules pkgs)
Modified file: web/src/App.svelte+5 additions−2 deletions
@@ -60,6 +60,7 @@
6060 } from './lib/api';
6161 import EmojiText from './lib/EmojiText.svelte';
6262 import UpdateBanner from './lib/UpdateBanner.svelte';
63+ import { engineRecovered } from './lib/engine-state';
6364 import { customEmotes, resolveEmotes } from './lib/emotes';
6465 import { isKnownCommand, parseCommand, splitTarget, unescapeCommand } from './lib/commands';
6566 import type { ParsedCommand } from './lib/commands';
@@ -354,6 +355,8 @@
354355 };
355356 });
356357
358+ $: if ($engineRecovered > 0 && $blocked) void boot();
359+
357360 async function boot(): Promise<void> {
358361 try {
359362 const session = await getSession();
@@ -1968,9 +1971,9 @@
19681971 <p>Loading Malice…</p>
19691972 </div>
19701973 {:else if $blocked}
1971- <div class="splash" role="status" aria-label="Malice is open in another tab" in:fade={{ duration: motionDuration(220) }}>
1974+ <div class="splash" role="status" aria-label="Close other Malice tabs" in:fade={{ duration: motionDuration(220) }}>
19721975 <div class="splash-mark" aria-hidden="true">M</div>
1973- <p>Malice is open in another tab. Close this tab or use that one instead.</p>
1976+ <p>Close other Malice tabs</p>
19741977 </div>
19751978 {:else if $sessionUser == null}
19761979 <LoginPanel softLogout={$softLogout} on:done={(ev) => afterLogin(ev.detail)} />
Modified file: web/src/lib/UpdateBanner.svelte+2 additions−1 deletions
@@ -1,8 +1,9 @@
11 <script lang="ts">
2+ import { engineStale } from './engine-state';
23 import { applyUpdate, updateReady } from './sw-register';
34 </script>
45
5-{#if $updateReady}
6+{#if $updateReady || $engineStale}
67 <div class="sec-banner" role="status">
78 <span>Update ready</span>
89 <button type="button" on:click={applyUpdate}>Reload</button>
Added file: web/src/lib/engine-build.ts+44 additions
@@ -0,0 +1,44 @@
1+export const ENGINE_CHANNEL = 'malice-engine';
2+
3+export type HelloAction = 'ignore' | 'yield' | 'announce';
4+
5+interface BuildStamp {
6+ seconds: number;
7+ tag: string;
8+}
9+
10+function parseBuild(build: string): BuildStamp | null {
11+ const match = /^(\d+)-(.+)$/.exec(build);
12+ return match ? { seconds: Number(match[1]), tag: match[2] } : null;
13+}
14+
15+/** Positive when `a` is newer than `b`, null when either is not a build id. */
16+export function compareBuilds(a: string, b: string): number | null {
17+ const left = parseBuild(a);
18+ const right = parseBuild(b);
19+ if (!left || !right) return null;
20+ if (left.seconds !== right.seconds) return left.seconds - right.seconds;
21+ return left.tag < right.tag ? -1 : left.tag > right.tag ? 1 : 0;
22+}
23+
24+/**
25+ * What a worker of build `own` does on hearing a hello from build `other`.
26+ * A newer peer wins the store lock, so the worker yields. An older peer is
27+ * told to yield by repeating our own hello, which it will judge the same way.
28+ */
29+export function helloAction(own: string, other: string): HelloAction {
30+ const order = compareBuilds(other, own);
31+ if (order === null || order === 0) return 'ignore';
32+ return order > 0 ? 'yield' : 'announce';
33+}
34+
35+export const RELOAD_GUARD_MS = 60_000;
36+
37+/** A superseded tab reloads itself once, so a shell stuck on the old build cannot loop. */
38+export function mayAutoReload(now: number, lastReload: number | null): boolean {
39+ return lastReload === null || now - lastReload >= RELOAD_GUARD_MS;
40+}
41+
42+export function retryDelay(attempt: number): number {
43+ return Math.min(1000 * 2 ** attempt, 8000);
44+}
Added file: web/src/lib/engine-state.ts+22 additions
@@ -0,0 +1,22 @@
1+import { writable } from 'svelte/store';
2+import { mayAutoReload } from './engine-build';
3+
4+const ENGINE_RELOAD_GUARD = 'malice-engine-reload';
5+
6+export const engineStale = writable(false);
7+export const engineRecovered = writable(0);
8+
9+function reloadWhenHidden(): void {
10+ if (document.visibilityState !== 'hidden') return;
11+ const last = Number(sessionStorage.getItem(ENGINE_RELOAD_GUARD)) || null;
12+ const now = Date.now();
13+ if (!mayAutoReload(now, last)) return;
14+ sessionStorage.setItem(ENGINE_RELOAD_GUARD, String(now));
15+ window.location.reload();
16+}
17+
18+export function markEngineStale(): void {
19+ engineStale.set(true);
20+ document.addEventListener('visibilitychange', reloadWhenHidden);
21+ reloadWhenHidden();
22+}
Modified file: web/src/lib/sw-register.ts+2 additions−1 deletions
@@ -7,7 +7,8 @@ export const updateReady = writable(false);
77 let waiting: ServiceWorker | null = null;
88
99 export function applyUpdate(): void {
10- waiting?.postMessage({ type: 'SKIP_WAITING' });
10+ if (waiting) waiting.postMessage({ type: 'SKIP_WAITING' });
11+ else window.location.reload();
1112 }
1213
1314 function watchForUpdates(registration: ServiceWorkerRegistration): void {
Modified file: web/src/lib/transport-worker.ts+23 additions
@@ -5,6 +5,7 @@ import { ApiError, type CommandRequest, type SendKind, type SessionInfo, type So
55 import { statusForKind } from './wire-status';
66 import { workerName } from './trace-name';
77 import { pageTraceFilter } from './trace-page';
8+import { markEngineStale, engineRecovered } from './engine-state';
89 import workerUrl from '../worker/worker.ts?worker&url';
910
1011 function toApiError(error: { kind: string; message: string }): ApiError {
@@ -27,6 +28,7 @@ class WorkerClient {
2728 private listeners = new Set<(account: string, event: string) => void>();
2829 private fatalHandlers = new Set<() => void>();
2930 private subscribed = new Set<string>();
31+ private superseded = false;
3032
3133 constructor() {
3234 window.addEventListener('pagehide', () => {
@@ -59,6 +61,7 @@ class WorkerClient {
5961 }
6062
6163 private post(message: Record<string, unknown>, transfer?: Transferable[]): void {
64+ if (this.superseded) return;
6265 if (!this.port) this.connect();
6366 if (!this.port) return;
6467 if (transfer && transfer.length > 0) this.port.postMessage(message, transfer);
@@ -68,6 +71,10 @@ class WorkerClient {
6871 private rpc<T>(message: Record<string, unknown>, transfer?: Transferable[]): Promise<T> {
6972 const id = this.nextId++;
7073 return new Promise<T>((resolve, reject) => {
74+ if (this.superseded) {
75+ reject(new ApiError(502, 'the worker was replaced by a newer build'));
76+ return;
77+ }
7178 this.pending.set(id, { resolve: resolve as (value: unknown) => void, reject });
7279 this.post({ ...message, id }, transfer);
7380 });
@@ -95,6 +102,20 @@ class WorkerClient {
95102 return;
96103 }
97104 if (msg['kind'] === 'fatal') this.handleFatal();
105+ else if (msg['kind'] === 'superseded') this.handleSuperseded();
106+ else if (msg['kind'] === 'recovered') engineRecovered.update((count) => count + 1);
107+ }
108+
109+ /** A newer build took over the engine, so this tab's JS can no longer drive it. */
110+ private handleSuperseded(): void {
111+ this.superseded = true;
112+ this.port = null;
113+ for (const pending of this.pending.values()) {
114+ pending.reject(new ApiError(502, 'the worker was replaced by a newer build'));
115+ }
116+ this.pending.clear();
117+ for (const handler of this.fatalHandlers) handler();
118+ markEngineStale();
98119 }
99120
100121 /** The worker crashed and closed every port; every tab respawns it after a beat. */
@@ -114,7 +135,9 @@ class WorkerClient {
114135 soft_logout?: SoftLogout;
115136 account?: string;
116137 blocked?: true;
138+ build?: string;
117139 }>({ kind: 'session' });
140+ if (reply.build && reply.build !== __MALICE_BUILD__) markEngineStale();
118141 if (reply.blocked) this.account = null;
119142 else if (reply.account) this.account = reply.account;
120143 return { user_id: reply.user_id, soft_logout: reply.soft_logout, blocked: reply.blocked };
Modified file: web/src/vite-env.d.ts+1 additions
@@ -1,2 +1,3 @@
11 /// <reference types="vite/client" />
22 declare const __MALICE_DEVICE__: boolean;
3+declare const __MALICE_BUILD__: string;
Modified file: web/src/worker/worker.ts+63 additions−4 deletions
@@ -2,6 +2,7 @@ import init, { Host, init_tracing } from './pkg/malice_worker.js';
22 import { isRecord } from '../lib/guards';
33 import { traceFilter } from '../lib/trace-name';
44 import { PUSH_CHANNEL } from '../lib/push-model';
5+import { ENGINE_CHANNEL, helloAction, retryDelay } from '../lib/engine-build';
56 import { EventCoalescer } from './coalesce';
67 import {
78 createAccount,
@@ -41,7 +42,10 @@ const softAccounts = new Map<string, Identity>();
4142 const blockedAccounts = new Set<string>();
4243 const accountIdentity = new Map<string, Identity>();
4344
45+declare const __MALICE_BUILD__: string;
46+
4447 let host: Host;
48+let superseded = false;
4549
4650 function postTo(port: PortLike, message: Record<string, unknown>): void {
4751 port.postMessage(message);
@@ -110,6 +114,7 @@ async function restoreAccount(account: AccountInfo): Promise<void> {
110114 try {
111115 const passphrase = await getPassphrase(account.key);
112116 const result = await host.restore(account.key, passphrase);
117+ blockedAccounts.delete(account.key);
113118 if (result.status === 'resumed' && result.user_id) {
114119 accountIdentity.set(account.key, { user_id: result.user_id, homeserver: account.homeserver ?? '' });
115120 liveAccounts.add(account.key);
@@ -128,6 +133,7 @@ const ready = (async () => {
128133 init_tracing(traceFilter((self as unknown as { name: string }).name));
129134 host = new Host(onHostEvent, onHostFatal);
130135 for (const account of await listAccounts()) await restoreAccount(account);
136+ void retryBlockedWithBackoff();
131137 })();
132138
133139 interface SessionReply {
@@ -135,18 +141,19 @@ interface SessionReply {
135141 soft_logout?: Identity;
136142 account?: string;
137143 blocked?: true;
144+ build: string;
138145 }
139146
140147 function handleSession(): SessionReply {
141148 const liveKey = currentAccountKey();
142149 if (liveKey) {
143150 const identity = accountIdentity.get(liveKey);
144- return { user_id: identity?.user_id ?? null, account: liveKey };
151+ return { user_id: identity?.user_id ?? null, account: liveKey, build: __MALICE_BUILD__ };
145152 }
146- if (blockedAccounts.size > 0) return { user_id: null, blocked: true };
153+ if (blockedAccounts.size > 0) return { user_id: null, blocked: true, build: __MALICE_BUILD__ };
147154 const soft = softAccounts.values().next();
148- if (!soft.done) return { user_id: null, soft_logout: soft.value };
149- return { user_id: null };
155+ if (!soft.done) return { user_id: null, soft_logout: soft.value, build: __MALICE_BUILD__ };
156+ return { user_id: null, build: __MALICE_BUILD__ };
150157 }
151158
152159 async function handleLogin(
@@ -294,6 +301,7 @@ mediaChannel.onmessage = (ev: MessageEvent) => void handleMediaRequest(ev.data);
294301
295302 async function handleMediaRequest(msg: unknown): Promise<void> {
296303 await ready;
304+ if (superseded) return;
297305 if (!isRecord(msg) || typeof msg['id'] !== 'string') return;
298306 const id = msg['id'];
299307 const account = currentAccountKey();
@@ -333,6 +341,7 @@ pushChannel.onmessage = (ev: MessageEvent) => void handlePushRequest(ev.data);
333341
334342 async function handlePushRequest(msg: unknown): Promise<void> {
335343 await ready;
344+ if (superseded) return;
336345 if (!isRecord(msg) || typeof msg['id'] !== 'string' || msg['kind'] !== 'resolve') return;
337346 const id = msg['id'];
338347 const account = currentAccountKey();
@@ -348,3 +357,53 @@ async function handlePushRequest(msg: unknown): Promise<void> {
348357 pushChannel.postMessage({ id, error: toWireError(error) });
349358 }
350359 }
360+
361+const engineChannel = new BroadcastChannel(ENGINE_CHANNEL);
362+
363+async function retryBlocked(): Promise<void> {
364+ await ready;
365+ if (superseded || blockedAccounts.size === 0) return;
366+ const blocked = (await listAccounts()).filter((account) => blockedAccounts.has(account.key));
367+ for (const account of blocked) await restoreAccount(account);
368+ if (blockedAccounts.size < blocked.length) for (const port of ports) postTo(port, { kind: 'recovered' });
369+}
370+
371+let retryChain: Promise<void> = Promise.resolve();
372+
373+function queueRetry(): Promise<void> {
374+ retryChain = retryChain.then(retryBlocked, retryBlocked);
375+ return retryChain;
376+}
377+
378+async function retryBlockedWithBackoff(): Promise<void> {
379+ for (let attempt = 0; blockedAccounts.size > 0 && !superseded; attempt++) {
380+ await new Promise((resolve) => setTimeout(resolve, retryDelay(attempt)));
381+ await queueRetry();
382+ }
383+}
384+
385+async function stepDown(): Promise<void> {
386+ if (superseded) return;
387+ superseded = true;
388+ await ready.catch(() => undefined);
389+ coalescer.flush();
390+ for (const port of ports) postTo(port, { kind: 'superseded' });
391+ await host?.shutdown().catch(() => undefined);
392+ engineChannel.postMessage({ type: 'released', build: __MALICE_BUILD__ });
393+ close();
394+}
395+
396+engineChannel.onmessage = (ev: MessageEvent) => {
397+ const msg: unknown = ev.data;
398+ if (superseded || !isRecord(msg) || typeof msg['build'] !== 'string') return;
399+ if (msg['type'] === 'released') {
400+ if (msg['build'] !== __MALICE_BUILD__) void queueRetry();
401+ return;
402+ }
403+ if (msg['type'] !== 'hello') return;
404+ const action = helloAction(__MALICE_BUILD__, msg['build']);
405+ if (action === 'yield') void stepDown();
406+ else if (action === 'announce') engineChannel.postMessage({ type: 'hello', build: __MALICE_BUILD__ });
407+};
408+
409+engineChannel.postMessage({ type: 'hello', build: __MALICE_BUILD__ });
Added file: web/test/engine-build.test.ts+50 additions
@@ -0,0 +1,50 @@
1+import assert from 'node:assert/strict';
2+import { test } from 'node:test';
3+import { compareBuilds, helloAction, mayAutoReload, retryDelay } from '../src/lib/engine-build.ts';
4+
5+test('builds order by timestamp then tag', () => {
6+ assert.ok((compareBuilds('200-aaa', '100-zzz') ?? 0) > 0);
7+ assert.ok((compareBuilds('100-aaa', '200-aaa') ?? 0) < 0);
8+ assert.ok((compareBuilds('100-abc', '100-abd') ?? 0) < 0);
9+ assert.equal(compareBuilds('100-abc', '100-abc'), 0);
10+});
11+
12+test('timestamps compare numerically, not as strings', () => {
13+ assert.ok((compareBuilds('1000-a', '999-a') ?? 0) > 0);
14+});
15+
16+test('malformed build ids never order', () => {
17+ assert.equal(compareBuilds('dev', '100-a'), null);
18+ assert.equal(compareBuilds('100-a', ''), null);
19+ assert.equal(helloAction('100-a', 'garbage'), 'ignore');
20+});
21+
22+test('a worker yields to a newer peer', () => {
23+ assert.equal(helloAction('100-a', '200-b'), 'yield');
24+});
25+
26+test('a worker announces itself to an older peer', () => {
27+ assert.equal(helloAction('200-b', '100-a'), 'announce');
28+});
29+
30+test('the same build coexists', () => {
31+ assert.equal(helloAction('100-a', '100-a'), 'ignore');
32+});
33+
34+test('two workers never both yield', () => {
35+ const pairs: [string, string][] = [['100-a', '200-b'], ['100-b', '100-a'], ['5-x', '50-x']];
36+ for (const [one, two] of pairs) {
37+ const actions = [helloAction(one, two), helloAction(two, one)];
38+ assert.equal(actions.filter((a) => a === 'yield').length, 1);
39+ }
40+});
41+
42+test('auto reload is limited to once per guard window', () => {
43+ assert.equal(mayAutoReload(1_000_000, null), true);
44+ assert.equal(mayAutoReload(1_030_000, 1_000_000), false);
45+ assert.equal(mayAutoReload(1_060_000, 1_000_000), true);
46+});
47+
48+test('retry delay backs off and caps', () => {
49+ assert.deepEqual([0, 1, 2, 3, 4, 10].map(retryDelay), [1000, 2000, 4000, 8000, 8000, 8000]);
50+});
Modified file: web/tsconfig.worker.json+1 additions−1 deletions
@@ -11,5 +11,5 @@
1111 "types": ["vite/client"],
1212 "noEmit": true
1313 },
14- "include": ["src/worker/**/*.ts", "src/sw.ts", "src/sw-media.ts", "src/sw-push.ts", "src/lib/media-decrypt.ts", "src/lib/media-sniff.ts", "src/lib/avatar-cache.ts", "src/lib/wire-status.ts", "src/lib/guards.ts"]
14+ "include": ["src/worker/**/*.ts", "src/sw.ts", "src/sw-media.ts", "src/sw-push.ts", "src/lib/media-decrypt.ts", "src/lib/media-sniff.ts", "src/lib/avatar-cache.ts", "src/lib/wire-status.ts", "src/lib/guards.ts", "src/lib/engine-build.ts"]
1515 }
Modified file: web/vite.config.ts+13 additions−1 deletions
@@ -1,3 +1,4 @@
1+import { execFileSync } from 'node:child_process';
12 import { readdirSync, readFileSync } from 'node:fs';
23 import { join } from 'node:path';
34 import { fileURLToPath } from 'node:url';
@@ -54,11 +55,22 @@ function twemojiPlugin(): import('vite').Plugin {
5455 };
5556 }
5657
58+function buildId(): string {
59+ if (process.env.MALICE_BUILD) return process.env.MALICE_BUILD;
60+ let rev = 'dev';
61+ try {
62+ rev = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim() || rev;
63+ } catch {
64+ }
65+ return `${Math.floor(Date.now() / 1000)}-${rev}`;
66+}
67+
5768 export default defineConfig(({ mode }) => {
5869 const device = mode === 'device';
70+ const build = buildId();
5971 return {
6072 plugins: [svelte(), twemojiPlugin(), precacheManifestPlugin()],
61- define: { __MALICE_DEVICE__: JSON.stringify(device) },
73+ define: { __MALICE_DEVICE__: JSON.stringify(device), __MALICE_BUILD__: JSON.stringify(build) },
6274 assetsInclude: ['**/*.wasm'],
6375 worker: { format: 'es' },
6476 resolve: {