import type { ChannelId } from "../channels/plugins/types.public.js" ;
import { createSubsystemLogger } from "../logging/subsystem.js" ;
import {
DEFAULT_CHANNEL_CONNECT_GRACE_MS,
DEFAULT_CHANNEL_STALE_EVENT_THRESHOLD_MS,
evaluateChannelHealth,
resolveChannelRestartReason,
type ChannelHealthPolicy,
} from "./channel-health-policy.js" ;
import type { ChannelManager } from "./server-channels.js" ;
const log = createSubsystemLogger("gateway/health-monitor" );
const DEFAULT_CHECK_INTERVAL_MS = 5 * 60 _000 ;
const DEFAULT_MONITOR_STARTUP_GRACE_MS = 60 _000 ;
const DEFAULT_COOLDOWN_CYCLES = 2 ;
const DEFAULT_MAX_RESTARTS_PER_HOUR = 10 ;
const ONE_HOUR_MS = 60 * 60 _000 ;
/**
* How long a connected channel can go without proven transport activity before
* the health monitor treats it as a " stale socket " and triggers a restart .
* Providers should only publish that timestamp from transport / heartbeat / poll
* signals , not from ordinary app messages .
*/
export type ChannelHealthTimingPolicy = {
monitorStartupGraceMs: number;
channelConnectGraceMs: number;
staleEventThresholdMs: number;
};
export type ChannelHealthMonitorDeps = {
channelManager: ChannelManager;
checkIntervalMs?: number;
/** @deprecated use timing.monitorStartupGraceMs */
startupGraceMs?: number;
/** @deprecated use timing.channelConnectGraceMs */
channelStartupGraceMs?: number;
/** @deprecated use timing.staleEventThresholdMs */
staleEventThresholdMs?: number;
timing?: Partial<ChannelHealthTimingPolicy>;
cooldownCycles?: number;
maxRestartsPerHour?: number;
abortSignal?: AbortSignal;
};
export type ChannelHealthMonitor = {
stop: () => void ;
};
type RestartRecord = {
lastRestartAt: number;
restartsThisHour: { at: number }[];
};
function resolveTimingPolicy(
deps: Pick<
ChannelHealthMonitorDeps,
"startupGraceMs" | "channelStartupGraceMs" | "staleEventThresholdMs" | "timing"
>,
): ChannelHealthTimingPolicy {
return {
monitorStartupGraceMs:
deps.timing?.monitorStartupGraceMs ?? deps.startupGraceMs ?? DEFAULT_MONITOR_STARTUP_GRACE_MS,
channelConnectGraceMs:
deps.timing?.channelConnectGraceMs ??
deps.channelStartupGraceMs ??
DEFAULT_CHANNEL_CONNECT_GRACE_MS,
staleEventThresholdMs:
deps.timing?.staleEventThresholdMs ??
deps.staleEventThresholdMs ??
DEFAULT_CHANNEL_STALE_EVENT_THRESHOLD_MS,
};
}
export function startChannelHealthMonitor(deps: ChannelHealthMonitorDeps): ChannelHealthMonitor {
const {
channelManager,
checkIntervalMs = DEFAULT_CHECK_INTERVAL_MS,
cooldownCycles = DEFAULT_COOLDOWN_CYCLES,
maxRestartsPerHour = DEFAULT_MAX_RESTARTS_PER_HOUR,
abortSignal,
} = deps;
const timing = resolveTimingPolicy(deps);
const cooldownMs = cooldownCycles * checkIntervalMs;
const restartRecords = new Map<string, RestartRecord>();
const startedAt = Date.now();
let stopped = false ;
let checkInFlight = false ;
let timer: ReturnType<typeof setInterval> | null = null ;
const rKey = (channelId: string, accountId: string) => `${channelId}:${accountId}`;
function pruneOldRestarts(record: RestartRecord, now: number) {
record.restartsThisHour = record.restartsThisHour.filter((r) => now - r.at < ONE_HOUR_MS);
}
async function runCheck() {
if (stopped || checkInFlight) {
return ;
}
checkInFlight = true ;
try {
const now = Date.now();
if (now - startedAt < timing.monitorStartupGraceMs) {
return ;
}
const snapshot = channelManager.getRuntimeSnapshot();
for (const [channelId, accounts] of Object.entries(snapshot.channelAccounts)) {
if (!accounts) {
continue ;
}
for (const [accountId, status] of Object.entries(accounts)) {
if (!status) {
continue ;
}
if (!channelManager.isHealthMonitorEnabled(channelId as ChannelId, accountId)) {
continue ;
}
if (channelManager.isManuallyStopped(channelId as ChannelId, accountId)) {
continue ;
}
const healthPolicy: ChannelHealthPolicy = {
channelId,
now,
staleEventThresholdMs: timing.staleEventThresholdMs,
channelConnectGraceMs: timing.channelConnectGraceMs,
};
const health = evaluateChannelHealth(status, healthPolicy);
if (health.healthy) {
continue ;
}
const key = rKey(channelId, accountId);
const record = restartRecords.get(key) ?? {
lastRestartAt: 0 ,
restartsThisHour: [],
};
if (now - record.lastRestartAt <= cooldownMs) {
continue ;
}
pruneOldRestarts(record, now);
if (record.restartsThisHour.length >= maxRestartsPerHour) {
log.warn?.(
`[${channelId}:${accountId}] health-monitor: hit ${maxRestartsPerHour} restarts/hour limit, skipping`,
);
continue ;
}
const reason = resolveChannelRestartReason(status, health);
log.info?.(`[${channelId}:${accountId}] health-monitor: restarting (reason: ${reason})`);
try {
if (status.running) {
await channelManager.stopChannel(channelId as ChannelId, accountId);
}
channelManager.resetRestartAttempts(channelId as ChannelId, accountId);
await channelManager.startChannel(channelId as ChannelId, accountId);
record.lastRestartAt = now;
record.restartsThisHour.push({ at: now });
restartRecords.set(key, record);
} catch (err) {
log.error?.(
`[${channelId}:${accountId}] health-monitor: restart failed: ${String(err)}`,
);
}
}
}
} finally {
checkInFlight = false ;
}
}
function stop() {
stopped = true ;
if (timer) {
clearInterval(timer);
timer = null ;
}
}
if (abortSignal?.aborted) {
stopped = true ;
} else {
abortSignal?.addEventListener("abort" , stop, { once: true });
timer = setInterval(() => void runCheck(), checkIntervalMs);
if (typeof timer === "object" && "unref" in timer) {
timer.unref();
}
log.info?.(
`started (interval: ${Math.round(checkIntervalMs / 1000 )}s, startup-grace: ${Math.round(timing.monitorStartupGraceMs / 1000 )}s, channel-connect-grace: ${Math.round(timing.channelConnectGraceMs / 1000 )}s)`,
);
}
return { stop };
}
Messung V0.5 in Prozent C=99 H=95 G=96
¤ Die Informationen auf dieser Webseite wurden
nach bestem Wissen sorgfältig zusammengestellt. Es wird jedoch weder Vollständigkeit, noch Richtigkeit,
noch Qualität der bereit gestellten Informationen zugesichert.0.13Bemerkung:
(vorverarbeitet am 2026-09-28)
¤
*Bot Zugriff