diff --git a/package.json b/package.json index a8ea927a4..b157c1d23 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/server/webclient/src/util/JsUtil.ts b/server/webclient/src/util/JsUtil.ts index 2bc9aa570..e88e86b90 100644 --- a/server/webclient/src/util/JsUtil.ts +++ b/server/webclient/src/util/JsUtil.ts @@ -1,4 +1,8 @@ -export const sleep = async (ms: number): Promise => 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 => delay(ms); export const downloadUrl = async (url: string): Promise => new Uint8Array(await (await fetch(url)).arrayBuffer()); diff --git a/server/webclient/src/util/WorkerTimer.test.ts b/server/webclient/src/util/WorkerTimer.test.ts new file mode 100644 index 000000000..eaed4ddd9 --- /dev/null +++ b/server/webclient/src/util/WorkerTimer.test.ts @@ -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 { + return import(`./WorkerTimer.js?variant=${variant++}`) as Promise; +} + +/** 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).Worker, + Blob: (globalThis as Record).Blob, + URL: (globalThis as Record).URL + }; + const realUrl = globalThis.URL; + + (globalThis as Record).Worker = factory; + (globalThis as Record).Blob = class {}; + (globalThis as Record).URL = Object.assign( + function URLStub() {} as unknown as typeof realUrl, + { createObjectURL: () => 'blob:stub', revokeObjectURL: () => {} } + ); + + return () => { + (globalThis as Record).Worker = saved.Worker; + (globalThis as Record).Blob = saved.Blob; + (globalThis as Record).URL = saved.URL; + }; +} + +describe('WorkerTimer', () => { + test('falls back to setTimeout when workers are unavailable', async () => { + const saved = (globalThis as Record).Worker; + delete (globalThis as Record).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).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(); + } + }); +}); diff --git a/server/webclient/src/util/WorkerTimer.ts b/server/webclient/src/util/WorkerTimer.ts new file mode 100644 index 000000000..2dbaecc3e --- /dev/null +++ b/server/webclient/src/util/WorkerTimer.ts @@ -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 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 { + const timer: Worker | null = getWorker(); + if (!timer) { + return new Promise((resolve): void => { + setTimeout(resolve, ms); + }); + } + + return new Promise((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; +}