openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
418 lines (417 loc) • 18.7 kB
JavaScript
import { c as normalizeOptionalString } from "./string-coerce-mnp54Vah.js";
import { t as createSubsystemLogger } from "./subsystem-BzXSmsuh.js";
import { n as disposeRegisteredAgentHarnesses } from "./registry-DJ-L47Jb.js";
import { m as triggerInternalHook, n as createInternalHookEvent } from "./internal-hooks-Bq6_9FFu.js";
import { a as closePluginStateSqliteStore } from "./plugin-state-store-jd1pQXxt.js";
import { i as listChannelPlugins } from "./registry-MkxNn1Ue.js";
import "./plugins-t2ejWcVy.js";
import { r as disposeAllSessionMcpRuntimes } from "./agent-bundle-mcp-runtime-Bf36JLIh.js";
import "./agent-bundle-mcp-tools-tGjdbeEf.js";
import { r as abortTrackedChatRunById } from "./chat-abort-Cu_ehtms.js";
import { n as collectGatewayProcessMemoryUsageMb, o as measureGatewayRestartTrace, s as recordGatewayRestartTrace } from "./restart-trace-q_or-N4J.js";
import { r as drainActiveSessionsForShutdown } from "./session-reset-service-DG5dw0Ix.js";
//#region src/gateway/server-close.ts
const shutdownLog = createSubsystemLogger("gateway/shutdown");
const GATEWAY_SHUTDOWN_HOOK_TIMEOUT_MS = 5e3;
const GATEWAY_PRE_RESTART_HOOK_TIMEOUT_MS = 1e4;
const ACTIVE_SESSIONS_SHUTDOWN_DRAIN_TIMEOUT_MS = 2e3;
const WEBSOCKET_CLOSE_GRACE_MS = 1e3;
const WEBSOCKET_CLOSE_FORCE_CONTINUE_MS = 250;
const HTTP_CLOSE_GRACE_MS = 1e3;
const HTTP_CLOSE_FORCE_WAIT_MS = 5e3;
const MCP_RUNTIME_CLOSE_GRACE_MS = 5e3;
const LSP_RUNTIME_CLOSE_GRACE_MS = 5e3;
const RESTART_REPLY_DRAIN_POLL_MS = 100;
const RESTART_REPLY_POST_ABORT_DRAIN_TIMEOUT_MS = 1e3;
const RESTART_REPLY_POST_ABORT_DRAIN_POLL_MS = 50;
/** Create a timeout promise plus cleanup hook for shutdown races. */
function createTimeoutRace(timeoutMs, onTimeout) {
let timer = null;
timer = setTimeout(() => {
if (timer) {
clearTimeout(timer);
timer = null;
}
resolve(onTimeout());
}, timeoutMs);
timer.unref?.();
let resolve;
return {
promise: new Promise((innerResolve) => {
resolve = innerResolve;
}),
clear() {
if (timer) {
clearTimeout(timer);
timer = null;
}
}
};
}
/** Run one shutdown step and record a warning instead of aborting the whole close. */
async function shutdownStep(name, fn, warnings) {
try {
await fn();
return true;
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
shutdownLog.warn(`${name}: ${detail}`);
recordShutdownWarning(warnings, name);
return false;
}
}
/** Record a shutdown warning once. */
function recordShutdownWarning(warnings, name) {
if (!warnings.includes(name)) warnings.push(name);
}
/** Count pending replies and active runs that must drain before restart shutdown. */
function getRestartReplyDrainCounts(params) {
const pendingReplyCount = params.getPendingReplyCount();
const activeRuns = listRestartDrainRuns(params.chatAbortControllers).length;
return {
pendingReplies: Number.isFinite(pendingReplyCount) && pendingReplyCount > 0 ? Math.floor(pendingReplyCount) : 0,
activeRuns
};
}
/** List active runs participating in restart drain. */
function listRestartDrainRuns(chatAbortControllers) {
return Array.from(chatAbortControllers.entries());
}
/** Format drain counts for shutdown logs. */
function formatRestartReplyDrainDetails(counts) {
const details = [];
if (counts.pendingReplies > 0) details.push(`${counts.pendingReplies} pending reply(ies)`);
if (counts.activeRuns > 0) details.push(`${counts.activeRuns} active run(s)`);
return details.length > 0 ? details.join(", ") : "no pending reply work";
}
/** Sleep helper with unref'd timer for restart drain polling. */
async function sleepForRestartReplyDrain(delayMs) {
await new Promise((resolve) => {
setTimeout(resolve, delayMs).unref?.();
});
}
/** Wait for pending replies and active runs to drain before restart shutdown. */
async function waitForRestartReplyDrain(params) {
const timeoutMs = Math.max(0, Math.floor(params.timeoutMs));
const pollMs = Math.max(25, Math.floor(params.pollMs ?? RESTART_REPLY_DRAIN_POLL_MS));
let counts = getRestartReplyDrainCounts(params);
if (counts.pendingReplies <= 0 && counts.activeRuns <= 0) return {
drained: true,
elapsedMs: 0,
counts
};
if (timeoutMs <= 0) return {
drained: false,
elapsedMs: 0,
counts
};
const startedAt = Date.now();
for (;;) {
const elapsedMs = Date.now() - startedAt;
if (elapsedMs >= timeoutMs) return {
drained: false,
elapsedMs,
counts
};
await sleepForRestartReplyDrain(Math.min(pollMs, timeoutMs - elapsedMs));
counts = getRestartReplyDrainCounts(params);
if (counts.pendingReplies <= 0 && counts.activeRuns <= 0) return {
drained: true,
elapsedMs: Date.now() - startedAt,
counts
};
}
}
/** Abort active chat runs that did not drain before restart shutdown. */
function abortActiveRunsForRestart(params) {
let aborted = 0;
for (const [runId, entry] of listRestartDrainRuns(params.chatAbortControllers)) if (abortTrackedChatRunById({
...params,
chatRunBuffers: params.chatRunState.buffers
}, {
runId,
sessionKey: entry.sessionKey,
stopReason: "restart"
}).aborted) aborted += 1;
return aborted;
}
/** Drain or abort pending reply work before restart shutdown proceeds. */
async function drainRestartPendingRepliesForShutdown(params) {
const initialCounts = getRestartReplyDrainCounts(params);
if (initialCounts.pendingReplies <= 0 && initialCounts.activeRuns <= 0) return;
const timeoutMs = Math.max(0, Math.floor(params.timeoutMs));
if (timeoutMs > 0) shutdownLog.info(`waiting for ${formatRestartReplyDrainDetails(initialCounts)} before restart shutdown (timeout ${timeoutMs}ms)`);
const drainResult = await waitForRestartReplyDrain({
getPendingReplyCount: params.getPendingReplyCount,
chatAbortControllers: params.chatAbortControllers,
timeoutMs
});
if (drainResult.drained) {
shutdownLog.info(`restart reply drain completed after ${drainResult.elapsedMs}ms`);
return;
}
shutdownLog.warn(`restart reply drain timed out after ${drainResult.elapsedMs}ms with ${formatRestartReplyDrainDetails(drainResult.counts)} still active; continuing shutdown`);
recordShutdownWarning(params.warnings, "restart-reply-drain");
if (drainResult.counts.activeRuns <= 0) return;
const abortedRuns = abortActiveRunsForRestart(params);
if (abortedRuns <= 0) return;
shutdownLog.warn(`aborted ${abortedRuns} active run(s) during restart shutdown`);
if ((await waitForRestartReplyDrain({
getPendingReplyCount: params.getPendingReplyCount,
chatAbortControllers: params.chatAbortControllers,
timeoutMs: RESTART_REPLY_POST_ABORT_DRAIN_TIMEOUT_MS,
pollMs: RESTART_REPLY_POST_ABORT_DRAIN_POLL_MS
})).drained) shutdownLog.info("restart reply drain completed after abort cleanup");
}
async function triggerGatewayLifecycleHookWithTimeout(params) {
let timeout;
const hookPromise = triggerInternalHook(params.event);
hookPromise.catch(() => void 0);
try {
const result = await Promise.race([hookPromise.then(() => "completed"), new Promise((resolve) => {
timeout = setTimeout(() => resolve("timeout"), params.timeoutMs);
timeout.unref?.();
})]);
if (result === "timeout") shutdownLog.warn(`${params.hookName} hook timed out after ${params.timeoutMs}ms; continuing shutdown`);
return result;
} finally {
if (timeout) clearTimeout(timeout);
}
}
async function disposeRuntimeWithShutdownGrace(params) {
const disposePromise = Promise.resolve().then(params.dispose).catch((err) => {
shutdownLog.warn(`${params.label} runtime disposal failed during shutdown: ${String(err)}`);
recordShutdownWarning(params.warnings, params.label);
});
const disposeTimeout = createTimeoutRace(params.graceMs, () => {
shutdownLog.warn(`${params.label} runtime disposal exceeded ${params.graceMs}ms; continuing shutdown`);
recordShutdownWarning(params.warnings, params.label);
});
await Promise.race([disposePromise, disposeTimeout.promise]);
disposeTimeout.clear();
}
async function disposeAllBundleLspRuntimesOnDemand() {
const { disposeAllBundleLspRuntimes } = await import("./agent-bundle-lsp-runtime-D46jskgq.js");
await disposeAllBundleLspRuntimes();
}
async function stopGmailWatcherOnDemand() {
const { stopGmailWatcher } = await import("./gmail-watcher-B2RJfulT.js");
await stopGmailWatcher();
}
async function runGatewayClosePrelude(params) {
params.stopDiagnostics?.();
params.clearSkillsRefreshTimer?.();
params.skillsChangeUnsub?.();
params.disposeAuthRateLimiter?.();
params.disposeBrowserAuthRateLimiter();
params.stopModelPricingRefresh?.();
params.stopChannelHealthMonitor?.();
params.stopReadinessEventLoopHealth?.();
params.clearSecretsRuntimeSnapshot?.();
await params.closeMcpServer?.().catch(() => {});
}
function isServerNotRunningError(err) {
return Boolean(err && typeof err === "object" && "code" in err && err.code === "ERR_SERVER_NOT_RUNNING");
}
async function waitForHttpClose(params) {
const timeout = createTimeoutRace(params.timeoutMs, () => false);
try {
return await Promise.race([params.closePromise.then(() => true, (err) => {
throw err;
}), timeout.promise]).catch((err) => {
const detail = err instanceof Error ? err.message : String(err);
shutdownLog.warn(`${params.label}: ${detail}`);
recordShutdownWarning(params.warnings, params.label);
return true;
});
} finally {
timeout.clear();
}
}
function createGatewayCloseHandler(params) {
return async (opts) => {
const start = Date.now();
const warnings = [];
const reason = (normalizeOptionalString(opts?.reason) ?? "") || "gateway stopping";
const restartExpectedMs = typeof opts?.restartExpectedMs === "number" && Number.isFinite(opts.restartExpectedMs) ? Math.max(0, Math.floor(opts.restartExpectedMs)) : null;
const measureCloseStep = (name, run) => measureGatewayRestartTrace(`restart.close.${name}`, run, [["reason", reason]]);
try {
shutdownLog.info(`shutdown started: ${reason}`);
await measureCloseStep("gateway-shutdown-hook", () => shutdownStep("gateway:shutdown", async () => {
if (await triggerGatewayLifecycleHookWithTimeout({
event: createInternalHookEvent("gateway", "shutdown", "gateway:shutdown", {
reason,
restartExpectedMs
}),
hookName: "gateway:shutdown",
timeoutMs: GATEWAY_SHUTDOWN_HOOK_TIMEOUT_MS
}) === "timeout") recordShutdownWarning(warnings, "gateway:shutdown");
}, warnings));
if (restartExpectedMs !== null) await measureCloseStep("gateway-pre-restart-hook", () => shutdownStep("gateway:pre-restart", async () => {
if (await triggerGatewayLifecycleHookWithTimeout({
event: createInternalHookEvent("gateway", "pre-restart", "gateway:pre-restart", {
reason,
restartExpectedMs
}),
hookName: "gateway:pre-restart",
timeoutMs: GATEWAY_PRE_RESTART_HOOK_TIMEOUT_MS
}) === "timeout") recordShutdownWarning(warnings, "gateway:pre-restart");
}, warnings));
if (restartExpectedMs !== null && params.getPendingReplyCount) {
const drainTimeoutMs = typeof opts?.drainTimeoutMs === "number" && Number.isFinite(opts.drainTimeoutMs) ? Math.max(0, Math.floor(opts.drainTimeoutMs)) : 0;
await measureCloseStep("reply-drain", () => shutdownStep("restart-reply-drain", () => drainRestartPendingRepliesForShutdown({
getPendingReplyCount: params.getPendingReplyCount,
chatAbortControllers: params.chatAbortControllers,
chatRunState: params.chatRunState,
removeChatRun: params.removeChatRun,
agentRunSeq: params.agentRunSeq,
broadcast: params.broadcast,
nodeSendToSession: params.nodeSendToSession,
timeoutMs: drainTimeoutMs,
warnings
}), warnings));
}
if (params.drainActiveSessionsForShutdown) await measureCloseStep("session-end-drain", () => shutdownStep("session-end-drain", async () => {
const drainReason = restartExpectedMs !== null ? "restart" : "shutdown";
const result = await params.drainActiveSessionsForShutdown({
reason: drainReason,
totalTimeoutMs: ACTIVE_SESSIONS_SHUTDOWN_DRAIN_TIMEOUT_MS
});
if (result.timedOut) {
shutdownLog.warn(`session-end-drain timed out after ${ACTIVE_SESSIONS_SHUTDOWN_DRAIN_TIMEOUT_MS}ms after ${result.emittedSessionIds.length} sessions; continuing shutdown`);
recordShutdownWarning(warnings, "session-end-drain");
}
}, warnings));
if (params.bonjourStop) await shutdownStep("bonjour", () => params.bonjourStop(), warnings);
if (params.tailscaleCleanup) await shutdownStep("tailscale", () => params.tailscaleCleanup(), warnings);
if (params.postReadySidecars?.length) await measureCloseStep("post-ready-sidecars", async () => {
for (const [index, sidecar] of params.postReadySidecars.entries()) await shutdownStep(`post-ready-sidecar/${index}`, () => sidecar.stop(), warnings);
});
if (params.pluginServices) await measureCloseStep("plugin-services", () => shutdownStep("plugin-services", () => params.pluginServices.stop(), warnings));
await measureCloseStep("channels", async () => {
const channelIds = params.channelIds ?? listChannelPlugins().map((plugin) => plugin.id);
for (const channelId of channelIds) await shutdownStep(`channel/${channelId}`, () => params.stopChannel(channelId), warnings);
});
await shutdownStep("agent-harnesses", () => disposeRegisteredAgentHarnesses(), warnings);
await measureCloseStep("bundle-runtimes", async () => {
await Promise.all([disposeRuntimeWithShutdownGrace({
label: "bundle-mcp",
dispose: params.disposeSessionMcpRuntimes ?? disposeAllSessionMcpRuntimes,
graceMs: MCP_RUNTIME_CLOSE_GRACE_MS,
warnings
}), disposeRuntimeWithShutdownGrace({
label: "bundle-lsp",
dispose: params.disposeBundleLspRuntimes ?? disposeAllBundleLspRuntimesOnDemand,
graceMs: LSP_RUNTIME_CLOSE_GRACE_MS,
warnings
})]);
});
await shutdownStep("plugin-state-store", () => closePluginStateSqliteStore(), warnings);
await measureCloseStep("config-reloader", () => shutdownStep("config-reloader", () => params.configReloader.stop(), warnings));
await measureCloseStep("gmail-watcher", () => shutdownStep("gmail-watcher", () => stopGmailWatcherOnDemand(), warnings));
params.cron.stop();
params.heartbeatRunner.stop();
await shutdownStep("task-registry-maintenance", () => params.stopTaskRegistryMaintenance?.(), warnings);
await shutdownStep("update-check", () => params.updateCheckStop?.(), warnings);
for (const timer of params.nodePresenceTimers.values()) clearInterval(timer);
params.nodePresenceTimers.clear();
params.broadcast("shutdown", {
reason,
restartExpectedMs
});
clearInterval(params.tickInterval);
clearInterval(params.healthInterval);
clearInterval(params.dedupeCleanup);
if (params.mediaCleanup) clearInterval(params.mediaCleanup);
if (params.agentUnsub) await shutdownStep("agent-unsub", () => params.agentUnsub(), warnings);
if (params.heartbeatUnsub) await shutdownStep("heartbeat-unsub", () => params.heartbeatUnsub(), warnings);
if (params.transcriptUnsub) await shutdownStep("transcript-unsub", () => params.transcriptUnsub(), warnings);
if (params.lifecycleUnsub) await shutdownStep("lifecycle-unsub", () => params.lifecycleUnsub(), warnings);
params.chatRunState.clear();
let clientCloseFailures = 0;
for (const c of params.clients) try {
c.socket.close(1012, "service restart");
} catch {
clientCloseFailures++;
}
if (clientCloseFailures > 0) {
shutdownLog.warn(`failed to close ${clientCloseFailures} WebSocket client(s)`);
recordShutdownWarning(warnings, "ws-clients");
}
params.clients.clear();
await measureCloseStep("websocket-server", async () => {
const wsClients = params.wss.clients ?? /* @__PURE__ */ new Set();
const closePromise = new Promise((resolve) => {
params.wss.close(() => resolve());
});
const websocketGraceTimeout = createTimeoutRace(WEBSOCKET_CLOSE_GRACE_MS, () => false);
const closedWithinGrace = await Promise.race([closePromise.then(() => true), websocketGraceTimeout.promise]);
websocketGraceTimeout.clear();
if (!closedWithinGrace) {
shutdownLog.warn(`websocket server close exceeded ${WEBSOCKET_CLOSE_GRACE_MS}ms; forcing shutdown continuation with ${wsClients.size} tracked client(s)`);
recordShutdownWarning(warnings, "websocket-server");
for (const client of wsClients) try {
client.terminate();
} catch {}
const websocketForceTimeout = createTimeoutRace(WEBSOCKET_CLOSE_FORCE_CONTINUE_MS, () => {
shutdownLog.warn(`websocket server close still pending after ${WEBSOCKET_CLOSE_FORCE_CONTINUE_MS}ms force window; continuing shutdown`);
});
await Promise.race([closePromise, websocketForceTimeout.promise]);
websocketForceTimeout.clear();
}
});
await measureCloseStep("http-server", async () => {
const servers = params.httpServers && params.httpServers.length > 0 ? params.httpServers : [params.httpServer];
for (let i = 0; i < servers.length; i++) {
const httpServer = servers[i];
const label = servers.length > 1 ? `http-server[${i}]` : "http-server";
if (typeof httpServer.closeIdleConnections === "function") httpServer.closeIdleConnections();
const closePromise = new Promise((resolve, reject) => {
httpServer.close((err) => {
if (!err || isServerNotRunningError(err)) {
resolve();
return;
}
reject(err);
});
});
closePromise.catch(() => void 0);
if (!await waitForHttpClose({
closePromise,
timeoutMs: HTTP_CLOSE_GRACE_MS,
label,
warnings
})) {
shutdownLog.warn(`${label} close exceeded ${HTTP_CLOSE_GRACE_MS}ms; forcing connection shutdown and waiting for close`);
recordShutdownWarning(warnings, label);
httpServer.closeAllConnections?.();
if (!await waitForHttpClose({
closePromise,
timeoutMs: HTTP_CLOSE_FORCE_WAIT_MS,
label,
warnings
})) throw new Error(`${label} close still pending after forced connection shutdown (${HTTP_CLOSE_FORCE_WAIT_MS}ms)`);
}
}
});
} finally {
try {
params.releasePluginRouteRegistry?.();
} catch {}
}
const durationMs = Date.now() - start;
if (warnings.length > 0) shutdownLog.warn(`shutdown completed in ${durationMs}ms with warnings: ${warnings.join(", ")}`);
else shutdownLog.info(`shutdown completed cleanly in ${durationMs}ms`);
recordGatewayRestartTrace("restart.close.total", durationMs, [
["reason", reason],
["restartExpectedMs", restartExpectedMs ?? "none"],
...collectGatewayProcessMemoryUsageMb()
]);
return {
durationMs,
warnings
};
};
}
//#endregion
export { createGatewayCloseHandler, drainActiveSessionsForShutdown, runGatewayClosePrelude };