Delete design-session citations (decision/audit/plan ordinals, stack positions), change narration, review choreography, and reviewer-addressed justification from comments, JSDoc, docs, READMEs, Agent Notes, tests, and generator templates; restate every affected fact as current-state contract prose. Fix generated docs at their sources and regenerate the catalogs and cordis-surface regions; re-paste type-equiv blocks; update every bilingual counterpart and re-record the pairs. Record the citation rule in the committed-artifact-citations Agent Note.
191 lines
7.3 KiB
TypeScript
191 lines
7.3 KiB
TypeScript
import type { IApiClient, HostFrame, MuxFrame, RpcRequest } from './api.ts'
|
|
|
|
/** Reconnect/backoff tunables (deployment-varying — no hardcoded tunables; these become the
|
|
* future `ctx.connection` plugin's Config). All fields optional; defaults below. */
|
|
export interface ConnectionConfig {
|
|
/** First-retry backoff cap in ms (jittered: actual delay is cap/2..cap). */
|
|
backoffBaseMs?: number
|
|
/** Exponential growth factor per consecutive failed attempt. */
|
|
backoffFactor?: number
|
|
/** Upper bound for the backoff cap in ms. */
|
|
backoffMaxMs?: number
|
|
/** Cap on waiting for both streams' onOpen before onConnected, in ms. The strict handshake
|
|
* waits for mux+host stream establishment plus describe; a carrier that never
|
|
* fires onOpen (misbehaving proxy) must not wedge the connection forever — on timeout the
|
|
* generation proceeds as connected and the live-gap repair path covers stragglers. */
|
|
streamOpenTimeoutMs?: number
|
|
}
|
|
|
|
const CONNECTION_DEFAULTS: Required<ConnectionConfig> = {
|
|
backoffBaseMs: 500,
|
|
backoffFactor: 2,
|
|
backoffMaxMs: 10_000,
|
|
streamOpenTimeoutMs: 3_000,
|
|
}
|
|
|
|
function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
|
return new Promise((resolve) => {
|
|
const t = setTimeout(done, ms)
|
|
signal.addEventListener('abort', done, { once: true })
|
|
function done(): void {
|
|
clearTimeout(t)
|
|
signal.removeEventListener('abort', done)
|
|
resolve()
|
|
}
|
|
})
|
|
}
|
|
|
|
/** Coarse connection state for the UI: 'connected' after each generation's handshake,
|
|
* 'reconnecting' the moment the generation fails (covers the whole backoff+retry span). */
|
|
export type ConnectionState = 'connected' | 'reconnecting'
|
|
|
|
/** Frame sink callbacks: the Controller owns the physical streams; business dispatch belongs to
|
|
* SessionManager. */
|
|
export interface ConnectionSinks {
|
|
onMuxEnvelope?: (envelope: RpcRequest<MuxFrame>) => void
|
|
onHostEnvelope?: (envelope: RpcRequest<HostFrame>) => void
|
|
/** After each connection generation is established (both streams open + describe succeeded), first connect included. */
|
|
onConnected?: () => void
|
|
/** Coarse state transitions (deduplicated: fires only on change). The initial pre-connect
|
|
* span reports nothing — the UI treats "no state yet" as connecting, not as an outage. */
|
|
onStateChange?: (state: ConnectionState) => void
|
|
}
|
|
|
|
/**
|
|
* Opens both streams and keeps iterating (pull mode: nothing reads the socket and the tap
|
|
* never fires unless someone for-awaits), reconnecting with exponential backoff on loss.
|
|
* State (generation/attempt) is instance-private, never in the store.
|
|
* The pump body feeds each frame to a sink (sink exceptions must
|
|
* not kill the pump — a broken business layer must not drag down the connection layer).
|
|
*/
|
|
export class ConnectionController {
|
|
private generation = 0
|
|
private attempt = 0
|
|
private current: AbortController | null = null
|
|
private running = false
|
|
private lastState: ConnectionState | null = null
|
|
private readonly config: Required<ConnectionConfig>
|
|
|
|
constructor(
|
|
private readonly api: IApiClient,
|
|
private readonly sinks: ConnectionSinks = {},
|
|
config: ConnectionConfig = {},
|
|
) {
|
|
this.config = { ...CONNECTION_DEFAULTS, ...config }
|
|
}
|
|
|
|
/** Idempotent: begin the connect/pump/reconnect loop. */
|
|
start(): void {
|
|
if (this.running) return
|
|
this.running = true
|
|
void this.loop()
|
|
}
|
|
|
|
/** Stop the loop and abort the current generation's streams. */
|
|
stop(): void {
|
|
this.running = false
|
|
this.current?.abort()
|
|
this.current = null
|
|
}
|
|
|
|
private backoffDelay(attempt: number): number {
|
|
const { backoffBaseMs, backoffFactor, backoffMaxMs } = this.config
|
|
const cap = Math.min(backoffMaxMs, backoffBaseMs * backoffFactor ** Math.max(0, attempt - 1))
|
|
return cap / 2 + Math.random() * (cap / 2)
|
|
}
|
|
|
|
/** Read through a method: stop() flips the flag across awaits, so narrowing from the loop condition must not stick. */
|
|
private isRunning(): boolean {
|
|
return this.running
|
|
}
|
|
|
|
private async loop(): Promise<void> {
|
|
while (this.running) {
|
|
const gen = ++this.generation
|
|
const ac = new AbortController()
|
|
this.current = ac
|
|
|
|
/* v8 ignore next -- initializer placeholder: the Promise executor
|
|
* below runs synchronously and replaces it before anyone can call it. */
|
|
let muxOpened = (): void => {}
|
|
/* v8 ignore next -- same placeholder pattern as muxOpened. */
|
|
let hostOpened = (): void => {}
|
|
const streamsOpen = Promise.all([
|
|
new Promise<void>((resolve) => { muxOpened = resolve }),
|
|
new Promise<void>((resolve) => { hostOpened = resolve }),
|
|
])
|
|
|
|
const failed = new Promise<void>((resolve) => {
|
|
const settle = (): void => {
|
|
if (gen === this.generation && !ac.signal.aborted) ac.abort()
|
|
resolve()
|
|
}
|
|
void this.pumpStream(this.api.events.mux({}, ac.signal, muxOpened), this.sinks.onMuxEnvelope, settle)
|
|
void this.pumpStream(this.api.events.host({}, ac.signal, hostOpened), this.sinks.onHostEnvelope, settle)
|
|
})
|
|
|
|
try {
|
|
// Strict readiness handshake: describe proves unary reachability, onOpen
|
|
// proves each physical stream is established before any frame —
|
|
// only then may onConnected fire, so the resync it triggers cannot outrun the
|
|
// subscribed baseline. The timeout guards against a carrier that never fires onOpen
|
|
// (see ConnectionConfig.streamOpenTimeoutMs).
|
|
const timeout = new AbortController()
|
|
await Promise.all([
|
|
this.api.host.describe({}),
|
|
Promise.race([streamsOpen, sleep(this.config.streamOpenTimeoutMs, timeout.signal)]),
|
|
])
|
|
timeout.abort()
|
|
if (ac.signal.aborted) throw new Error('generation aborted during readiness handshake')
|
|
this.attempt = 0
|
|
this.emitState('connected')
|
|
this.callSink(this.sinks.onConnected)
|
|
} catch {
|
|
// Transport failure: treat as generation failure, fall through to the shared backoff.
|
|
if (!ac.signal.aborted) ac.abort()
|
|
}
|
|
|
|
await failed
|
|
if (!this.isRunning()) return
|
|
this.emitState('reconnecting')
|
|
this.attempt += 1
|
|
console.warn(`[web-runtime] connection lost, retry #${this.attempt}`)
|
|
const idle = new AbortController()
|
|
await sleep(this.backoffDelay(this.attempt), idle.signal)
|
|
}
|
|
}
|
|
|
|
/** Deduplicated state emission (sink isolation applies). */
|
|
private emitState(state: ConnectionState): void {
|
|
if (this.lastState === state) return
|
|
this.lastState = state
|
|
this.callSink(() => this.sinks.onStateChange?.(state))
|
|
}
|
|
|
|
private async pumpStream<F extends { type: string }>(
|
|
stream: AsyncIterable<RpcRequest<F>>,
|
|
sink: ((envelope: RpcRequest<F>) => void) | undefined,
|
|
onEnd: () => void,
|
|
): Promise<void> {
|
|
try {
|
|
for await (const envelope of stream) {
|
|
if (envelope.payload.type === 'stream/error') break
|
|
if (sink !== undefined) this.callSink(() => { sink(envelope) })
|
|
}
|
|
} catch {
|
|
// Stream loss: converge on onEnd, which triggers the shared reconnect.
|
|
}
|
|
onEnd()
|
|
}
|
|
|
|
/** Sink exception isolation: a business-layer throw is logged only, never affecting pump or reconnect semantics. */
|
|
private callSink(fn: (() => void) | undefined): void {
|
|
if (fn === undefined) return
|
|
try {
|
|
fn()
|
|
} catch (error) {
|
|
console.error('[web-runtime] connection sink threw:', error)
|
|
}
|
|
}
|
|
}
|