import { ROUTER_CODES, RouterError } from "../error-codes/error-codes.js"; /** A per-plugin handler reference held in a slot position. */ export type SlotHandler = (...args: readonly unknown[]) => unknown; /** Identifies one of the three positions a `BlueGreenSlot` can hold. */ export type SlotPosition = "live" | "shadow" | "draining"; /** Opaque handle representing a draining slot โ€” returned by `flip()`. */ export interface DrainingHandle { readonly slotId: string; /** Promise that resolves once all in-flight requests against this slot settle. */ readonly settled: Promise; /** Signals abort to the still-pending requests. */ abort(): void; } /** Per-plugin router mount point. */ export interface RouterSlot { readonly slotId: string; readonly handler: SlotHandler; readonly version: number; } /** * Atomic flip primitive backing hot-reload. Three positions: * - `live` (currently serving) * - `shadow` (imported, not yet serving) * - `draining` (former `live`, finishing in-flight) * * Per design ยง8.1: the per-plugin version pointer is the single atomic * point that decides which module gets a request. This interface is that * pointer's contract; concrete impls live in `api-host`. */ export interface BlueGreenSlot { /** Current `live` slot. Throws if disposed. */ readonly live: RouterSlot; /** Currently-staged shadow, or null. */ readonly shadow: RouterSlot | null; /** Set or replace the shadow handler. */ setShadow(handler: SlotHandler): void; /** * Atomically promote `shadow` to `live`, returning a draining handle * for the previous live slot. Throws `ROUTER.SLOT_FLIP_FAILED` if no * shadow is set. */ flip(): DrainingHandle; /** * Wait for a draining slot to settle, or abort if the timeout elapses. * Throws `ROUTER.SLOT_DISPOSE_TIMEOUT` on timeout AND signals abort to * still-pending requests. */ dispose(slotId: string, opts: { timeoutMs: number }): Promise; /** Increment pending counter for the named slot โ€” used by request acquire. */ acquire(slotId: string): void; /** Decrement pending counter โ€” used after request settles. */ release(slotId: string): void; } interface SlotState { slot: RouterSlot; pending: number; waiters: Array<() => void>; abortController: AbortController; } /** * Reference, in-memory implementation of `BlueGreenSlot`. * * `api-host` is expected to compose this primitive with its router * registry; tests and the conformance harness use it directly. Slot IDs * are derived deterministically from `.` so log lines * and span tags stay readable. */ export class InMemoryBlueGreenSlot implements BlueGreenSlot { private readonly plugin: string; private liveState: SlotState; private shadowSlot: RouterSlot | null = null; private nextVersion: number; private readonly draining = new Map(); public constructor(plugin: string, initialHandler: SlotHandler) { this.plugin = plugin; this.nextVersion = 1; const slot = this.makeSlot(initialHandler); this.liveState = this.makeState(slot); } public get live(): RouterSlot { return this.liveState.slot; } public get shadow(): RouterSlot | null { return this.shadowSlot; } public setShadow(handler: SlotHandler): void { this.shadowSlot = this.makeSlot(handler); } public flip(): DrainingHandle { if (this.shadowSlot === null) { throw new RouterError( ROUTER_CODES.SLOT_FLIP_FAILED, `BlueGreenSlot(${this.plugin}).flip() called with no shadow`, { context: { plugin: this.plugin } } ); } const previous = this.liveState; const newLive = this.shadowSlot; this.shadowSlot = null; this.liveState = this.makeState(newLive); this.draining.set(previous.slot.slotId, previous); const handle: DrainingHandle = { slotId: previous.slot.slotId, settled: this.settledPromise(previous), abort: () => previous.abortController.abort(), }; return handle; } public async dispose( slotId: string, opts: { timeoutMs: number } ): Promise { const state = this.draining.get(slotId); if (state === undefined) return; if (state.pending === 0) { this.draining.delete(slotId); return; } const timerRef: { value: ReturnType | undefined } = { value: undefined, }; const timeout = new Promise<"timeout">((resolve) => { timerRef.value = setTimeout( () => resolve("timeout"), opts.timeoutMs ); }); const settled = this.settledPromise(state).then( () => "settled" as const ); const winner = await Promise.race([timeout, settled]); clearTimeout(timerRef.value); if (winner === "timeout") { state.abortController.abort(); throw new RouterError( ROUTER_CODES.SLOT_DISPOSE_TIMEOUT, `BlueGreenSlot.dispose(${slotId}) timed out after ${opts.timeoutMs}ms with ${state.pending} pending`, { context: { slotId, pending: state.pending, timeoutMs: opts.timeoutMs, }, } ); } this.draining.delete(slotId); } public acquire(slotId: string): void { const state = this.lookup(slotId); if (state === null) return; state.pending += 1; } public release(slotId: string): void { const state = this.lookup(slotId); if (state === null) return; state.pending -= 1; if (state.pending <= 0) { state.pending = 0; const drainWaiters = state.waiters.splice(0, state.waiters.length); for (const waiter of drainWaiters) waiter(); } } /** Returns abort signal callers can wire onto in-flight requests on a draining slot. */ public signalFor(slotId: string): AbortSignal | null { const state = this.lookup(slotId); return state === null ? null : state.abortController.signal; } private lookup(slotId: string): SlotState | null { if (this.liveState.slot.slotId === slotId) return this.liveState; const draining = this.draining.get(slotId); return draining ?? null; } private makeSlot(handler: SlotHandler): RouterSlot { const version = this.nextVersion; this.nextVersion += 1; return { slotId: `${this.plugin}.v${version}`, handler, version, }; } private makeState(slot: RouterSlot): SlotState { return { slot, pending: 0, waiters: [], abortController: new AbortController(), }; } private settledPromise(state: SlotState): Promise { if (state.pending === 0) return Promise.resolve(); return new Promise((resolve) => { state.waiters.push(resolve); }); } }