Merge pull request #53 from MaxBittker/sdk/worker-timer

fix(client): keep the main loop running in a background tab
This commit is contained in:
Max Bittker authored and GitHub committed 2026-07-24 23:17:34 -04:00
commit 344432ed36
4 files changed
+243 -2

No files matched your search

+1 -1
View File
@@ -6,7 +6,7 @@
"docs:api": "bun sdk/generate-api-docs.ts",
"docs:api:check": "bun sdk/generate-api-docs.ts --check",
"fetch-collision-data": "bun sdk/fetch-collision-data.ts",
"test": "bun test sdk/test server/webclient/src/bot",
"test": "bun test sdk/test server/webclient/src/bot server/webclient/src/util",
"typecheck": "tsc --noEmit",
"typecheck:webclient": "tsc --noEmit -p server/webclient/tsconfig.json"
},
+5 -1
View File
@@ -1,4 +1,8 @@
export const sleep = async (ms: number): Promise<void> => new Promise((resolve): NodeJS.Timeout => setTimeout(resolve, ms));
import { delay } from '#/util/WorkerTimer.js';
// Backed by a worker clock so the main loop keeps its rate in a background tab,
// where Chrome would otherwise throttle setTimeout to one wake per second.
export const sleep = async (ms: number): Promise<void> => delay(ms);
export const downloadUrl = async (url: string): Promise<Uint8Array> => new Uint8Array(await (await fetch(url)).arrayBuffer());
@@ -0,0 +1,124 @@
import { describe, expect, test } from 'bun:test';
type WorkerTimerModule = typeof import('./WorkerTimer.js');
/**
* Load a fresh copy of the module. It memoises whether a worker is available,
* so each scenario needs its own instance.
*/
let variant = 0;
async function freshModule(): Promise<WorkerTimerModule> {
return import(`./WorkerTimer.js?variant=${variant++}`) as Promise<WorkerTimerModule>;
}
/** A worker that echoes each id back after its delay, like the real one. */
class EchoWorker {
onmessage: ((event: { data: number }) => void) | null = null;
onerror: (() => void) | null = null;
posted: { id: number; ms: number }[] = [];
postMessage(data: { id: number; ms: number }): void {
this.posted.push(data);
setTimeout(() => this.onmessage?.({ data: data.id }), data.ms);
}
terminate(): void {}
}
/** Install stubs for the globals the module builds a worker from. */
function withWorkerGlobals(factory: () => unknown): () => void {
const saved = {
Worker: (globalThis as Record<string, unknown>).Worker,
Blob: (globalThis as Record<string, unknown>).Blob,
URL: (globalThis as Record<string, unknown>).URL
};
const realUrl = globalThis.URL;
(globalThis as Record<string, unknown>).Worker = factory;
(globalThis as Record<string, unknown>).Blob = class {};
(globalThis as Record<string, unknown>).URL = Object.assign(
function URLStub() {} as unknown as typeof realUrl,
{ createObjectURL: () => 'blob:stub', revokeObjectURL: () => {} }
);
return () => {
(globalThis as Record<string, unknown>).Worker = saved.Worker;
(globalThis as Record<string, unknown>).Blob = saved.Blob;
(globalThis as Record<string, unknown>).URL = saved.URL;
};
}
describe('WorkerTimer', () => {
test('falls back to setTimeout when workers are unavailable', async () => {
const saved = (globalThis as Record<string, unknown>).Worker;
delete (globalThis as Record<string, unknown>).Worker;
try {
const { delay, isWorkerTimerActive } = await freshModule();
expect(isWorkerTimerActive()).toBe(false);
const start = performance.now();
await delay(20);
expect(performance.now() - start).toBeGreaterThanOrEqual(15);
} finally {
(globalThis as Record<string, unknown>).Worker = saved;
}
});
test('runs on a real worker when the platform provides one', async () => {
const { delay, isWorkerTimerActive } = await freshModule();
expect(isWorkerTimerActive()).toBe(true);
const start = performance.now();
await delay(20);
expect(performance.now() - start).toBeGreaterThanOrEqual(15);
});
test('drives delays through the worker when one can be created', async () => {
let created: EchoWorker | null = null;
const restore = withWorkerGlobals(function (this: unknown) {
created = new EchoWorker();
return created;
});
try {
const { delay, isWorkerTimerActive } = await freshModule();
expect(isWorkerTimerActive()).toBe(true);
await delay(5);
await delay(7);
expect(created!.posted.map(p => p.ms)).toEqual([5, 7]);
// Ids must be distinct, or one reply would resolve the wrong waiter.
expect(new Set(created!.posted.map(p => p.id)).size).toBe(2);
} finally {
restore();
}
});
test('releases waiters and stops using a worker that errors', async () => {
let created: EchoWorker | null = null;
const restore = withWorkerGlobals(function (this: unknown) {
created = new EchoWorker();
return created;
});
try {
const { delay, isWorkerTimerActive } = await freshModule();
// A delay long enough that only the error can resolve it in time.
const waiting = delay(60_000);
created!.onerror?.();
await waiting; // hangs forever if the error path drops its waiters
expect(isWorkerTimerActive()).toBe(false);
// Later calls still work, now on setTimeout.
await delay(1);
} finally {
restore();
}
});
});
+113
View File
@@ -0,0 +1,113 @@
// A clock that keeps running while the tab is in the background.
//
// Chrome clamps a hidden page's setTimeout to one wake per second, and after
// about five minutes of intensive throttling to one per minute. GameShell's
// main loop is a sleep() loop, so a backgrounded client stops draining the
// packet buffer and falls minutes behind: a bot left running in an unfocused
// tab goes stale and its scripts report "Stuck" while nothing moves.
//
// Timers inside a dedicated worker are exempt from that throttling, so the
// worker owns the clock and posts back once each delay elapses. Measured in
// Chrome 146 with the tab hidden: 1.0 page wake/s against 66.4 worker wakes/s,
// where the foreground rate was 66.0.
//
// Falls back to setTimeout wherever a worker can't be created — no Worker or
// Blob constructor, a Content-Security-Policy that forbids blob: workers, or
// the worker failing at runtime.
const WORKER_SOURCE = 'self.onmessage=e=>{setTimeout(()=>self.postMessage(e.data.id),e.data.ms)};';
/** undefined = not tried yet, null = unavailable, Worker = in use. */
let worker: Worker | null | undefined;
let blobUrl: string | null = null;
let nextId: number = 1;
const pending: Map<number, () => void> = new Map();
/** Stop using the worker and let every waiter through, so nothing hangs. */
function abandonWorker(): void {
worker = null;
const waiters: (() => void)[] = [...pending.values()];
pending.clear();
for (const resolve of waiters) {
resolve();
}
}
function getWorker(): Worker | null {
if (worker !== undefined) {
return worker;
}
worker = null;
try {
if (typeof Worker === 'undefined' || typeof Blob === 'undefined' || typeof URL === 'undefined' || typeof URL.createObjectURL !== 'function') {
return worker;
}
blobUrl = URL.createObjectURL(new Blob([WORKER_SOURCE], { type: 'text/javascript' }));
const created: Worker = new Worker(blobUrl);
created.onmessage = (event: MessageEvent): void => {
// The first reply proves the worker script loaded, so the blob URL
// has served its purpose and can be released.
if (blobUrl) {
URL.revokeObjectURL(blobUrl);
blobUrl = null;
}
const id: number = event.data as number;
const resolve: (() => void) | undefined = pending.get(id);
if (resolve) {
pending.delete(id);
resolve();
}
};
created.onerror = (): void => {
created.terminate();
if (blobUrl) {
URL.revokeObjectURL(blobUrl);
blobUrl = null;
}
abandonWorker();
};
worker = created;
} catch {
worker = null;
}
return worker;
}
/**
* Resolve after `ms` milliseconds, at the requested rate even when the tab is
* in the background. Drop-in replacement for a setTimeout-backed sleep.
*/
export async function delay(ms: number): Promise<void> {
const timer: Worker | null = getWorker();
if (!timer) {
return new Promise<void>((resolve): void => {
setTimeout(resolve, ms);
});
}
return new Promise<void>((resolve): void => {
const id: number = nextId++;
pending.set(id, resolve);
try {
timer.postMessage({ id, ms });
} catch {
// The worker died between the check and the post.
pending.delete(id);
abandonWorker();
setTimeout(resolve, ms);
}
});
}
/** True while the worker clock is driving delays. Exposed for diagnostics. */
export function isWorkerTimerActive(): boolean {
return getWorker() !== null;
}