// anneal - CSP/Actor building blocks for TypeScript // // Five channel types covering every actor communication pattern: // Func request -> response (function call) // Query void -> response (getter) // Proc request -> done (setter/command) // Signal void -> done (trigger/reset) // Inbox fire-and-forget (logging/notifications) // // Lifecycle manages the stop/done shutdown protocol. // select() and loop() provide the actor's message dispatch. // --------------------------------------------------------------------------- // Sentinel // --------------------------------------------------------------------------- /** Return from a handler in select/loop to terminate the loop. */ export const STOP: unique symbol = Symbol('anneal.stop'); export type Stop = typeof STOP; // --------------------------------------------------------------------------- // Mailbox (internal) // --------------------------------------------------------------------------- class Mailbox { private q: T[] = []; private waker: (() => void) | null = null; post(v: T): void { this.q.push(v); if (this.waker) { const fn = this.waker; this.waker = null; fn(); } } get pending(): boolean { return this.q.length > 0; } get length(): number { return this.q.length; } take(): T { return this.q.shift()!; } wake(fn: () => void): void { this.waker = fn; } unwake(): void { this.waker = null; } } // --------------------------------------------------------------------------- // Case // --------------------------------------------------------------------------- /** A select case produced by a channel's .on() method. */ export interface Case { /** @internal */ readonly _mb: unknown; /** @internal */ readonly _fn: (msg: any) => unknown; } function makeCase(mb: Mailbox, fn: (msg: T) => void | Stop | Promise): Case { return { _mb: mb, _fn: fn }; } function caseMb(c: Case): Mailbox { return c._mb as Mailbox; } // --------------------------------------------------------------------------- // select / loop // --------------------------------------------------------------------------- /** * Wait for one message from any of the given cases and run its * handler. Cases are checked in order - first pending wins. */ export async function select(...cases: Case[]): Promise { for (const c of cases) { const mb = caseMb(c); if (mb.pending) return c._fn(mb.take()); } return new Promise(resolve => { let fired = false; const cleanup = () => { for (const c of cases) caseMb(c).unwake(); }; for (const c of cases) { const mb = caseMb(c); mb.wake(() => { if (fired) return; fired = true; cleanup(); resolve(c._fn(mb.take())); }); } }); } /** Run select in a loop until a handler returns STOP. */ export async function loop(...cases: Case[]): Promise { for (;;) { const r = await select(...cases); if (r === STOP) return; } } // --------------------------------------------------------------------------- // Func // --------------------------------------------------------------------------- /** Message received by the actor from a Func channel. */ export interface FuncMsg { readonly req: Req; /** Send the response back to the caller. Call exactly once. */ reply(resp: Resp): void; } /** * Synchronous request-response channel. The caller sends a request * and awaits the actor's reply. */ export class Func { private mb = new Mailbox>(); /** Send req to the actor and await the response. */ call(req: Req, signal?: AbortSignal): Promise { if (signal?.aborted) return Promise.reject(signal.reason); return new Promise((resolve, reject) => { let done = false; const onAbort = signal ? () => { if (!done) { done = true; reject(signal.reason); } } : undefined; this.mb.post({ req, reply: (resp: Resp) => { if (!done) { done = true; if (onAbort) signal!.removeEventListener('abort', onAbort); resolve(resp); } }, }); if (onAbort) signal!.addEventListener('abort', onAbort, { once: true }); }); } /** Create a select case for this channel. */ on(handler: (msg: FuncMsg) => void | Stop | Promise): Case { return makeCase(this.mb, handler); } } // --------------------------------------------------------------------------- // Query // --------------------------------------------------------------------------- /** Message received by the actor from a Query channel. */ export interface QueryMsg { /** Send the response back to the caller. Call exactly once. */ reply(resp: Resp): void; } /** * Synchronous getter channel. The caller sends no data and awaits * the actor's reply. */ export class Query { private mb = new Mailbox>(); /** Query the actor and await the response. */ call(signal?: AbortSignal): Promise { if (signal?.aborted) return Promise.reject(signal.reason); return new Promise((resolve, reject) => { let done = false; const onAbort = signal ? () => { if (!done) { done = true; reject(signal.reason); } } : undefined; this.mb.post({ reply: (resp: Resp) => { if (!done) { done = true; if (onAbort) signal!.removeEventListener('abort', onAbort); resolve(resp); } }, }); if (onAbort) signal!.addEventListener('abort', onAbort, { once: true }); }); } /** Create a select case for this channel. */ on(handler: (msg: QueryMsg) => void | Stop | Promise): Case { return makeCase(this.mb, handler); } } // --------------------------------------------------------------------------- // Proc // --------------------------------------------------------------------------- /** Message received by the actor from a Proc channel. */ export interface ProcMsg { readonly req: Req; /** Signal the caller that processing is complete. Call exactly once. */ done(): void; } /** * Synchronous command channel. The caller sends a request and awaits * confirmation that the actor has processed it. */ export class Proc { private mb = new Mailbox>(); /** Send req to the actor and await completion. */ call(req: Req, signal?: AbortSignal): Promise { if (signal?.aborted) return Promise.reject(signal.reason); return new Promise((resolve, reject) => { let settled = false; const onAbort = signal ? () => { if (!settled) { settled = true; reject(signal.reason); } } : undefined; this.mb.post({ req, done: () => { if (!settled) { settled = true; if (onAbort) signal!.removeEventListener('abort', onAbort); resolve(); } }, }); if (onAbort) signal!.addEventListener('abort', onAbort, { once: true }); }); } /** Create a select case for this channel. */ on(handler: (msg: ProcMsg) => void | Stop | Promise): Case { return makeCase(this.mb, handler); } } // --------------------------------------------------------------------------- // Signal // --------------------------------------------------------------------------- /** Message received by the actor from a Signal channel. */ export interface SignalMsg { /** Signal the caller that the trigger has been handled. Call exactly once. */ done(): void; } /** * Synchronous trigger channel. The caller sends no data and awaits * confirmation that the actor has handled the signal. */ export class Signal { private mb = new Mailbox(); /** Send the signal and await completion. */ call(signal?: AbortSignal): Promise { if (signal?.aborted) return Promise.reject(signal.reason); return new Promise((resolve, reject) => { let settled = false; const onAbort = signal ? () => { if (!settled) { settled = true; reject(signal.reason); } } : undefined; this.mb.post({ done: () => { if (!settled) { settled = true; if (onAbort) signal!.removeEventListener('abort', onAbort); resolve(); } }, }); if (onAbort) signal!.addEventListener('abort', onAbort, { once: true }); }); } /** Create a select case for this channel. */ on(handler: (msg: SignalMsg) => void | Stop | Promise): Case { return makeCase(this.mb, handler); } } // --------------------------------------------------------------------------- // Inbox // --------------------------------------------------------------------------- /** * Buffered fire-and-forget channel. Senders do not wait for the * actor to process the message. */ export class Inbox { private mb = new Mailbox(); private cap: number; constructor(buffer: number) { this.cap = Math.max(0, buffer); } /** Enqueue a message. Always succeeds. */ send(v: T): void { this.mb.post(v); } /** * Attempt to enqueue a message. Returns false if the buffer * capacity has been reached (message dropped). */ trySend(v: T): boolean { if (this.cap > 0 && this.mb.length >= this.cap) return false; this.mb.post(v); return true; } /** Create a select case for this channel. */ on(handler: (msg: T) => void | Stop | Promise): Case { return makeCase(this.mb, handler); } } // --------------------------------------------------------------------------- // Lifecycle // --------------------------------------------------------------------------- /** * Manages the shutdown handshake between an actor and its owner. * The owner calls stop() to request shutdown and wait for completion. * The actor uses .on() in its select loop to detect the stop signal. */ export class Lifecycle { private mb = new Mailbox(); private resolvers: (() => void)[] = []; private isDone = false; private hasStopped = false; /** Create a select case for the stop signal. */ on(handler: () => void | Stop | Promise): Case { return makeCase(this.mb, handler); } /** Promise that resolves when the actor has finished. */ get stopped(): Promise { if (this.isDone) return Promise.resolve(); return new Promise(r => this.resolvers.push(r)); } /** Signal the actor to stop and wait for it to finish. */ stop(): Promise { if (!this.hasStopped) { this.hasStopped = true; this.mb.post(undefined); } return this.stopped; } /** * Signal that the actor has finished. Called automatically by * spawn - do not call directly. */ markDone(): void { if (!this.isDone) { this.isDone = true; for (const r of this.resolvers) r(); this.resolvers.length = 0; } } } // --------------------------------------------------------------------------- // spawn // --------------------------------------------------------------------------- /** * Start an actor function with lifecycle management. Calls * lc.markDone() when the async function returns or throws. */ export function spawn(lc: Lifecycle, fn: () => Promise): void { fn().finally(() => lc.markDone()); }