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 = { backoffBaseMs: 500, backoffFactor: 2, backoffMaxMs: 10_000, streamOpenTimeoutMs: 3_000, } function sleep(ms: number, signal: AbortSignal): Promise { 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) => void onHostEnvelope?: (envelope: RpcRequest) => 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 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 { 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((resolve) => { muxOpened = resolve }), new Promise((resolve) => { hostOpened = resolve }), ]) const failed = new Promise((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( stream: AsyncIterable>, sink: ((envelope: RpcRequest) => void) | undefined, onEnd: () => void, ): Promise { 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) } } }