import * as plugins from './plugins.js'; import type { IAuthoritySocketPaths } from './classes.authorityclient.js'; import { createAuthorityClient, supportedAuthoritySchemaVersion } from './classes.authorityclient.js'; import type { IControllerAuthorityProjection, TControllerAccountsImportInventoryResult, TControllerAuthorityDaemonState, TControllerAuthorityUnavailableReason, } from '../ts_interfaces/index.js'; /** The daemon's long-poll window. Its contract bounds this to 50-30000 ms. */ const heartbeatMs = 30_000; /** * How long one subscription attempt may stay unconnected, and how long the loop waits before the * next one. * * The grace window is what separates a daemon restart -- recovered by the client's own 250 ms * reconnect, at no cost -- from a daemon that is not there, which must stop being dialled. */ export interface IAuthoritySupervisionTimings { attemptGraceMs: number; retryBaseMs: number; retryMaxMs: number; } const defaultSupervisionTimings: IAuthoritySupervisionTimings = { attemptGraceMs: 2_000, retryBaseMs: 1_000, retryMaxMs: 60_000, }; export interface IAuthorityStateOptions { /** * Where the daemon listens. Omitted means there is nothing to talk to: no client is built and no * socket is dialled. The endpoints are always supplied by the caller, never resolved here, so a * controller can never reach the host's real authority without having been given it. */ paths?: IAuthoritySocketPaths; /** Called after every projection change, so the controller can push one `accounts.changed`. */ onChange?: (projection: IControllerAuthorityProjection) => void; /** Supervision timings. The defaults suit a desktop daemon; a short-lived host may shorten them. */ timings?: Partial; } /** * The controller-owned view of the account authority. * * AGL holds exactly one of these and the browser holds none: a view reads the projection and never * tracks staleness itself, which is why a failed request cannot leave a screen locked. The * projection is credential-free by construction -- it carries the daemon's own management DTOs, * which the authority contract defines as safe for browser code. * * Connection supervision is deliberately split with the client. `AuthSwitchClient.subscribe` owns * one attempt: its long poll, its resync on an epoch change or a `resyncRequired` answer, and a * 250 ms reconnect while the daemon is briefly away. This class owns *whether there is an attempt * at all*: a host where the authority was never installed would otherwise reconnect four times a * second for the controller's whole life, so an attempt that has not connected within the grace * window is abandoned and retried on an exponential backoff instead. * * The published state is a named state, and it never flickers: * * | situation | published | * | --- | --- | * | before the first attempt has an outcome | `connecting` | * | the client's `initial` and `resync` statuses | unchanged | * | a schema-valid snapshot | `ready` | * | a connection lost or never made | `absent/not_running` | * | a snapshot shape this AGL cannot read | `incompatible/schema_unsupported`, until a new snapshot | * | every further retry | unchanged -- the last named state stays | */ export class AuthorityState { private readonly onChange?: (projection: IControllerAuthorityProjection) => void; private readonly configuredPaths?: IAuthoritySocketPaths; private readonly timings: IAuthoritySupervisionTimings; private client?: plugins.authswitch.AuthSwitchClient; private projection: IControllerAuthorityProjection; private started = false; private stopped = false; private announcedConnecting = false; private supervision?: Promise; private attempt?: AbortController; private abandonTimer?: ReturnType; private retryTimer?: ReturnType; /** Ends the backoff wait early on `stop()`, so shutdown never waits out a 60 s timer. */ private retryWaitResolve?: (resumed: boolean) => void; private retryDelayMs: number; constructor(optionsArg: IAuthorityStateOptions = {}) { this.onChange = optionsArg.onChange; this.configuredPaths = optionsArg.paths; this.timings = { ...defaultSupervisionTimings, ...optionsArg.timings }; this.retryDelayMs = this.timings.retryBaseMs; this.projection = { state: 'absent', reason: 'not_running', snapshot: null, epoch: null, revision: null, lastVerifiedAt: null, observedAt: new Date().toISOString(), }; } public getProjection(): IControllerAuthorityProjection { return this.projection; } /** * The credential-free report of the legacy account stores this host still holds. * * It lives here rather than beside the handler because this class owns the one client and the one * verdict about the daemon: a read is attempted only while the projection is `ready`, and any * other state is answered by its own name instead of by a connection error. The read itself * writes nothing -- the authority opens those stores, hashes what it finds and answers with * identity claims -- and it takes the operation's signal, so a controller shutdown ends it * instead of waiting out the client's request timeout. */ public async readImportInventory( signalArg: AbortSignal, ): Promise { const client = this.client; if (!client || this.projection.state !== 'ready') { return { state: 'unavailable', daemon: this.projection.state }; } return { state: 'read', inventory: await client.importInventory(signalArg) }; } /** * Begins supervision and returns immediately. * * Controller startup never waits for the authority and never fails because of it: a host with no * daemon settles into the named `absent` state and keeps trying in the background, and a * controller built without endpoints settles there without opening anything at all. */ public start(): void { if (this.started || this.stopped) return; this.started = true; const paths = this.configuredPaths; if (!paths) { this.publish({ state: 'absent', reason: 'not_installed' }); return; } this.client = createAuthorityClient(paths); this.supervision = this.superviseSubscription(); void this.supervision.catch(() => undefined); } /** Idempotent. Releases the subscription and both timers, and waits for the loop to finish. */ public async stop(): Promise { this.stopped = true; this.clearAbandonTimer(); this.endRetryWait(false); this.attempt?.abort(); const supervision = this.supervision; this.supervision = undefined; if (supervision) await supervision.catch(() => undefined); this.client = undefined; } private async superviseSubscription(): Promise { const client = this.client; if (!client) return; while (!this.stopped) { const attempt = new AbortController(); this.attempt = attempt; // The clock runs from the start of the attempt, not from a reported loss: a daemon that was // never there reports nothing at all -- the client simply keeps reconnecting -- so an attempt // that has not become current within the window is the only signal there is. this.armAbandonTimer(); if (!this.announcedConnecting) { this.announcedConnecting = true; this.publish({ state: 'connecting', reason: null }); } try { await client.subscribe( (snapshotArg) => this.applySnapshot(snapshotArg), () => undefined, attempt.signal, { heartbeatMs, onStatus: (statusArg) => this.applyStatus(statusArg), }, ); } catch { // A rejected subscription is the same observable outcome as an aborted one: no connection. // It is never retried faster than the backoff below, so a permanent fault cannot spin. } finally { this.clearAbandonTimer(); if (this.attempt === attempt) this.attempt = undefined; } if (this.stopped) break; this.reportUnavailable('not_running'); if (!await this.waitBeforeRetry()) break; } } /** Resolves `false` when the wait was cut short by `stop()`. The timer never holds the process. */ private waitBeforeRetry(): Promise { const delayMs = this.retryDelayMs; this.retryDelayMs = Math.min(this.retryDelayMs * 2, this.timings.retryMaxMs); return new Promise((resolve) => { this.retryWaitResolve = resolve; const timer = setTimeout(() => this.endRetryWait(!this.stopped), delayMs); timer.unref(); this.retryTimer = timer; }); } /** * Ends a backoff wait exactly once. * * Clearing the timer alone would strand the supervision loop on a promise nobody resolves, and * `stop()` awaits that loop -- so the resolver and the timer are always released together. */ private endRetryWait(resumedArg: boolean): void { if (this.retryTimer) { clearTimeout(this.retryTimer); this.retryTimer = undefined; } const resolve = this.retryWaitResolve; this.retryWaitResolve = undefined; resolve?.(resumedArg); } private applyStatus(statusArg: plugins.authswitch.IAuthSwitchSubscriptionStatus): void { if (this.stopped) return; if (statusArg.state === 'current') { // The subscription holds a live snapshot; there is nothing to abandon until it is lost again. this.clearAbandonTimer(); return; } // Any other status means the subscription no longer holds a snapshot, so the clock has to run // again -- for the two that publish nothing just as much as for a reported loss. The client // reports a loss only once: after a `resync` it is already unavailable, so if the snapshot // request that follows fails because the daemon went away, its `disconnect` is silent and this // attempt would be dialled every 250 ms for the controller's whole life. A healthy resync // republishes `current` within milliseconds and clears the clock again, at no cost. this.armAbandonTimer(); // `initial` is the client announcing it has not tried yet, and `resync` is a healthy // resynchronisation of a live subscription. Neither is a loss of the connection, and naming // either of them `absent` would make a working daemon flicker on every resync. if (statusArg.reason === 'initial' || statusArg.reason === 'resync') return; this.reportUnavailable('not_running'); } /** * Names a connection AGL does not have, without ever overwriting a verdict about the daemon. * * `incompatible` is not a connection failure -- retrying cannot help and a view must keep saying * so -- therefore only a new snapshot leaves that state. */ private reportUnavailable(reasonArg: TControllerAuthorityUnavailableReason): void { if (this.projection.state === 'incompatible') return; this.publish({ state: 'absent', reason: reasonArg }); } /** * Abandons an attempt that has not connected within the grace window. * * The window exists so a daemon restart recovers through the client's own 250 ms retry instead of * paying a backoff, while a daemon that is simply not there stops being dialled four times a * second for the controller's whole life. */ private armAbandonTimer(): void { const attempt = this.attempt; if (this.abandonTimer || this.stopped || !attempt || attempt.signal.aborted) return; const timer = setTimeout(() => { this.abandonTimer = undefined; if (this.stopped || this.attempt !== attempt) return; attempt.abort(); }, this.timings.attemptGraceMs); timer.unref(); this.abandonTimer = timer; } private clearAbandonTimer(): void { if (!this.abandonTimer) return; clearTimeout(this.abandonTimer); this.abandonTimer = undefined; } private applySnapshot(snapshotArg: plugins.authswitchAuthority.IAuthSwitchSnapshot): void { if (this.stopped) return; if (snapshotArg.schemaVersion !== supportedAuthoritySchemaVersion) { // A daemon this AGL cannot read is its own named state, not a connection failure: retrying // would never help and a view must say so rather than showing an empty account list. this.publish({ state: 'incompatible', reason: 'schema_unsupported' }); this.attempt?.abort(); return; } // A daemon proved itself, so the next loss starts from the shortest backoff again. this.retryDelayMs = this.timings.retryBaseMs; this.publish({ state: 'ready', reason: null, snapshot: snapshotArg, epoch: snapshotArg.epoch, revision: snapshotArg.revision, lastVerifiedAt: new Date().toISOString(), }); } private publish(changeArg: Partial> & { state?: TControllerAuthorityDaemonState; reason?: TControllerAuthorityUnavailableReason | null; }): void { const next: IControllerAuthorityProjection = { ...this.projection, ...changeArg, observedAt: new Date().toISOString(), }; const unchanged = next.state === this.projection.state && next.reason === this.projection.reason && next.snapshot === this.projection.snapshot && next.revision === this.projection.revision && next.epoch === this.projection.epoch; if (unchanged) return; this.projection = next; try { this.onChange?.(next); } catch { // A consumer fault must never break supervision; the next change reports the same state. } } }