feat(soak): RoomCoordinator with host→joiners handoff
Lazy Deferred per roomId with a timeout on await. Lets concurrent joiner sessions block until their host announces the room code without polling or page scraping. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
42
tests/soak/core/room-coordinator.ts
Normal file
42
tests/soak/core/room-coordinator.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* RoomCoordinator — tiny host→joiners handoff primitive.
|
||||||
|
*
|
||||||
|
* Lazy Deferred per roomId. `announce` resolves the promise; `await`
|
||||||
|
* blocks until the promise resolves or a per-call timeout fires.
|
||||||
|
* Rooms are keyed by string; each key has at most one Deferred.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { deferred, Deferred } from './deferred';
|
||||||
|
import type { RoomCoordinatorApi } from './types';
|
||||||
|
|
||||||
|
export class RoomCoordinator implements RoomCoordinatorApi {
|
||||||
|
private rooms = new Map<string, Deferred<string>>();
|
||||||
|
|
||||||
|
announce(roomId: string, code: string): void {
|
||||||
|
this.getOrCreate(roomId).resolve(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
async await(roomId: string, timeoutMs: number = 30_000): Promise<string> {
|
||||||
|
const d = this.getOrCreate(roomId);
|
||||||
|
let timer: NodeJS.Timeout | undefined;
|
||||||
|
const timeout = new Promise<never>((_, reject) => {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
reject(new Error(`RoomCoordinator: room "${roomId}" timed out after ${timeoutMs}ms`));
|
||||||
|
}, timeoutMs);
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
return await Promise.race([d.promise, timeout]);
|
||||||
|
} finally {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getOrCreate(roomId: string): Deferred<string> {
|
||||||
|
let d = this.rooms.get(roomId);
|
||||||
|
if (!d) {
|
||||||
|
d = deferred<string>();
|
||||||
|
this.rooms.set(roomId, d);
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
}
|
||||||
29
tests/soak/tests/room-coordinator.test.ts
Normal file
29
tests/soak/tests/room-coordinator.test.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { RoomCoordinator } from '../core/room-coordinator';
|
||||||
|
|
||||||
|
describe('RoomCoordinator', () => {
|
||||||
|
it('resolves await with the announced code (announce then await)', async () => {
|
||||||
|
const rc = new RoomCoordinator();
|
||||||
|
rc.announce('room-1', 'ABCD');
|
||||||
|
await expect(rc.await('room-1')).resolves.toBe('ABCD');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves await with the announced code (await then announce)', async () => {
|
||||||
|
const rc = new RoomCoordinator();
|
||||||
|
const p = rc.await('room-2');
|
||||||
|
rc.announce('room-2', 'WXYZ');
|
||||||
|
await expect(p).resolves.toBe('WXYZ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects await after timeout if not announced', async () => {
|
||||||
|
const rc = new RoomCoordinator();
|
||||||
|
await expect(rc.await('room-3', 50)).rejects.toThrow(/timed out/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isolates rooms — announcing room-A does not unblock room-B', async () => {
|
||||||
|
const rc = new RoomCoordinator();
|
||||||
|
const pB = rc.await('room-B', 100);
|
||||||
|
rc.announce('room-A', 'A-CODE');
|
||||||
|
await expect(pB).rejects.toThrow(/timed out/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user