mirror of
https://github.com/bdnugget/rs-sdk.git
synced 2026-11-07 09:07:30 +00:00
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:
4 files changed
+243
-2
No files matched your search
+1
-1
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user