Commit d9be72fdb393
tarball12 files changed+227-10
@@ -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)@@ -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)} />@@ -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>@@ -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 is27
+ * 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
+}@@ -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
+}@@ -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 {@@ -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 };@@ -1,2 +1,3 @@
11
/// <reference types="vite/client" />22
declare const __MALICE_DEVICE__: boolean;3
+declare const __MALICE_BUILD__: string;@@ -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__ });@@ -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
+});@@ -11,5 +11,5 @@
1111
"types": ["vite/client"],1212
"noEmit": true1313
},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
}@@ -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: {