dsh-im-ops/plugin-src/host/channels/shared/connection-supervisor.mjs
2026-08-16 14:47:16 +08:00

133 lines
3.8 KiB
JavaScript

const DEFAULT_RETRY_DELAYS_MS = Object.freeze([250, 1_000, 3_000, 5_000, 10_000, 30_000]);
function retryDelays(value) {
if (!Array.isArray(value) || value.length === 0) return [...DEFAULT_RETRY_DELAYS_MS];
const valid = value.filter((delay) => Number.isFinite(delay) && delay >= 0);
return valid.length > 0 ? valid : [...DEFAULT_RETRY_DELAYS_MS];
}
export class TokenConnectionSupervisor {
#channel;
#controller;
#harness;
#logger;
#retryDelays;
#healthyIntervalMs;
#setTimeout;
#clearTimeout;
#timer = null;
#running = null;
#retryIndex = 0;
#closed = false;
#started = false;
#ready;
#resolveReady;
constructor({
channel,
controller,
harness,
logger = console,
retryDelaysMs,
healthyIntervalMs = 15_000,
setTimeoutImpl = setTimeout,
clearTimeoutImpl = clearTimeout,
}) {
if (!channel) throw new TypeError('TokenConnectionSupervisor requires a channel');
if (!controller || typeof controller.initialize !== 'function' || typeof controller.status !== 'function') {
throw new TypeError('TokenConnectionSupervisor requires a controller');
}
if (!harness || typeof harness.ensureRunning !== 'function') {
throw new TypeError('TokenConnectionSupervisor requires a Harness client');
}
this.#channel = channel;
this.#controller = controller;
this.#harness = harness;
this.#logger = logger;
this.#retryDelays = retryDelays(retryDelaysMs);
this.#healthyIntervalMs = Number.isFinite(healthyIntervalMs) && healthyIntervalMs >= 0
? healthyIntervalMs : 15_000;
this.#setTimeout = setTimeoutImpl;
this.#clearTimeout = clearTimeoutImpl;
this.#ready = new Promise((resolve) => {
this.#resolveReady = resolve;
});
}
get ready() {
return this.#ready;
}
start() {
if (this.#started || this.#closed) return this;
this.#started = true;
this.#schedule(0);
return this;
}
async close() {
if (this.#closed) return;
this.#closed = true;
if (this.#timer !== null) this.#clearTimeout(this.#timer);
this.#timer = null;
await this.#running?.catch(() => undefined);
this.#resolveReady?.(null);
this.#resolveReady = null;
}
#schedule(delayMs) {
if (this.#closed) return;
this.#timer = this.#setTimeout(() => {
this.#timer = null;
void this.#run();
}, delayMs);
this.#timer?.unref?.();
}
async #run() {
if (this.#closed || this.#running) return;
const operation = this.#reconcile();
this.#running = operation;
try {
await operation;
} finally {
if (this.#running === operation) this.#running = null;
}
}
async #reconcile() {
try {
await this.#harness.ensureRunning();
if (this.#closed) return;
const status = await this.#controller.initialize();
if (this.#closed) return;
this.#resolveReady?.(status);
this.#resolveReady = null;
const { configured, connected } = status.totals;
if (connected < configured) {
const delay = this.#retryDelays[Math.min(this.#retryIndex, this.#retryDelays.length - 1)];
this.#retryIndex += 1;
this.#logger.warn?.(
`[dsh-im:${this.#channel}] ${connected}/${configured} bots connected; retrying in ${delay}ms`,
);
this.#schedule(delay);
return;
}
this.#retryIndex = 0;
this.#schedule(this.#healthyIntervalMs);
} catch (error) {
if (this.#closed) return;
const delay = this.#retryDelays[Math.min(this.#retryIndex, this.#retryDelays.length - 1)];
this.#retryIndex += 1;
this.#logger.warn?.(
`[dsh-im:${this.#channel}] connection reconciliation failed; retrying in ${delay}ms`,
error,
);
this.#schedule(delay);
}
}
}
export function createTokenConnectionSupervisor(options) {
return new TokenConnectionSupervisor(options);
}