atagen

malice

public
Star0
Watch0

Commit 5d6f6e2eb5a5

tarball

atagen <boss@atagen.co>2026-10-27 05:48 UTC

6 files changed+91-19
web: stop the engine cleanly before an update reload

Reloading for an update took the old SharedWorker's last client away, so Chrome killed it mid-sync about a second after the click, before the new build's hello could make it step down. Reload now asks the engine to step down and waits up to 5s for it to release before activating the new service worker. A hidden superseded tab also no longer reloads ahead of that, which could kill the worker in the middle of its shutdown.
Modified file: web/src/lib/engine-build.ts+13 additions
@@ -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+}
Modified file: web/src/lib/engine-state.ts+17 additions−2 deletions
@@ -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 reloading
30+ * 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 }
Modified file: web/src/lib/sw-register.ts+25 additions−8 deletions
@@ -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 to
19+ * unload takes the old SharedWorker down mid-sync. The timeout covers a waiting worker that went
20+ * 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.serviceWorker
5571 .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);
Modified file: web/src/lib/transport-worker.ts+21 additions−6 deletions
@@ -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> {
Modified file: web/src/worker/worker.ts+6 additions−2 deletions
@@ -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 }
Modified file: web/test/engine-build.test.ts+9 additions−1 deletions
@@ -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+});