Commit 5d6f6e2eb5a5
tarball6 files changed+91-19
@@ -42,3 +42,16 @@ export function mayAutoReload(now: number, lastReload: number | null): boolean {
4242
export function retryDelay(attempt: number): number {4343
return Math.min(1000 * 2 ** attempt, 8000);4444
}45
+46
+export const RELEASE_TIMEOUT_MS = 5_000;47
+48
+export function within(promise: Promise<unknown>, ms: number): Promise<void> {49
+ return new Promise((resolve) => {50
+ const timer = setTimeout(resolve, ms);51
+ const done = () => {52
+ clearTimeout(timer);53
+ resolve();54
+ };55
+ promise.then(done, done);56
+ });57
+}@@ -6,17 +6,32 @@ const ENGINE_RELOAD_GUARD = 'malice-engine-reload';
66
export const engineStale = writable(false);77
export const engineRecovered = writable(0);88
9
+let release: () => Promise<void> = () => Promise.resolve();10
+11
+export function setEngineRelease(fn: () => Promise<void>): void {12
+ release = fn;13
+}14
+15
+export function releaseEngine(): Promise<void> {16
+ return release();17
+}18
+919
function reloadWhenHidden(): void {1020
if (document.visibilityState !== 'hidden') return;1121
const last = Number(sessionStorage.getItem(ENGINE_RELOAD_GUARD)) || null;1222
const now = Date.now();1323
if (!mayAutoReload(now, last)) return;1424
sessionStorage.setItem(ENGINE_RELOAD_GUARD, String(now));15
- window.location.reload();25
+ void releaseEngine().then(() => window.location.reload());1626
}1727
18
-export function markEngineStale(): void {28
+/**29
+ * Tabs in a handover leave the reload to the service worker activation. A hidden tab reloading30
+ * ahead of it lands on the new build under the old service worker and gets reloaded again.31
+ */32
+export function markEngineStale(autoReload = true): void {1933
engineStale.set(true);34
+ if (!autoReload) return;2035
document.addEventListener('visibilitychange', reloadWhenHidden);2136
reloadWhenHidden();2237
}@@ -1,22 +1,38 @@
11
import { writable } from 'svelte/store';2
+import { releaseEngine } from './engine-state';23
34
const SW_RELOAD_GUARD = 'malice-sw-reload';5
+const ACTIVATE_TIMEOUT_MS = 10_000;46
57
export const updateReady = writable(false);68
7
-let waiting: ServiceWorker | null = null;9
+let registered: ServiceWorkerRegistration | null = null;10
+let applying: Promise<void> | null = null;811
9
-export function applyUpdate(): void {10
- if (waiting) waiting.postMessage({ type: 'SKIP_WAITING' });11
- else window.location.reload();12
+export function applyUpdate(): Promise<void> {13
+ applying ??= handOver();14
+ return applying;15
+}16
+17
+/**18
+ * The engine stops before SKIP_WAITING because activation reloads every tab, and the last one to19
+ * unload takes the old SharedWorker down mid-sync. The timeout covers a waiting worker that went20
+ * redundant and so never fires controllerchange.21
+ */22
+async function handOver(): Promise<void> {23
+ await releaseEngine();24
+ const waiting = registered?.waiting;25
+ if (!waiting) {26
+ window.location.reload();27
+ return;28
+ }29
+ waiting.postMessage({ type: 'SKIP_WAITING' });30
+ window.setTimeout(() => window.location.reload(), ACTIVATE_TIMEOUT_MS);1231
}1332
1433
function watchForUpdates(registration: ServiceWorkerRegistration): void {1534
const check = () => {16
- if (registration.waiting && navigator.serviceWorker.controller) {17
- waiting = registration.waiting;18
- updateReady.set(true);19
- }35
+ if (registration.waiting && navigator.serviceWorker.controller) updateReady.set(true);2036
};2137
check();2238
registration.addEventListener('updatefound', () => {@@ -54,6 +70,7 @@ export function registerServiceWorker(): void {
5470
void navigator.serviceWorker5571
.register('/sw.js', { type: 'module', updateViaCache: 'none' })5672
.then((registration) => {73
+ registered = registration;5774
watchForUpdates(registration);5875
if (!__MALICE_DEVICE__) return;5976
return navigator.serviceWorker.ready.then(recoverUncontrolled);@@ -5,7 +5,8 @@ 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';8
+import { markEngineStale, engineRecovered, setEngineRelease } from './engine-state';9
+import { RELEASE_TIMEOUT_MS, within } from './engine-build';910
import workerUrl from '../worker/worker.ts?worker&url';1011
1112
function toApiError(error: { kind: string; message: string }): ApiError {@@ -29,13 +30,26 @@ class WorkerClient {
2930
private fatalHandlers = new Set<() => void>();3031
private subscribed = new Set<string>();3132
private superseded = false;33
+ private releaseRequested = false;34
+ private markReleased!: () => void;35
+ private released = new Promise<void>((resolve) => (this.markReleased = resolve));3236
3337
constructor() {38
+ setEngineRelease(() => this.releaseEngine());3439
window.addEventListener('pagehide', () => {3540
if (this.port) this.post({ kind: 'bye' });3641
});3742
}3843
44
+ releaseEngine(): Promise<void> {45
+ if (!this.port) return Promise.resolve();46
+ if (!this.superseded && !this.releaseRequested) {47
+ this.releaseRequested = true;48
+ this.port.postMessage({ kind: 'step_down' });49
+ }50
+ return within(this.released, RELEASE_TIMEOUT_MS);51
+ }52
+3953
get accountId(): string | null {4054
return this.account;4155
}@@ -102,20 +116,20 @@ class WorkerClient {
102116
return;103117
}104118
if (msg['kind'] === 'fatal') this.handleFatal(String(msg['message'] ?? ''));105
- else if (msg['kind'] === 'superseded') this.handleSuperseded();119
+ else if (msg['kind'] === 'superseded') this.handleSuperseded(msg['handover'] === true);120
+ else if (msg['kind'] === 'released') this.markReleased();106121
else if (msg['kind'] === 'recovered') engineRecovered.update((count) => count + 1);107122
}108123
109124
/** A newer build took over the engine, so this tab's JS can no longer drive it. */110
- private handleSuperseded(): void {125
+ private handleSuperseded(handover: boolean): void {111126
this.superseded = true;112
- this.port = null;113127
for (const pending of this.pending.values()) {114128
pending.reject(new ApiError(502, 'the worker was replaced by a newer build'));115129
}116130
this.pending.clear();117131
for (const handler of this.fatalHandlers) handler();118
- markEngineStale();132
+ markEngineStale(!handover);119133
}120134
121135
/** The worker crashed and closed every port; every tab respawns it after a beat. */@@ -127,7 +141,8 @@ class WorkerClient {
127141
}128142
this.pending.clear();129143
for (const handler of this.fatalHandlers) handler();130
- window.setTimeout(() => this.connect(), 1000);144
+ if (this.superseded || this.releaseRequested) this.markReleased();145
+ else window.setTimeout(() => this.connect(), 1000);131146
}132147
133148
async getSession(): Promise<SessionInfo> {@@ -279,6 +279,9 @@ async function dispatch(port: PortLike, data: unknown): Promise<void> {
279279
case 'bye':280280
dropPort(port);281281
return;282
+ case 'step_down':283
+ await stepDown(true);284
+ return;282285
}283286
}284287
@@ -383,13 +386,14 @@ async function retryBlockedWithBackoff(): Promise<void> {
383386
}384387
}385388
386
-async function stepDown(): Promise<void> {389
+async function stepDown(handover = false): Promise<void> {387390
if (superseded) return;388391
superseded = true;389392
await ready.catch(() => undefined);390393
coalescer.flush();391
- for (const port of ports) postTo(port, { kind: 'superseded' });394
+ for (const port of ports) postTo(port, { kind: 'superseded', handover });392395
await host?.shutdown().catch(() => undefined);396
+ for (const port of ports) postTo(port, { kind: 'released' });393397
engineChannel.postMessage({ type: 'released', build: __MALICE_BUILD__ });394398
close();395399
}@@ -1,6 +1,6 @@
11
import assert from 'node:assert/strict';22
import { test } from 'node:test';3
-import { compareBuilds, helloAction, mayAutoReload, retryDelay } from '../src/lib/engine-build.ts';3
+import { compareBuilds, helloAction, mayAutoReload, retryDelay, within } from '../src/lib/engine-build.ts';44
55
test('builds order by timestamp then tag', () => {66
assert.ok((compareBuilds('200-aaa', '100-zzz') ?? 0) > 0);@@ -48,3 +48,11 @@ test('auto reload is limited to once per guard window', () => {
4848
test('retry delay backs off and caps', () => {4949
assert.deepEqual([0, 1, 2, 3, 4, 10].map(retryDelay), [1000, 2000, 4000, 8000, 8000, 8000]);5050
});51
+52
+test('a release wait ends when the engine answers, fails, or goes quiet', async () => {53
+ const start = Date.now();54
+ await within(Promise.resolve(), 5_000);55
+ await within(Promise.reject(new Error('gone')), 5_000);56
+ assert.ok(Date.now() - start < 1_000);57
+ await within(new Promise(() => undefined), 20);58
+});