openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
636 lines (635 loc) • 29.6 kB
JavaScript
import { t as drainGlobalSingletonLifecycleState } from "./global-singleton-Dc_stLtU.js";
import { t as createSubsystemLogger } from "./subsystem-Dy2tqXOS.js";
import { r as logVerbose } from "./globals-CTaGxEqj.js";
import { r as clearActivePluginRegistry } from "./runtime-BL4wZfTq.js";
import { i as disposeAllSessionMcpRuntimes } from "./agent-bundle-mcp-manager-api-DCHQm4w1.js";
import { t as getGlobalHookRunner } from "./hook-runner-global-0kfmMG4T.js";
import { m as closePluginStateDatabase } from "./plugin-state-store-C6hmUuuk.js";
import { t as disposeRegisteredAgentHarnesses } from "./registry-D1_C7waJ.js";
import { i as listChannelPlugins } from "./registry-Cz6cv4VC.js";
import "./plugins-Cozj1Enf.js";
import { c as WEBSOCKET_CLOSE_GRACE_MS } from "./server-constants-BrVEC7RW.js";
import { a as disposeAcpSessionManagerInstance, t as getAcpSessionManager } from "./manager-Bbp2Ogkb.js";
import { c as createAgentRunRestartAbortError } from "./run-termination-jabvTOp5.js";
import { r as captureGatewayReplyRunRestartAbort } from "./reply-run-registry.registry-BMo0d96L.js";
import "./reply-run-registry-BlmBKV23.js";
import { n as fenceSessionSuspensionWritesForGatewayShutdown } from "./session-suspension-DlBl5K-b.js";
import { n as createInternalHookEvent, u as triggerInternalHook } from "./internal-hooks-Dgwjgwuj.js";
import "./agent-bundle-mcp-tools-CejVZWe3.js";
import { s as resolveStableSessionEndTranscript } from "./session-transcript-files.fs-D0B5TSty.js";
import { a as markGatewayRestartTrace, n as collectGatewayProcessMemoryUsageMb, o as measureGatewayRestartTrace, s as recordGatewayRestartTrace } from "./restart-trace-DlhJ9XxP.js";
import { r as clearSessionTypingState } from "./session-typing-state-C5klAPZ6.js";
import { c as removeChatAbortControllerEntry, i as isChatAbortControllerEntryAbortable, t as abortChatRunById } from "./chat-abort-qLn3eFOg.js";
import { n as createChatAbortMarker } from "./server-chat-state-CwKZZaYd.js";
import { n as abortQueuedChatTurns } from "./chat-queued-turns-hwiIcr_T.js";
import { i as resolveGatewayShutdownNotice, n as createGatewayShutdownTimeout, r as recordGatewayShutdownWarning } from "./server-shutdown-Djoi94QR.js";
import { i as buildSessionEndHookPayload, n as listActiveSessionsForShutdown, t as forgetActiveSessionForShutdown } from "./active-sessions-shutdown-tracker-NctYi_BN.js";
import { cleanupSessionResources } from "@openclaw/ai/internal/runtime";
//#region src/gateway/server-run-shutdown.ts
const shutdownLog$1 = createSubsystemLogger("gateway/shutdown");
const RESTART_REPLY_DRAIN_POLL_MS = 100;
const RESTART_TERMINAL_PERSISTENCE_WAIT_TIMEOUT_MS = 1e3;
const RESTART_MARKER_SLOW_WARNING_MS = 1e3;
function getRestartReplyDrainCounts(params) {
const pendingReplyCount = params.getPendingReplyCount();
const activeRuns = listRestartDrainRuns(params.chatAbortControllers).length;
const queuedTurns = Array.from(params.chatQueuedTurns.values(), (entry) => entry.controller.signal.aborted).filter((aborted) => !aborted).length;
return {
pendingReplies: Number.isFinite(pendingReplyCount) && pendingReplyCount > 0 ? Math.floor(pendingReplyCount) : 0,
activeRuns,
queuedTurns
};
}
function listUnabortedRuns(chatAbortControllers) {
return Array.from(chatAbortControllers.entries()).filter(([, entry]) => !entry.controller.signal.aborted);
}
function listRestartDrainRuns(chatAbortControllers) {
return listUnabortedRuns(chatAbortControllers).filter(([, entry]) => entry.registrationCleanupRequested !== true);
}
function listRestartRecoveryRuns(chatAbortControllers) {
return listUnabortedRuns(chatAbortControllers).filter(([, entry]) => entry.controlUiVisible !== false && (entry.registrationCleanupRequested !== true || entry.projectSessionTerminalPersisted !== true));
}
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)`);
if (counts.queuedTurns > 0) details.push(`${counts.queuedTurns} queued turn(s)`);
return details.length > 0 ? details.join(", ") : "no pending reply work";
}
async function sleepForRestartReplyDrain(delayMs) {
await new Promise((resolve) => {
setTimeout(resolve, delayMs).unref?.();
});
}
async function waitForRestartReplyDrain(params) {
const timeoutMs = Math.max(0, Math.floor(params.timeoutMs));
let counts = getRestartReplyDrainCounts(params);
if (counts.pendingReplies <= 0 && counts.activeRuns <= 0 && counts.queuedTurns <= 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(RESTART_REPLY_DRAIN_POLL_MS, timeoutMs - elapsedMs));
counts = getRestartReplyDrainCounts(params);
if (counts.pendingReplies <= 0 && counts.activeRuns <= 0 && counts.queuedTurns <= 0) return {
drained: true,
elapsedMs: Date.now() - startedAt,
counts
};
}
}
function collectActiveRestartSessionRefs(params) {
const activeRuns = /* @__PURE__ */ new Map();
const observedAt = Date.now();
const addRun = (run) => {
activeRuns.set(`${run.runId}\u0000${run.lifecycleGeneration}`, {
...run,
observedAt: run.observedAt ?? observedAt
});
};
for (const [runId, entry] of listRestartRecoveryRuns(params.chatAbortControllers)) {
const sessionKey = entry.sessionKey.trim();
const sessionId = (entry.kind === "agent" || !sessionKey ? void 0 : params.resolveActiveSessionIdForKey?.(sessionKey)) || entry.sessionId.trim();
if (runId && entry.lifecycleGeneration && sessionKey && sessionId) addRun({
runId,
lifecycleGeneration: entry.lifecycleGeneration,
sessionKey,
sessionId,
observedAt: entry.projectSessionTerminalObservedAt
});
}
for (const candidate of params.restartRecoveryCandidates?.values() ?? []) {
const resolvedSessionId = params.resolveActiveSessionIdForKey?.(candidate.sessionKey);
addRun({
...candidate,
sessionId: resolvedSessionId || candidate.sessionId
});
}
return [...activeRuns.values()];
}
async function settleTerminalSessionPersistenceForRestart(chatAbortControllers) {
const pending = listUnabortedRuns(chatAbortControllers).flatMap(([, entry]) => {
const persistence = entry.projectSessionTerminalPersistence;
if (entry.projectSessionActive !== false || !persistence) return [];
return [{
entry,
persistence
}];
});
if (pending.length === 0) return;
const timeout = createGatewayShutdownTimeout(RESTART_TERMINAL_PERSISTENCE_WAIT_TIMEOUT_MS, () => null);
const results = await Promise.race([Promise.allSettled(pending.map(({ persistence }) => persistence)), timeout.promise]);
timeout.clear();
if (!results) {
shutdownLog$1.warn(`terminal session persistence did not settle within ${RESTART_TERMINAL_PERSISTENCE_WAIT_TIMEOUT_MS}ms; preserving restart recovery`);
return;
}
for (const [index, result] of results.entries()) {
const tracked = pending[index];
if (!tracked || tracked.entry.projectSessionTerminalPersistence !== tracked.persistence) continue;
tracked.entry.projectSessionTerminalPending = false;
tracked.entry.projectSessionTerminalPersistence = void 0;
if (result.status === "fulfilled") tracked.entry.projectSessionTerminalPersisted = true;
}
}
async function markActiveRunsForRestartRecovery(params) {
if (!params.markMainSessionsAbortedForRestart) return 0;
await settleTerminalSessionPersistenceForRestart(params.chatAbortControllers);
const activeRuns = collectActiveRestartSessionRefs(params);
const activeEntries = new Map(params.chatAbortControllers);
const recoveryCandidates = new Map(params.restartRecoveryCandidates);
const abortReplyRuns = captureGatewayReplyRunRestartAbort(params.resolveGatewayContext);
try {
const markerTimeout = createGatewayShutdownTimeout(RESTART_MARKER_SLOW_WARNING_MS, () => "timeout");
const markerOutcome = Promise.resolve(params.markMainSessionsAbortedForRestart({
resolveGatewayContext: params.resolveGatewayContext,
activeRuns,
reason: params.reason,
isActiveRun: (run) => {
const entry = params.chatAbortControllers.get(run.runId);
const candidate = params.restartRecoveryCandidates?.get(run.runId);
return entry && entry === activeEntries.get(run.runId) && !entry.controller.signal.aborted && (entry.registrationCleanupRequested !== true || entry.projectSessionTerminalPersisted !== true) && entry.lifecycleGeneration === run.lifecycleGeneration || candidate !== void 0 && candidate === recoveryCandidates.get(run.runId) && candidate.lifecycleGeneration === run.lifecycleGeneration;
}
})).then(() => ({ status: "completed" }), (error) => ({
status: "failed",
error
}));
const firstOutcome = await Promise.race([markerOutcome, markerTimeout.promise]);
markerTimeout.clear();
if (firstOutcome === "timeout") {
shutdownLog$1.warn(`restart session marker did not settle within ${RESTART_MARKER_SLOW_WARNING_MS}ms; waiting before shutdown`);
recordGatewayShutdownWarning(params.warnings, "restart-main-session-marker");
const delayedOutcome = await markerOutcome;
if (delayedOutcome.status === "failed") throw delayedOutcome.error;
} else if (firstOutcome.status === "failed") throw firstOutcome.error;
for (const run of activeRuns) if (params.restartRecoveryCandidates?.get(run.runId) === recoveryCandidates.get(run.runId)) params.restartRecoveryCandidates?.delete(run.runId);
} catch (err) {
shutdownLog$1.warn(`failed to mark active main session(s) for restart recovery: ${String(err)}`);
recordGatewayShutdownWarning(params.warnings, "restart-main-session-marker");
}
return abortReplyRuns((sessionId, error) => {
shutdownLog$1.warn(`failed to cancel reply for restart: sessionId=${sessionId} error=${String(error)}`);
recordGatewayShutdownWarning(params.warnings, "restart-reply-abort");
});
}
/** Cancels only this Gateway's exact controller registrations. */
function abortActiveRuns(params, restart) {
let aborted = 0;
for (const [runId, entry] of listUnabortedRuns(params.chatAbortControllers)) {
if (!isChatAbortControllerEntryAbortable(entry)) continue;
if (entry.projectSessionActive === false) {
entry.abortStopReason = restart ? "restart" : "rpc";
entry.controller.abort(restart ? createAgentRunRestartAbortError() : void 0);
removeChatAbortControllerEntry(params.chatAbortControllers, runId, entry);
params.chatRunState.getOrCreate(runId).abortMarker = createChatAbortMarker();
params.chatRunState.clearRun(runId);
const removed = params.removeChatRun(runId, runId, entry.sessionKey);
params.agentRunSeq.delete(runId);
if (removed?.clientRunId) params.agentRunSeq.delete(removed.clientRunId);
aborted += 1;
continue;
}
if (abortChatRunById(params, {
runId,
sessionKey: entry.sessionKey,
stopReason: restart ? "restart" : "rpc"
}).aborted) aborted += 1;
}
return aborted;
}
/** Abort queued owners before active teardown can promote them into the closing runtime. */
function abortQueuedTurns(params, restart) {
const matches = Array.from(params.chatQueuedTurns, ([runId, entry]) => ({
runId,
entry
}));
return abortQueuedChatTurns(params.chatQueuedTurns, matches, restart ? "restart" : void 0).length;
}
/** Completes grace and requests cancellation before execution joining begins. */
async function prepareGatewayRunShutdown(params) {
if (!params.restart) {
abortQueuedTurns(params, false);
abortActiveRuns(params, false);
return;
}
const initialCounts = getRestartReplyDrainCounts(params);
let drainResult;
if (initialCounts.pendingReplies > 0 || initialCounts.activeRuns > 0 || initialCounts.queuedTurns > 0) {
const timeoutMs = Math.max(0, Math.floor(params.timeoutMs));
if (timeoutMs > 0) shutdownLog$1.info(`waiting for ${formatRestartReplyDrainDetails(initialCounts)} before restart shutdown (timeout ${timeoutMs}ms)`);
drainResult = await waitForRestartReplyDrain({
getPendingReplyCount: params.getPendingReplyCount,
chatAbortControllers: params.chatAbortControllers,
chatQueuedTurns: params.chatQueuedTurns,
timeoutMs
});
if (!drainResult.drained) {
shutdownLog$1.warn(`restart reply drain timed out after ${drainResult.elapsedMs}ms with ${formatRestartReplyDrainDetails(drainResult.counts)} still active; continuing shutdown`);
recordGatewayShutdownWarning(params.warnings, "restart-reply-drain");
}
}
const abortedQueuedTurns = abortQueuedTurns(params, true);
if (drainResult?.drained === false && abortedQueuedTurns > 0) shutdownLog$1.warn(`aborted ${abortedQueuedTurns} queued turn(s) during restart shutdown`);
const abortedReplies = await markActiveRunsForRestartRecovery({
...params,
reason: "gateway restart shutdown"
});
const abortedRuns = abortActiveRuns(params, true) + abortedReplies;
if (drainResult?.drained) shutdownLog$1.info(`restart reply drain completed after ${drainResult.elapsedMs}ms`);
else if (drainResult && abortedRuns > 0) shutdownLog$1.warn(`aborted ${abortedRuns} active run(s) during restart shutdown`);
}
//#endregion
//#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_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 EMBEDDING_PROVIDER_CLOSE_GRACE_MS = 5e3;
const AGENT_HARNESS_CLOSE_GRACE_MS = 5e3;
function createCloseStepTimer(reason) {
return (name, run) => {
markGatewayRestartTrace(`restart.close.${name}.begin`);
return measureGatewayRestartTrace(`restart.close.${name}`, run, [["reason", reason]]);
};
}
/** 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}`);
recordGatewayShutdownWarning(warnings, name);
return false;
}
}
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)}`);
recordGatewayShutdownWarning(params.warnings, params.label);
});
const disposeTimeout = createGatewayShutdownTimeout(params.graceMs, () => {
shutdownLog.warn(`${params.label} runtime disposal exceeded ${params.graceMs}ms; continuing shutdown`);
recordGatewayShutdownWarning(params.warnings, params.label);
});
await Promise.race([disposePromise, disposeTimeout.promise]);
disposeTimeout.clear();
}
async function runGatewayClosePrelude(params) {
params.stopDiagnostics?.();
params.clearSkillsRefreshTimer?.();
await params.skillsChangeUnsub?.();
params.disposeAuthRateLimiter?.();
params.disposeBrowserAuthRateLimiter();
await params.stopChannelHealthMonitor?.();
params.stopReadinessEventLoopHealth?.();
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 = createGatewayShutdownTimeout(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}`);
recordGatewayShutdownWarning(params.warnings, params.label);
return true;
});
} finally {
timeout.clear();
}
}
async function closeHttpListener(params) {
const { server, label, warnings } = params;
server.closeIdleConnections?.();
const closePromise = new Promise((resolve, reject) => {
server.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
})) return;
shutdownLog.warn(`${label} close exceeded ${HTTP_CLOSE_GRACE_MS}ms; forcing connection shutdown and waiting for close`);
recordGatewayShutdownWarning(warnings, label);
server.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)`);
}
async function prepareGatewayClose(params, opts) {
const start = Date.now();
const warnings = [];
const notice = resolveGatewayShutdownNotice(opts);
const { reason } = notice;
const restartExpectedMs = notice.restartExpectedMs ?? null;
const measureCloseStep = createCloseStepTimer(reason);
fenceSessionSuspensionWritesForGatewayShutdown();
shutdownLog.debug(`shutdown started: ${reason}`);
await shutdownStep("update-check", () => params.updateCheckStop?.(), warnings);
await measureCloseStep("config-reloader", () => shutdownStep("config-reloader", () => params.configReloader.stop(), warnings));
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") recordGatewayShutdownWarning(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") recordGatewayShutdownWarning(warnings, "gateway:pre-restart");
}, warnings));
const drainTimeoutMs = typeof opts?.drainTimeoutMs === "number" && Number.isFinite(opts.drainTimeoutMs) ? Math.max(0, Math.floor(opts.drainTimeoutMs)) : 0;
await measureCloseStep("reply-drain", () => prepareGatewayRunShutdown({
...params,
restart: restartExpectedMs !== null,
timeoutMs: drainTimeoutMs,
warnings
}));
return {
start,
notice,
warnings
};
}
async function completeGatewayClose(params, preparation) {
const { start, notice, warnings } = preparation;
const { reason } = notice;
const restartExpectedMs = notice.restartExpectedMs ?? null;
let pluginServicesCleanup;
let mediaCleanupStopResult = "timed-out";
const measureCloseStep = createCloseStepTimer(reason);
try {
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`);
recordGatewayShutdownWarning(warnings, "session-end-drain");
}
}, warnings));
if (params.bonjourStop) await shutdownStep("bonjour", () => params.bonjourStop(), warnings);
await measureCloseStep("acp-session-manager", () => shutdownStep("acp-session-manager", () => disposeAcpSessionManagerInstance(getAcpSessionManager(), "gateway-shutdown"), warnings));
if (params.pluginServices) {
const cleanup = Promise.resolve().then(() => params.pluginServices.stop());
pluginServicesCleanup = cleanup;
await measureCloseStep("plugin-services", () => disposeRuntimeWithShutdownGrace({
label: "plugin-services",
dispose: () => cleanup,
graceMs: MCP_RUNTIME_CLOSE_GRACE_MS,
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("code-mode-runs", () => params.disposeAllCodeModeRuns(), warnings);
await disposeRuntimeWithShutdownGrace({
label: "agent-harnesses",
dispose: disposeRegisteredAgentHarnesses,
graceMs: AGENT_HARNESS_CLOSE_GRACE_MS,
warnings
});
await shutdownStep("ai-session-resources", () => cleanupSessionResources(), warnings);
await shutdownStep("provider-transport-dispatchers", () => params.closeProviderTransportDispatcherPool(), 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 ?? params.disposeAllBundleLspRuntimes,
graceMs: LSP_RUNTIME_CLOSE_GRACE_MS,
warnings
})]);
});
try {
mediaCleanupStopResult = await params.stopMediaCleanup();
} catch (err) {
shutdownLog.warn(`media-cleanup: ${err instanceof Error ? err.message : String(err)}`);
recordGatewayShutdownWarning(warnings, "media-cleanup");
}
if (mediaCleanupStopResult !== "drained") recordGatewayShutdownWarning(warnings, "media-cleanup");
await measureCloseStep("gmail-watcher", () => shutdownStep("gmail-watcher", () => params.stopGmailWatcher(), warnings));
await shutdownStep("cron", () => params.cron.stopAndDrain ? params.cron.stopAndDrain() : params.cron.stop(), warnings);
await shutdownStep("heartbeat-runner", () => params.heartbeatRunner.stop(), warnings);
await shutdownStep("task-registry-maintenance", () => params.stopTaskRegistryMaintenance?.(), warnings);
for (const timer of params.nodePresenceTimers.values()) clearInterval(timer);
params.nodePresenceTimers.clear();
if (params.maintenance) {
clearInterval(params.maintenance.tickInterval);
clearInterval(params.maintenance.healthInterval);
clearInterval(params.maintenance.dedupeCleanup);
clearInterval(params.maintenance.worktreeCleanup);
params.maintenance.skillUsageCleanup();
}
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);
if (params.taskUnsub) await shutdownStep("task-unsub", () => params.taskUnsub(), warnings);
params.chatRunState.clear();
let clientCloseFailures = 0;
for (const c of params.clients) try {
c.socket.close(1012, c.connectionKind === "worker" ? "gateway-shutdown" : "service restart");
} catch {
clientCloseFailures++;
}
if (clientCloseFailures > 0) {
shutdownLog.warn(`failed to close ${clientCloseFailures} WebSocket client(s)`);
recordGatewayShutdownWarning(warnings, "ws-clients");
}
params.clients.clear();
if (params.wss) await measureCloseStep("websocket-server", async () => {
const wsClients = params.wss?.clients ?? /* @__PURE__ */ new Set();
const closePromise = new Promise((resolve) => {
params.wss?.close(() => resolve());
});
const websocketGraceTimeout = createGatewayShutdownTimeout(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)`);
recordGatewayShutdownWarning(warnings, "websocket-server");
for (const client of wsClients) try {
client.terminate();
} catch {}
const websocketForceTimeout = createGatewayShutdownTimeout(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 params.finishRequestEntries?.();
clearSessionTypingState();
const transportServers = params.httpServers && params.httpServers.length > 0 ? params.httpServers : params.httpServer ? [params.httpServer] : [];
try {
if (transportServers.length > 0) await measureCloseStep("http-server", async () => {
const failure = (await Promise.allSettled(transportServers.map((server, index) => closeHttpListener({
server,
label: transportServers.length > 1 ? `http-server[${index}]` : "http-server",
warnings
})))).find((result) => result.status === "rejected");
if (failure) throw failure.reason;
});
} finally {
if (params.tailscaleCleanup) await shutdownStep("tailscale", () => params.tailscaleCleanup(), warnings);
}
await disposeRuntimeWithShutdownGrace({
label: "embedding-providers",
dispose: params.drainRetainedOpenAiEmbeddingProviders,
graceMs: EMBEDDING_PROVIDER_CLOSE_GRACE_MS,
warnings
});
} finally {
await pluginServicesCleanup?.catch(() => {});
await params.finishRequestEntries?.();
if (mediaCleanupStopResult === "drained") await shutdownStep("plugin-state-store", () => closePluginStateDatabase(), warnings);
await shutdownStep("plugin-host-registry", clearActivePluginRegistry, warnings);
try {
await drainGlobalSingletonLifecycleState(restartExpectedMs === null ? "close" : "restart");
} finally {
try {
params.clearSecretsRuntimeSnapshot?.();
} 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
//#region src/gateway/active-sessions-shutdown-drain.ts
async function drainActiveSessionsForShutdown(params) {
const tracked = listActiveSessionsForShutdown();
if (tracked.length === 0) return {
emittedSessionIds: [],
timedOut: false
};
const totalTimeoutMs = Math.max(100, Math.floor(params.totalTimeoutMs ?? 2e3));
const emittedSessionIds = [];
const hookRunner = getGlobalHookRunner();
let settledEmissions = 0;
const drain = Promise.allSettled(tracked.map(async (entry) => {
try {
forgetActiveSessionForShutdown(entry.sessionId);
emittedSessionIds.push(entry.sessionId);
if (!hookRunner?.hasHooks("session_end")) return;
const transcript = resolveStableSessionEndTranscript({
sessionId: entry.sessionId,
storePath: entry.storePath,
sessionFile: entry.sessionFile,
agentId: entry.agentId
});
const payload = buildSessionEndHookPayload({
sessionId: entry.sessionId,
sessionKey: entry.sessionKey,
agentId: entry.agentId,
reason: params.reason,
sessionFile: transcript.sessionFile,
transcriptArchived: transcript.transcriptArchived
});
await hookRunner.runSessionEnd(payload.event, payload.context);
} catch (err) {
logVerbose(`session_end hook failed during shutdown drain: ${String(err)}`);
} finally {
settledEmissions++;
}
}));
let timer;
const timeout = new Promise((resolve) => {
timer = setTimeout(() => resolve("timeout"), totalTimeoutMs);
timer.unref?.();
});
try {
if (await Promise.race([drain.then(() => "ok"), timeout]) === "timeout") {
logVerbose(`shutdown session-end drain timed out after ${totalTimeoutMs}ms with ${tracked.length - settledEmissions} session_end handler(s) still pending`);
return {
emittedSessionIds,
timedOut: true
};
}
return {
emittedSessionIds,
timedOut: false
};
} finally {
if (timer) clearTimeout(timer);
}
}
//#endregion
export { completeGatewayClose, drainActiveSessionsForShutdown, prepareGatewayClose, runGatewayClosePrelude };