UNPKG

@copilotkit/runtime

Version:

<img src="https://github.com/user-attachments/assets/0a6b64d9-e193-4940-a3f6-60334ac34084" alt="banner" style="border-radius: 12px; border: 2px solid #d6d4fa;" />

738 lines (736 loc) 31.9 kB
import "reflect-metadata"; import { ChannelConfigError, deriveChannelActivationConfig } from "./channel-activation-config.mjs"; import { EMPTY } from "rxjs"; import { randomUUID } from "node:crypto"; import { AbstractAgent, EventType } from "@ag-ui/client"; //#region src/v2/runtime/core/channel-manager.ts /** * Signals that a declared Channel cannot be activated because no managed * provider exists for it yet. The engine throws this (or any error whose * `code === "SETUP_REQUIRED"`) to move a Channel to `setup_required` rather * than `error` — a declared-but-unprovisioned Channel is a valid degraded * state, not a failure. */ var ChannelSetupRequiredError = class extends Error { constructor(message) { super(message); this.name = "ChannelSetupRequiredError"; } }; /** * Runtime installs this pure-ESM package as a direct dependency, but the * specifier must stay non-literal so it never becomes a static dependency of * the runtime's CJS build. The packed-consumer contract is enforced by * `scripts/release/verify-runtime-package.ts`. */ const CHANNELS_INTELLIGENCE_SPECIFIER = "@copilotkit/channels-intelligence"; /** * Default engine: wrap the channels-intelligence Realtime Gateway launcher. * * The module is reached through an injectable importer that defaults to a * dynamic `import()` of a non-literal specifier, so the pure-ESM * `@copilotkit/channels-intelligence` never becomes a static dependency of this * CJS package (mirrors the runtime's other channels seams). The `import` * seam is a parameter purely so this function's config→opts mapping and its * module-not-found / generic-error branches are unit-testable WITHOUT the real * package installed; production always uses the default importer. * * Passes NO `org`/`channelId` — the launcher's realtime scope treats them as * optional. * * @param config - Resolved activation config for the Channel. * @param channel - The Channel to activate. * @param importChannelsIntelligence - Test seam; loads the channels-intelligence * module. Defaults to a dynamic import of the real package. * @param log - Optional diagnostic sink forwarded to the launcher/transport so * transport-level drop diagnostics are not silent in the managed path. * @returns The launcher's {@link ChannelsHandle}. */ async function defaultActivateChannel(config, channel, importChannelsIntelligence = () => import(CHANNELS_INTELLIGENCE_SPECIFIER), log, services) { let mod; try { mod = await importChannelsIntelligence(); } catch (err) { if (isModuleNotFound(err)) throw new Error("Managed Channels require '@copilotkit/channels-intelligence' to be installed. Add it to your app's dependencies.", { cause: err }); throw err; } if (!services) throw new Error("Managed Channels require the runtime AgentRunner and Intelligence client"); return mod.startChannelsOverRealtimeGateway([channel], { wsUrl: config.wsUrl, apiKey: config.apiKey, scope: { projectId: config.projectId, channelName: config.channelName }, runtimeInstanceId: config.runtimeInstanceId, adapter: config.adapter, ...config.showToolStatus !== void 0 ? { showToolStatus: config.showToolStatus } : {}, ...config.replyContinuation !== void 0 ? { replyContinuation: config.replyContinuation } : {}, appApiBaseUrl: config.apiUrl, ...log ? { log } : {}, runCanonical: (args) => runCanonicalChannelAgent(services.runner, services.intelligence, services.lockTtlSeconds ?? 20, services.lockHeartbeatIntervalSeconds ?? 15, args, services.lockKeyPrefix), loadHistory: async ({ threadId, appUserId }) => { const history = await services.intelligence.getThreadMessages({ threadId, userId: appUserId }); return Promise.all(history.messages.map((message) => toAgentMessage(message, services.intelligence))); } }); } /** One outer agent that lets the standard runner own the whole local tool loop. */ var ChannelOuterAgent = class extends AbstractAgent { constructor(inner, canonicalThreadId, executeLoop) { super({ threadId: inner.threadId, initialMessages: inner.messages, initialState: inner.state, ...inner.agentId ? { agentId: inner.agentId } : {} }); this.inner = inner; this.canonicalThreadId = canonicalThreadId; this.executeLoop = executeLoop; } run() { return EMPTY; } async runAgent(parameters, subscriber) { if (!parameters?.runId) throw new Error("Canonical Channel run requires a runId"); return { result: await this.executeLoop(subscriber ?? {}, { threadId: this.canonicalThreadId, runId: parameters.runId }), newMessages: [] }; } abortRun() { this.inner.abortRun(); } }; /** Drive one public Channel run through the runtime's existing AgentRunner. */ async function runCanonicalChannelAgent(runner, intelligence, lockTtlSeconds, lockHeartbeatIntervalSeconds, args, lockKeyPrefix) { const lock = await intelligence.ɵacquireThreadLock({ threadId: args.threadId, runId: args.runId, userId: args.userId, agentId: args.agentId, ttlSeconds: lockTtlSeconds, ...lockKeyPrefix !== void 0 ? { lockKeyPrefix } : {} }); const canonicalThreadId = lock.threadId; const canonicalRunId = lock.runId; let result = { iterations: 0, interrupted: false }; const outer = new ChannelOuterAgent(args.agent, canonicalThreadId, async (subscriber, canonicalRun) => { result = await args.execute(subscriber, canonicalRun); return result; }); let stopPromise; let heartbeatError; let heartbeatTimer; const stopCanonicalRun = () => { stopPromise ??= Promise.resolve().then(() => runner.stop({ threadId: canonicalThreadId })).catch(() => false); }; heartbeatTimer = setInterval(() => { intelligence.ɵrenewThreadLock({ threadId: canonicalThreadId, runId: canonicalRunId, ttlSeconds: lockTtlSeconds, ...lockKeyPrefix !== void 0 ? { lockKeyPrefix } : {} }).catch((error) => { if (heartbeatTimer === void 0) return; clearInterval(heartbeatTimer); heartbeatTimer = void 0; heartbeatError = error; try { args.agent.abortRun(); } catch {} stopCanonicalRun(); }); }, lockHeartbeatIntervalSeconds * 1e3); heartbeatTimer.unref?.(); try { await new Promise((resolve, reject) => { let terminalError; runner.run({ threadId: canonicalThreadId, agent: outer, input: { threadId: canonicalThreadId, runId: canonicalRunId, messages: args.agent.messages, state: args.agent.state, tools: [...args.tools], context: [...args.context], forwardedProps: void 0 }, persistedInputMessages: args.persistedInputMessages }).subscribe({ next: (event) => { if (event.type !== EventType.RUN_ERROR || terminalError) return; const message = "message" in event && typeof event.message === "string" ? event.message : "Canonical Channel agent run failed"; terminalError = new Error(message); terminalError.name = "ChannelCanonicalRunError"; if ("code" in event && typeof event.code === "string" && event.code.length > 0) terminalError.code = event.code; }, error: reject, complete: () => { if (terminalError) reject(terminalError); else resolve(); } }); }); } finally { if (heartbeatTimer !== void 0) { clearInterval(heartbeatTimer); heartbeatTimer = void 0; } await intelligence.ɵcleanupThreadLock({ threadId: canonicalThreadId, runId: canonicalRunId }).catch(() => void 0); } if (heartbeatError !== void 0) { await stopPromise; throw heartbeatError; } return result; } /** Convert canonical Intelligence history into AG-UI messages. */ async function toAgentMessage(message, intelligence) { const content = await hydrateManagedContent(message.content, intelligence); return { id: message.id, role: message.role, content: content ?? "", ...message.activityType ? { activityType: message.activityType } : {}, ...message.toolCalls ? { toolCalls: message.toolCalls.map((call) => ({ id: call.id, type: "function", function: { name: call.name, arguments: call.args } })) } : {}, ...message.toolCallId ? { toolCallId: message.toolCallId } : {} }; } /** Resolves managed asset references only at the authorized Runtime boundary. */ async function hydrateManagedContent(content, intelligence) { if (Array.isArray(content)) return Promise.all(content.map(async (part) => { if (typeof part !== "object" || part === null || !("source" in part) || typeof part.source !== "object" || part.source === null || !("value" in part.source) || typeof part.source.value !== "string" || !part.source.value.startsWith("cpki-asset://")) return part; const assetId = part.source.value.slice(13); const asset = await intelligence.ɵgetManagedChannelAsset(assetId); return { ...part, source: { type: "data", value: Buffer.from(asset.bytes).toString("base64"), mimeType: asset.mimeType ?? ("mimeType" in part.source && typeof part.source.mimeType === "string" ? part.source.mimeType : "application/octet-stream") } }; })); if (typeof content === "object" && content !== null && "assetId" in content && typeof content.assetId === "string") { const asset = await intelligence.ɵgetManagedChannelAsset(content.assetId); return { ...content, source: { type: "data", value: Buffer.from(asset.bytes).toString("base64"), mimeType: asset.mimeType ?? ("mimeType" in content && typeof content.mimeType === "string" ? content.mimeType : "application/octet-stream") } }; } return content; } /** Whether `err` signals a missing managed provider rather than a hard failure. */ function isSetupRequired(err) { return err instanceof ChannelSetupRequiredError || typeof err === "object" && err !== null && err.code === "SETUP_REQUIRED"; } /** * Whether `err` is a Node/runtime module-resolution failure — i.e. the error * a dynamic `import()` throws when the target package is not installed. * Exported so the friendly-error path in {@link defaultActivateChannel} can be * unit-tested without forcing a real failing import. */ function isModuleNotFound(err) { if (typeof err !== "object" || err === null) return false; const code = err.code; return code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND"; } /** Default deadline (ms) for a single `handle.stop()` during teardown. */ const DEFAULT_STOP_HANDLE_TIMEOUT_MS = 5e3; /** Default cadence (ms) for the "still down" log while a session is dropped. */ const DEFAULT_RECONNECT_LOG_INTERVAL_MS = 3e4; /** * Reject with `timeoutMessage` after `timeoutMs` if `inner` has not settled, * otherwise pass `inner` through. When `timeoutMs` is undefined, `inner` is * returned unchanged. The timer is `unref`'d so a pending deadline never keeps * the process alive, and `inner` always has a settle handler attached, so a * timed-out promise that later settles never surfaces as unhandled. */ function withTimeout(inner, timeoutMs, timeoutMessage) { if (timeoutMs === void 0) return inner; return new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs); timer.unref?.(); inner.then((value) => { clearTimeout(timer); resolve(value); }, (err) => { clearTimeout(timer); reject(err); }); }); } /** * Drives Channel activation for an Intelligence runtime: lazily activates each * declared Channel, tracks per-Channel lifecycle status, exposes readiness, and * tears everything down. A MANAGED Channel (empty `adapters`) is activated * through the injected engine over the Intelligence gateway; a DIRECT Channel * (developer-supplied adapter) is started through its own transport seam * (`channel.ɵruntime.start()`) — driven by the manager, but running only its own * adapter transport, not the gateway path (direct Channels stay below the * canonical/reliability layer by design — see {@link ChannelStatus} and OSS-599). * Its very existence means Intelligence is configured, so there is no * standalone/self-started path. * * Activation is lazy and idempotent — constructing the manager does nothing; * {@link activate} starts it and a second call is a no-op. Activation throws * SYNCHRONOUSLY (a {@link ChannelConfigError}) only for a misconfiguration it * can detect up front — a duplicate or missing Channel name. Every OTHER * activation failure is recorded as the Channel's status (`error`, or * `setup_required` for a missing provider) and surfaced through {@link status} * and {@link ready} rather than thrown. * * Reconnection is NOT handled here — it is delegated to the Phoenix connection * layer that backs the launcher. When a managed control socket drops, Phoenix's * `Socket` reconnects and rejoins with the same Runtime declaration. Active * deliveries request fresh one-use join tokens through that control link. A * re-activation here would be both redundant AND broken: re-invoking the engine * on an already-started `Channel` throws in `channel.addAdapter` (started=true). * The manager therefore never re-activates on a drop. * * It DOES, however, reflect real connection health through the session's * `onStateChange` observer so {@link ChannelManager.status} stays honest rather * than reporting `online` forever after a drop: a drop moves the Channel to * `reconnecting`, a successful rejoin restores `online`, and a bounded give-up * (Phoenix would otherwise retry forever) moves it to `error`. */ var ChannelManager = class { /** @param args - See {@link ChannelManagerArgs}. */ constructor(args) { this.entries = /* @__PURE__ */ new Map(); this.activated = false; this.stopped = false; this.intelligence = args.intelligence; this.runner = args.runner; this.lockTtlSeconds = args.lockTtlSeconds ?? 20; this.lockHeartbeatIntervalSeconds = args.lockHeartbeatIntervalSeconds ?? 15; this.lockKeyPrefix = args.lockKeyPrefix; this.channels = args.channels; this.log = args.log; this.activateChannel = args.activateChannel ?? ((config, channel) => defaultActivateChannel(config, channel, void 0, this.log, this.runner ? { runner: this.runner, intelligence: this.intelligence, lockTtlSeconds: this.lockTtlSeconds, lockHeartbeatIntervalSeconds: this.lockHeartbeatIntervalSeconds, ...this.lockKeyPrefix !== void 0 ? { lockKeyPrefix: this.lockKeyPrefix } : {} } : void 0)); this.mintRuntimeInstanceId = args.mintRuntimeInstanceId ?? (() => `rti_${randomUUID().replace(/-/g, "")}`); this.stopHandleTimeoutMs = args.stopHandleTimeoutMs ?? DEFAULT_STOP_HANDLE_TIMEOUT_MS; this.reconnectLogIntervalMs = args.reconnectLogIntervalMs ?? DEFAULT_RECONNECT_LOG_INTERVAL_MS; } /** * Start activation of every declared Channel (lazy + idempotent). Mints a * distinct runtime instance id per Channel, derives its activation config, * and calls the engine. Records each Channel as `connecting`, transitioning * to `online`/`setup_required`/`error` as its activation settles. */ activate() { if (this.activated || this.stopped) return; this.assertUniqueChannelNames(); this.activated = true; for (const channel of this.channels) { if (channel.adapters.some((a) => !a.__intelligenceChannel)) { this.startDirectChannel(channel); continue; } const name = channel.name; const runtimeInstanceId = this.mintRuntimeInstanceId(); let resolveSettled; let rejectSettled; const settled = new Promise((resolve, reject) => { resolveSettled = resolve; rejectSettled = reject; }); settled.catch(() => {}); let activation; let config; try { config = deriveChannelActivationConfig({ intelligence: this.intelligence, channel, runtimeInstanceId }); activation = this.activateChannel(config, channel); } catch (err) { activation = Promise.reject(err); } const entry = { status: "connecting", handle: void 0, handleStopped: false, settled }; activation.then(async (handle) => { entry.handle = handle; if (this.stopped) { await this.stopEntry(entry); resolveSettled(); return; } entry.status = "online"; this.registerConnectionObserver(name, entry); resolveSettled(); }, async (err) => { if (this.stopped) { await this.stopEntry(entry); resolveSettled(); return; } if (isSetupRequired(err)) { entry.status = "setup_required"; this.log?.(`channel "${name}" requires setup`, err); resolveSettled(); } else { entry.status = "error"; this.log?.(`channel "${name}" failed to activate`, err); rejectSettled(err); } }).catch(() => {}); this.entries.set(name, entry); } } /** * Start a DIRECT-adapter Channel through its own transport seam * ({@link Channel.ɵruntime}`.start()`), recording a live entry so * {@link ready}/{@link status}/{@link stop} all cover it. A direct Channel is * driven by the manager — but only because the Intelligence runtime constructed * this manager at all — and runs its OWN adapter transport, NOT the Intelligence * gateway path. It is deliberately NOT wired into the gateway/canonical/ * reliability layer — that boundary is permanent, not a deferral (OSS-599). * * Mirrors the managed path's settle machinery so teardown resilience is shared: * the entry's `handle` is a synthetic {@link ChannelsHandle} whose `stop()` * calls `channel.ɵruntime.stop()`, so the SAME idempotent, bounded, resilient * {@link stopEntry} that tears down a managed handle tears down a direct Channel * too. The handle is assigned only AFTER `start()` resolves (exactly as the * managed path assigns its handle only on resolve), so a `stop()` during a * still-starting direct Channel returns promptly with nothing to stop and the * post-settle guard tears down the late transport. A direct Channel exposes no * managed-session drop signal, so no connection observer is wired and it never * reaches `reconnecting`. * * @param channel - The direct-adapter Channel to start. */ startDirectChannel(channel) { const name = channel.name; this.log?.(`channel "${name}" carries a direct adapter — starting its own transport via channel.ɵruntime.start() (the Intelligence runtime drives its lifecycle; it runs its own adapter transport, NOT the managed gateway path — direct Channels stay below the canonical/reliability layer by design (OSS-599); managed+direct coexistence deferred (OSS-484))`); let resolveSettled; let rejectSettled; const settled = new Promise((resolve, reject) => { resolveSettled = resolve; rejectSettled = reject; }); settled.catch(() => {}); const directHandle = { metadata: {}, stop: () => channel.ɵruntime.stop() }; let activation; try { activation = channel.ɵruntime.start(); } catch (err) { activation = Promise.reject(err); } const entry = { status: "connecting", handle: void 0, handleStopped: false, settled }; activation.then(async () => { entry.handle = directHandle; if (this.stopped) { await this.stopEntry(entry); resolveSettled(); return; } entry.status = "online"; resolveSettled(); }, async (err) => { if (this.stopped) { entry.handle = directHandle; await this.stopEntry(entry); resolveSettled(); return; } entry.status = "error"; this.log?.(`channel "${name}" failed to start its direct transport`, err); rejectSettled(err); }).catch(() => {}); this.entries.set(name, entry); } /** * Throw if two declared Channels share a `name`. `entries` is keyed by name, * so a duplicate would overwrite the first Channel's entry and leak its live * session. Called at the very start of {@link activate}, before any engine * call, so a misconfiguration fails loud instead of silently. * * @throws {ChannelConfigError} If any Channel is missing a name, or if any * name appears more than once. */ assertUniqueChannelNames() { const seen = /* @__PURE__ */ new Set(); for (const channel of this.channels) { const name = channel.name; if (!name) throw new ChannelConfigError("A managed Channel is missing a `name` — every declared Channel must have a unique, non-empty name (pass createChannel({ name }))."); if (seen.has(name)) throw new ChannelConfigError(`Duplicate managed Channel name "${name}" — every declared Channel must have a unique name.`); seen.add(name); } } /** * Resolve when every declared Channel — managed OR direct — has settled to * `online`/`setup_required`. * * A direct-adapter Channel is awaited too: its `settled` resolves once its own * transport is up (`channel.ɵruntime.start()` settling) and rejects if that * start fails, exactly as a managed Channel's `settled` tracks its activation. * * Activates lazily if not already started — so a first call rejects with the * same {@link ChannelConfigError} as the synchronous throw from * {@link activate} for an up-front misconfiguration (duplicate/missing Channel * names). Once activation has been kicked off, all OTHER failures are surfaced * here instead: this rejects with an `AggregateError` if any Channel settled * to `error` OR — when `timeoutMs` is given — did not settle in time. The * `timeoutMs` deadline is applied PER CHANNEL, so the aggregate carries each * failed Channel's real reason AND a named timeout for each Channel still * hanging: a genuine activation error is never masked by a sibling that hangs * (a pre-fix set-wide timeout discarded the real reason in that case). * * A STOPPED manager short-circuits and resolves: a Channel that settled to * `error` BEFORE {@link stop} already rejected its `settled` promise, so * awaiting it here would throw an `AggregateError` even though * {@link status}.overall is `"stopped"` — inconsistent with the case where the * Channel was still online at stop() (which resolves). A stopped manager has * nothing left to be ready for, so resolve uniformly. * * `ready()` is ONE-SHOT: it settles on the INITIAL activation outcome. Later * connection-health transitions (a live Channel dropping to `reconnecting`, or * giving up to `error`) are reported through {@link status} — where `online` * means currently-sendable — but do NOT re-arm or re-reject an already-settled * `ready()`. */ async ready(opts) { if (this.stopped) return; this.activate(); const entries = [...this.entries.entries()]; const errors = (await Promise.allSettled(entries.map(([name, e]) => withTimeout(e.settled, opts?.timeoutMs, `channel "${name}" did not settle within ${opts?.timeoutMs}ms`)))).filter((r) => r.status === "rejected").map((r) => r.reason); if (errors.length > 0) throw new AggregateError(errors, `ChannelManager.ready: ${errors.length} channel(s) failed to activate or settle in time`); } /** * Snapshot status. Every declared Channel — managed OR direct — appears keyed * by name in `channels`; a direct-adapter Channel reads `online` once its own * transport is up, exactly like a managed one. * * `overall` is folded over ALL declared Channels (see {@link computeOverall}), * by precedence `error` > `reconnecting` > `setup_required` > `connecting` > * `online`. `online` means every Channel can currently send. `reconnecting` * outranks `setup_required` because a dropped-but-retrying Channel is an active * outage, louder than a steadily-degraded unprovisioned one (only managed * Channels ever reach `reconnecting`; a direct Channel has no managed-session * drop signal). With no declared Channels at all, `overall` is `online` (nothing * is degraded); once every Channel has been stopped, `overall` is `stopped`. */ status() { const channels = {}; for (const [name, entry] of this.entries) channels[name] = entry.status; if (this.stopped) return { overall: "stopped", channels }; if (!this.activated && this.channels.length > 0) return { overall: "connecting", channels }; return { overall: this.computeOverall(Object.values(channels)), channels }; } /** * Fold per-Channel statuses into a single overall status (see {@link status}). * * Every declared Channel — managed OR direct — participates: a started direct * Channel reads `online` and counts toward health exactly like a managed one, * and its `error` is a real outage that must dominate. Statuses are ranked * `error` > `reconnecting` > `setup_required` > `connecting` > `online`, so a * genuine failure still dominates a healthy sibling. (Only managed Channels ever * reach `reconnecting`; a direct Channel has no managed-session drop signal.) * The empty-input case (no declared Channels at all) stays `online` (nothing is * degraded). */ computeOverall(values) { if (values.length === 0) return "online"; if (values.every((v) => v === "stopped")) return "stopped"; if (values.includes("error")) return "error"; if (values.includes("reconnecting")) return "reconnecting"; if (values.includes("setup_required")) return "setup_required"; if (values.includes("connecting")) return "connecting"; return "online"; } /** * Wire the Channel's connection-health observer (if the handle exposes the * optional `onStateChange` seam) so {@link ChannelManager.status} reflects real * health instead of reporting `online` forever after a drop: * * - `reconnecting` → status `reconnecting` (dropped, Phoenix retrying); * - `online` → status `online` (rejoined, sendable again); * - `gave_up` → status `error` (dead after the bounded reconnect window). * * Makes NO re-activation — reconnection is delegated to the Phoenix connection * layer (see {@link ChannelManager}), which auto-rejoins under the persistent * adapter. A STOPPED manager (or an already-stopped entry) ignores late * connection events, so a drop that fires after {@link ChannelManager.stop} * never resurrects the Channel out of `stopped`. * * @param name - The Channel name (map key). * @param entry - The Channel's activation entry. */ registerConnectionObserver(name, entry) { entry.handle?.onStateChange?.((state, detail) => { if (this.stopped || entry.status === "stopped") return; const cause = detail?.reason ?? detail?.code; const because = cause !== void 0 ? ` — ${cause}` : ""; if (state === "reconnecting") { entry.status = "reconnecting"; entry.downSince ??= Date.now(); this.log?.(`channel "${name}" managed session dropped; reconnecting (Phoenix auto-rejoin)${because}`); this.startReconnectLog(name, entry); } else if (state === "online") { entry.status = "online"; this.clearReconnectLog(entry); entry.downSince = void 0; this.log?.(`channel "${name}" managed session back online`); } else if (state === "gave_up") { entry.status = "error"; this.log?.(`channel "${name}" managed session gave up reconnecting after ${this.downFor(entry)}; marking error (still retrying — a successful rejoin restores online)${because}`); } }); } /** Rendered downtime for this outage episode (`"45s"`), or `"unknown"`. */ downFor(entry) { return entry.downSince === void 0 ? "unknown" : `${Math.round((Date.now() - entry.downSince) / 1e3)}s`; } /** * Repeat a "still down" line for as long as this outage lasts. Runs THROUGH * `gave_up` on purpose: that transition is where the old behavior went quiet, * and an operator watching a silent process cannot tell a dead bot from an * idle one. */ startReconnectLog(name, entry) { if (entry.reconnectLogTimer !== void 0) return; const timer = setInterval(() => { if (this.stopped || entry.status === "stopped") { this.clearReconnectLog(entry); return; } this.log?.(`channel "${name}" managed session still down after ${this.downFor(entry)}; Phoenix is retrying`); }, this.reconnectLogIntervalMs); timer.unref?.(); entry.reconnectLogTimer = timer; } /** Stop this entry's "still down" repeat, if one is running. */ clearReconnectLog(entry) { if (entry.reconnectLogTimer !== void 0) { clearInterval(entry.reconnectLogTimer); entry.reconnectLogTimer = void 0; } } /** * Drive a single entry to its terminal `stopped` state, tearing down its * handle AT MOST ONCE. Idempotent: it always sets `status = "stopped"`, and * only calls `handle.stop()` on the first invocation that sees a live, * not-yet-stopped handle (gated by {@link ChannelEntry.handleStopped}). * * This is the ONE guarded teardown path shared by both `stop()` and the * post-settle guard in {@link activate}/{@link startDirectChannel}. Because the * guard is per-entry and idempotent, a handle assigned in the same tick as * `stop()` is stopped exactly once even when both callers reach the entry, and a * late settle can never resurrect a `stopped` entry. It is transport-agnostic: a * managed entry's `handle.stop()` releases the gateway session, and a direct * entry's synthetic handle (assigned in {@link startDirectChannel}) routes the * same `handle.stop()` to `channel.ɵruntime.stop()` — so direct and managed * Channels share one bounded, resilient teardown. * * `handle.stop()` failures are logged (via {@link ChannelManager.log}) but NOT * rethrown: the real launcher's `stop()` rethrows after `session.disconnect()`, * and teardown must still complete for every other entry. The call is wrapped * in `Promise.resolve().then(...)` so a foreign/injected handle whose `stop()` * throws SYNCHRONOUSLY (before any promise is created) is caught by the same * `.catch` — otherwise the sync throw would escape, skip `resolveSettled()` in * the fulfilled-then-stopped branch of {@link activate}, and hang `settled`. * * An entry with no handle yet (a still-`connecting` Channel whose transport has * not come up) is only marked `stopped`: there is nothing to tear down, and the * post-settle guard releases the transport if it arrives after `stop()`. * * A WEDGED `handle.stop()` (one that never settles) is bounded by * {@link ChannelManagerArgs.stopHandleTimeoutMs}: after the deadline the call * is logged and abandoned so it can't hang `stop()` — and thus SIGTERM * shutdown — forever. * * @param entry - The Channel entry to stop. */ async stopEntry(entry) { entry.status = "stopped"; this.clearReconnectLog(entry); if (entry.handle && !entry.handleStopped) { entry.handleStopped = true; const handle = entry.handle; await withTimeout(Promise.resolve().then(() => handle.stop()), this.stopHandleTimeoutMs, `channel handle stop() timed out after ${this.stopHandleTimeoutMs}ms during teardown`).catch((err) => this.log?.("channel handle stop() failed during teardown", err)); } } /** * Stop every activated Channel exactly once and mark all statuses `stopped`. * Idempotent — a second call is a no-op. * * Resolves promptly: {@link stopEntry} stops only the handles that already * exist and never blocks on activations that have not settled. A hung connect * (which `ready({ timeoutMs })` tolerates) has no handle to stop yet, and * awaiting it here would hang teardown — and thus SIGTERM shutdown — forever. * Any handle that arrives after this point is torn down by the post-settle * guard in {@link activate}, which routes through the same idempotent * {@link stopEntry}, so nothing leaks and nothing double-stops. * * Teardown is resilient to a throwing `handle.stop()`: `Promise.allSettled` * over the per-entry `stopEntry` calls guarantees one rejection can't abort * the rest, so every entry reaches `stopped` and `stop()` always resolves. * It is equally resilient to a WEDGED `handle.stop()` that never settles: each * is bounded by {@link ChannelManagerArgs.stopHandleTimeoutMs} inside * {@link stopEntry}, so a single hung handle can't hang SIGTERM shutdown. */ async stop() { if (this.stopped) return; this.stopped = true; const entries = [...this.entries.values()]; await Promise.allSettled(entries.map((entry) => this.stopEntry(entry))); } }; //#endregion export { ChannelManager }; //# sourceMappingURL=channel-manager.mjs.map