/** The operation lost its owner: a role change, a newer opening or the page going away. */ export class DemoOperationRetired extends Error { constructor() { super('This demo step belongs to an opening that has ended.'); } } /** The operation's absolute allowance ran out before it finished. */ export class DemoAllowanceExpired extends Error { constructor() { super('The demo did not answer in time.'); } } /** * One demo step and its fixed allowance. Every wait goes through `settle`, which answers when the dependency * does or the allowance ends, then admits the continuation only while the step is owned and in time. */ export interface DemoOperation { attempt: string; signal: AbortSignal; deadline: number; isCurrent: () => boolean; /** Throws unless the step may still act; called after every wait and immediately before each send. */ admit: () => void; settle: (work: Promise) => Promise; /** Ends the step now: aborts its requests and settles every pending wait. */ cancel: () => void; } export const startDemoOperation = ( attempt: string, budgetMs: number, owned: () => boolean = () => true, now: () => number = Date.now, ): DemoOperation => { const deadline = now() + budgetMs; const controller = new AbortController(); let stop = (_reason: Error) => undefined as void; const stopped = new Promise((_, reject) => { stop = reject; }); stopped.catch(() => undefined); const end = (reason: Error) => { window.clearTimeout(timer); controller.abort(); stop(reason); }; const timer = window.setTimeout(() => end(new DemoAllowanceExpired()), budgetMs); const isCurrent = () => owned() && !controller.signal.aborted && now() < deadline; const admit = () => { if (isCurrent()) return; const reason = owned() ? new DemoAllowanceExpired() : new DemoOperationRetired(); end(reason); throw reason; }; return { attempt, signal: controller.signal, deadline, isCurrent, admit, settle: async (work) => { admit(); const value = await Promise.race([work, stopped]); admit(); return value; }, cancel: () => end(owned() ? new DemoAllowanceExpired() : new DemoOperationRetired()), }; };