Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,217 @@
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<void>;
/** 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<void>;
/** 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 `<plugin>.<version>` 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<string, SlotState>();
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<void> {
const state = this.draining.get(slotId);
if (state === undefined) return;
if (state.pending === 0) {
this.draining.delete(slotId);
return;
}
const timerRef: { value: ReturnType<typeof setTimeout> | 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<void> {
if (state.pending === 0) return Promise.resolve();
return new Promise<void>((resolve) => {
state.waiters.push(resolve);
});
}
}