mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
fix(gateway): keep shutdown reliable during updates (#124582)
* fix(gateway): resolve shutdown dependencies before stopping * fix(gateway): isolate prepared shutdown runtimes
This commit is contained in:
committed by
GitHub
parent
cb65a14c46
commit
45572dbf36
@@ -205,8 +205,9 @@ function createLazyBrowserPluginService(): OpenClawPluginService {
|
||||
let service: OpenClawPluginService | null = null;
|
||||
const loadService = async () => {
|
||||
if (!service) {
|
||||
const { createBrowserPluginService } = await loadBrowserRegistrationRuntimeModule();
|
||||
service = createBrowserPluginService();
|
||||
const { createBrowserPluginService, stopBrowserControlService } =
|
||||
await loadBrowserRegistrationRuntimeModule();
|
||||
service = createBrowserPluginService({ stopOnDemand: stopBrowserControlService });
|
||||
}
|
||||
return service;
|
||||
};
|
||||
@@ -221,7 +222,11 @@ function createLazyBrowserPluginService(): OpenClawPluginService {
|
||||
},
|
||||
stop: async (ctx) => {
|
||||
if (!service) {
|
||||
const { stopBrowserControlService } = await import("./src/control-service.js");
|
||||
const loadedRuntime = loadBrowserRegistrationRuntimeModule.peek();
|
||||
if (!loadedRuntime) {
|
||||
return;
|
||||
}
|
||||
const { stopBrowserControlService } = await loadedRuntime;
|
||||
await stopBrowserControlService();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -7,4 +7,5 @@ export { ensureBrowserProxyUploadCleanup } from "./src/browser-proxy-upload.js";
|
||||
export { handleBrowserGatewayRequest } from "./src/gateway/browser-request.js";
|
||||
export { runBrowserProxyCommand } from "./src/node-host/invoke-browser.js";
|
||||
export { createBrowserPluginService } from "./src/plugin-service.js";
|
||||
export { stopBrowserControlService } from "./src/control-service.js";
|
||||
export { collectBrowserSecurityAuditFindings } from "./src/security-audit.js";
|
||||
|
||||
@@ -52,8 +52,11 @@ describe("createBrowserPluginService", () => {
|
||||
return { validateOverrideSpecifier: params.validateOverrideSpecifier };
|
||||
}
|
||||
|
||||
const createService = () =>
|
||||
createBrowserPluginService({ stopOnDemand: runtimeMocks.stopBrowserControlService });
|
||||
|
||||
it("does not start the control server during gateway startup by default", async () => {
|
||||
const service = createBrowserPluginService();
|
||||
const service = createService();
|
||||
|
||||
await service.start(SERVICE_CONTEXT);
|
||||
|
||||
@@ -63,7 +66,7 @@ describe("createBrowserPluginService", () => {
|
||||
for (const value of ["0", "", "disabled"]) {
|
||||
it(`does not start the control server for eager env value ${JSON.stringify(value)}`, async () => {
|
||||
vi.stubEnv("OPENCLAW_EAGER_BROWSER_CONTROL_SERVER", value);
|
||||
const service = createBrowserPluginService();
|
||||
const service = createService();
|
||||
|
||||
await service.start(SERVICE_CONTEXT);
|
||||
|
||||
@@ -73,7 +76,7 @@ describe("createBrowserPluginService", () => {
|
||||
|
||||
it("passes a browser override validator to the eager service loader", async () => {
|
||||
vi.stubEnv("OPENCLAW_EAGER_BROWSER_CONTROL_SERVER", "1");
|
||||
const service = createBrowserPluginService();
|
||||
const service = createService();
|
||||
|
||||
await service.start(SERVICE_CONTEXT);
|
||||
|
||||
@@ -83,7 +86,7 @@ describe("createBrowserPluginService", () => {
|
||||
|
||||
it("rejects unsafe browser override specifiers", async () => {
|
||||
vi.stubEnv("OPENCLAW_EAGER_BROWSER_CONTROL_SERVER", "1");
|
||||
const service = createBrowserPluginService();
|
||||
const service = createService();
|
||||
|
||||
await service.start(SERVICE_CONTEXT);
|
||||
|
||||
@@ -100,7 +103,7 @@ describe("createBrowserPluginService", () => {
|
||||
});
|
||||
|
||||
it("stops an on-demand browser runtime even when startup stayed lazy", async () => {
|
||||
const service = createBrowserPluginService();
|
||||
const service = createService();
|
||||
|
||||
await service.stop?.(SERVICE_CONTEXT);
|
||||
|
||||
@@ -109,7 +112,7 @@ describe("createBrowserPluginService", () => {
|
||||
|
||||
it("propagates on-demand cleanup failures", async () => {
|
||||
runtimeMocks.stopBrowserControlService.mockRejectedValueOnce(new Error("cleanup failed"));
|
||||
const service = createBrowserPluginService();
|
||||
const service = createService();
|
||||
|
||||
await expect(service.stop?.(SERVICE_CONTEXT)).rejects.toThrow("cleanup failed");
|
||||
});
|
||||
@@ -121,7 +124,7 @@ describe("createBrowserPluginService", () => {
|
||||
.mockRejectedValueOnce(new Error("loaded cleanup failed"))
|
||||
.mockResolvedValue(undefined);
|
||||
runtimeMocks.startLazyPluginServiceModule.mockResolvedValue({ stop } as never);
|
||||
const service = createBrowserPluginService();
|
||||
const service = createService();
|
||||
await service.start(SERVICE_CONTEXT);
|
||||
|
||||
await expect(service.stop?.(SERVICE_CONTEXT)).rejects.toThrow("loaded cleanup failed");
|
||||
|
||||
@@ -21,7 +21,9 @@ function validateBrowserControlOverrideSpecifier(specifier: string): string {
|
||||
}
|
||||
|
||||
/** Creates the Browser plugin service registered by the plugin entrypoint. */
|
||||
export function createBrowserPluginService(): OpenClawPluginService {
|
||||
export function createBrowserPluginService(params: {
|
||||
stopOnDemand: () => Promise<void>;
|
||||
}): OpenClawPluginService {
|
||||
let handle: BrowserControlHandle = null;
|
||||
|
||||
return {
|
||||
@@ -55,8 +57,7 @@ export function createBrowserPluginService(): OpenClawPluginService {
|
||||
}
|
||||
return;
|
||||
}
|
||||
const { stopBrowserControlService } = await import("./control-service.js");
|
||||
await stopBrowserControlService();
|
||||
await params.stopOnDemand();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -529,6 +529,27 @@ describe("runGatewayLoop", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("names and canonically formats a gateway close failure", async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
await withIsolatedSignals(async ({ captureSignal }) => {
|
||||
const close = vi.fn<GatewayCloseFn>(async () => {
|
||||
throw new TypeError("close owner failed");
|
||||
});
|
||||
const { start, started } = createSignaledStart(close);
|
||||
const { runtime, exited } = createRuntimeWithExitSignal();
|
||||
await runLoopWithStart({ start, runtime });
|
||||
await waitForStart(started);
|
||||
|
||||
captureSignal("SIGTERM")();
|
||||
|
||||
await expect(exited).resolves.toBe(0);
|
||||
expect(gatewayLog.error).toHaveBeenCalledWith(
|
||||
"shutdown step failed (gateway server close): close owner failed",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["SIGTERM", "SIGINT"] as const)(
|
||||
"drains admitted root work before closing on %s",
|
||||
async (signal) => {
|
||||
|
||||
@@ -790,7 +790,7 @@ export async function runGatewayLoop(params: {
|
||||
...(closeDrainTimeoutMs !== null ? { drainTimeoutMs: closeDrainTimeoutMs } : {}),
|
||||
});
|
||||
} catch (err) {
|
||||
gatewayLog.error(`shutdown error: ${String(err)}`);
|
||||
gatewayLog.error(`shutdown step failed (gateway server close): ${formatErrorMessage(err)}`);
|
||||
} finally {
|
||||
server = null;
|
||||
if (isRestart) {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { buildSessionEndHookPayload } from "../auto-reply/reply/session-hooks.js";
|
||||
import { logVerbose } from "../globals.js";
|
||||
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
|
||||
import {
|
||||
forgetActiveSessionForShutdown,
|
||||
listActiveSessionsForShutdown,
|
||||
} from "./active-sessions-shutdown-tracker.js";
|
||||
import { resolveStableSessionEndTranscript } from "./session-transcript-files.fs.js";
|
||||
|
||||
export async function drainActiveSessionsForShutdown(params: {
|
||||
reason: "shutdown" | "restart";
|
||||
totalTimeoutMs?: number;
|
||||
}): Promise<{ emittedSessionIds: string[]; timedOut: boolean }> {
|
||||
const tracked = listActiveSessionsForShutdown();
|
||||
if (tracked.length === 0) {
|
||||
return { emittedSessionIds: [], timedOut: false };
|
||||
}
|
||||
const totalTimeoutMs = Math.max(100, Math.floor(params.totalTimeoutMs ?? 2_000));
|
||||
const emittedSessionIds: string[] = [];
|
||||
const hookRunner = getGlobalHookRunner();
|
||||
let settledEmissions = 0;
|
||||
// Start all emissions before the bounded aggregate so one slow plugin cannot
|
||||
// prevent later tracked sessions from receiving session_end.
|
||||
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: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeout = new Promise<"timeout">((resolve) => {
|
||||
timer = setTimeout(() => resolve("timeout"), totalTimeoutMs);
|
||||
timer.unref?.();
|
||||
});
|
||||
try {
|
||||
const result = await Promise.race([drain.then(() => "ok" as const), timeout]);
|
||||
if (result === "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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,11 +48,9 @@ vi.mock("../auto-reply/reply/session-hooks.js", () => ({
|
||||
buildSessionStartHookPayload: vi.fn(() => ({ event: {}, context: {} })),
|
||||
}));
|
||||
|
||||
const {
|
||||
drainActiveSessionsForShutdown,
|
||||
emitGatewaySessionEndPluginHook,
|
||||
emitGatewaySessionStartPluginHook,
|
||||
} = await import("./session-reset-service.js");
|
||||
const { emitGatewaySessionEndPluginHook, emitGatewaySessionStartPluginHook } =
|
||||
await import("./session-reset-service.js");
|
||||
const { drainActiveSessionsForShutdown } = await import("./active-sessions-shutdown-drain.js");
|
||||
const { forgetActiveSessionForShutdown, listActiveSessionsForShutdown } =
|
||||
await import("./active-sessions-shutdown-tracker.js");
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
// Runtime close barrel keeps shutdown imports narrow for lazy server paths.
|
||||
export * from "./server-close.js";
|
||||
export { drainActiveSessionsForShutdown } from "./session-reset-service.js";
|
||||
export { drainActiveSessionsForShutdown } from "./active-sessions-shutdown-drain.js";
|
||||
|
||||
@@ -27,6 +27,7 @@ const mocks = vi.hoisted(() => ({
|
||||
triggerInternalHook: vi.fn<TriggerInternalHookMock>(async (_eventValue) => undefined),
|
||||
disposeAllBundleLspRuntimes: vi.fn(async () => undefined),
|
||||
drainRetainedEmbeddingProviders: vi.fn(async () => undefined),
|
||||
stopGmailWatcher: vi.fn(async () => undefined),
|
||||
disposeAcpSessionManagerInstance: vi.fn(async () => undefined),
|
||||
getAcpSessionManager: vi.fn(() => ({})),
|
||||
fenceSessionSuspensionWritesForGatewayShutdown: vi.fn(),
|
||||
@@ -47,7 +48,7 @@ vi.mock("../channels/plugins/index.js", async () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/gmail-watcher.js", () => ({
|
||||
stopGmailWatcher: vi.fn(async () => undefined),
|
||||
stopGmailWatcher: mocks.stopGmailWatcher,
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/internal-hooks.js", async () => {
|
||||
@@ -151,6 +152,11 @@ function createGatewayCloseTestDeps(
|
||||
tailscaleCleanup: null,
|
||||
stopChannel: vi.fn(async () => undefined),
|
||||
pluginServices: null,
|
||||
disposeAllBundleLspRuntimes: mocks.disposeAllBundleLspRuntimes,
|
||||
drainRetainedOpenAiEmbeddingProviders: mocks.drainRetainedEmbeddingProviders,
|
||||
stopGmailWatcher: mocks.stopGmailWatcher,
|
||||
disposeAllCodeModeRuns: mocks.disposeAllCodeModeRuns,
|
||||
closeProviderTransportDispatcherPool: mocks.closeProviderTransportDispatcherPool,
|
||||
cron: { stop: vi.fn() },
|
||||
heartbeatRunner: { stop: vi.fn() } as never,
|
||||
updateCheckStop: null,
|
||||
@@ -209,6 +215,10 @@ describe("createGatewayCloseHandler", () => {
|
||||
mocks.disposeAllBundleLspRuntimes.mockResolvedValue(undefined);
|
||||
mocks.drainRetainedEmbeddingProviders.mockClear();
|
||||
mocks.drainRetainedEmbeddingProviders.mockResolvedValue(undefined);
|
||||
mocks.stopGmailWatcher.mockClear();
|
||||
mocks.stopGmailWatcher.mockResolvedValue(undefined);
|
||||
mocks.closeProviderTransportDispatcherPool.mockClear();
|
||||
mocks.closeProviderTransportDispatcherPool.mockResolvedValue(undefined);
|
||||
mocks.disposeAcpSessionManagerInstance.mockReset();
|
||||
mocks.disposeAcpSessionManagerInstance.mockResolvedValue(undefined);
|
||||
mocks.getAcpSessionManager.mockClear();
|
||||
|
||||
+11
-34
@@ -57,7 +57,7 @@ const RESTART_REPLY_POST_ABORT_DRAIN_POLL_MS = 50;
|
||||
const RESTART_TERMINAL_PERSISTENCE_WAIT_TIMEOUT_MS = 1_000;
|
||||
const RESTART_MARKER_SLOW_WARNING_MS = 1_000;
|
||||
|
||||
export type ShutdownResult = {
|
||||
type ShutdownResult = {
|
||||
durationMs: number;
|
||||
warnings: string[];
|
||||
};
|
||||
@@ -603,21 +603,6 @@ async function disposeRuntimeWithShutdownGrace(params: {
|
||||
disposeTimeout.clear();
|
||||
}
|
||||
|
||||
async function disposeAllBundleLspRuntimesOnDemand(): Promise<void> {
|
||||
const { disposeAllBundleLspRuntimes } = await import("../agents/agent-bundle-lsp-runtime.js");
|
||||
await disposeAllBundleLspRuntimes();
|
||||
}
|
||||
|
||||
async function drainRetainedEmbeddingProvidersOnDemand(): Promise<void> {
|
||||
const { drainRetainedOpenAiEmbeddingProviders } = await import("./embeddings-http.js");
|
||||
await drainRetainedOpenAiEmbeddingProviders();
|
||||
}
|
||||
|
||||
async function stopGmailWatcherOnDemand(): Promise<void> {
|
||||
const { stopGmailWatcher } = await import("../hooks/gmail-watcher.js");
|
||||
await stopGmailWatcher();
|
||||
}
|
||||
|
||||
export async function runGatewayClosePrelude(params: {
|
||||
stopDiagnostics?: () => void;
|
||||
clearSkillsRefreshTimer?: () => void;
|
||||
@@ -685,6 +670,11 @@ export function createGatewayCloseHandler(
|
||||
postReadySidecars?: readonly GatewayPostReadySidecarHandle[];
|
||||
disposeSessionMcpRuntimes?: () => Promise<void>;
|
||||
disposeBundleLspRuntimes?: () => Promise<void>;
|
||||
disposeAllBundleLspRuntimes: () => Promise<void>;
|
||||
drainRetainedOpenAiEmbeddingProviders: () => Promise<void>;
|
||||
stopGmailWatcher: () => Promise<void>;
|
||||
disposeAllCodeModeRuns: () => Promise<void> | void;
|
||||
closeProviderTransportDispatcherPool: () => Promise<void>;
|
||||
cron: { stop: () => void; stopAndDrain?: () => Promise<void> };
|
||||
heartbeatRunner: HeartbeatRunner;
|
||||
updateCheckStop?: (() => void) | null;
|
||||
@@ -882,25 +872,12 @@ export function createGatewayCloseHandler(
|
||||
await shutdownStep(`channel/${channelId}`, () => params.stopChannel(channelId), warnings);
|
||||
}
|
||||
});
|
||||
// Load the bridge only at shutdown; eager imports boot the subagent registry at startup.
|
||||
// Cancel parked calls before their agent harnesses and MCP transports disappear.
|
||||
await shutdownStep(
|
||||
"code-mode-runs",
|
||||
async () => {
|
||||
const { disposeAllCodeModeRuns } = await import("../agents/code-mode-state.js");
|
||||
return disposeAllCodeModeRuns();
|
||||
},
|
||||
warnings,
|
||||
);
|
||||
await shutdownStep("code-mode-runs", () => params.disposeAllCodeModeRuns(), warnings);
|
||||
await shutdownStep("agent-harnesses", () => disposeRegisteredAgentHarnesses(), warnings);
|
||||
await shutdownStep("ai-session-resources", () => cleanupSessionResources(), warnings);
|
||||
await shutdownStep(
|
||||
"provider-transport-dispatchers",
|
||||
async () => {
|
||||
const { closeProviderTransportDispatcherPool } =
|
||||
await import("../agents/provider-transport-dispatcher-pool.js");
|
||||
await closeProviderTransportDispatcherPool();
|
||||
},
|
||||
() => params.closeProviderTransportDispatcherPool(),
|
||||
warnings,
|
||||
);
|
||||
await measureCloseStep("bundle-runtimes", async () => {
|
||||
@@ -913,7 +890,7 @@ export function createGatewayCloseHandler(
|
||||
}),
|
||||
disposeRuntimeWithShutdownGrace({
|
||||
label: "bundle-lsp",
|
||||
dispose: params.disposeBundleLspRuntimes ?? disposeAllBundleLspRuntimesOnDemand,
|
||||
dispose: params.disposeBundleLspRuntimes ?? params.disposeAllBundleLspRuntimes,
|
||||
graceMs: LSP_RUNTIME_CLOSE_GRACE_MS,
|
||||
warnings,
|
||||
}),
|
||||
@@ -934,7 +911,7 @@ export function createGatewayCloseHandler(
|
||||
recordShutdownWarning(warnings, "media-cleanup");
|
||||
}
|
||||
await measureCloseStep("gmail-watcher", () =>
|
||||
shutdownStep("gmail-watcher", () => stopGmailWatcherOnDemand(), warnings),
|
||||
shutdownStep("gmail-watcher", () => params.stopGmailWatcher(), warnings),
|
||||
);
|
||||
await shutdownStep(
|
||||
"cron",
|
||||
@@ -1093,7 +1070,7 @@ export function createGatewayCloseHandler(
|
||||
}
|
||||
await disposeRuntimeWithShutdownGrace({
|
||||
label: "embedding-providers",
|
||||
dispose: drainRetainedEmbeddingProvidersOnDemand,
|
||||
dispose: params.drainRetainedOpenAiEmbeddingProviders,
|
||||
graceMs: EMBEDDING_PROVIDER_CLOSE_GRACE_MS,
|
||||
warnings,
|
||||
});
|
||||
|
||||
@@ -151,6 +151,7 @@ export async function startGatewayCoreRuntime(input: {
|
||||
broadcastPluginEvent,
|
||||
activateRuntimeSecrets,
|
||||
residentRegistry,
|
||||
shutdownRuntime,
|
||||
} = runtime;
|
||||
if (desktopSessionRegistry) {
|
||||
kernel.addGatewayLifetimeSidecar({ stop: () => desktopSessionRegistry.stopAll() });
|
||||
@@ -234,8 +235,7 @@ export async function startGatewayCoreRuntime(input: {
|
||||
stop: async () => {
|
||||
const earlyRuntime = await startEarlyRuntime();
|
||||
earlyRuntime.skillsChangeUnsub();
|
||||
const { stopTaskRegistryMaintenance } = await import("../tasks/task-registry.maintenance.js");
|
||||
stopTaskRegistryMaintenance();
|
||||
shutdownRuntime.stopTaskRegistryMaintenance();
|
||||
},
|
||||
});
|
||||
const earlyRuntime = await startupTrace.measure("runtime.early", () =>
|
||||
|
||||
@@ -88,6 +88,7 @@ function readServerImplementation(): string {
|
||||
return [
|
||||
"src/gateway/server-start.ts",
|
||||
"src/gateway/server-kernel.ts",
|
||||
"src/gateway/server-shutdown.runtime.ts",
|
||||
"src/gateway/server-startup-bootstrap.ts",
|
||||
"src/gateway/server-runtime-state-prepare.ts",
|
||||
"src/gateway/server-lifecycle.ts",
|
||||
@@ -99,6 +100,14 @@ function readServerImplementation(): string {
|
||||
}
|
||||
|
||||
describe("gateway startup import boundaries", () => {
|
||||
it("keeps ordinary session lifecycle code out of the prepared shutdown graph", () => {
|
||||
const graph = collectStaticValueImportGraph("src/gateway/server-close.runtime.ts");
|
||||
|
||||
expect([...graph.keys()].map((filePath) => path.relative(repoRoot, filePath))).not.toContain(
|
||||
"src/gateway/session-reset-service.ts",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the kernel static import graph free of HTTP server and WebSocket construction", () => {
|
||||
const graph = collectStaticValueImportGraph("src/gateway/server-kernel.ts");
|
||||
const violations: string[] = [];
|
||||
@@ -240,9 +249,9 @@ describe("gateway startup import boundaries", () => {
|
||||
it("fences config reload before gateway teardown and gateway_stop hooks", () => {
|
||||
const serverImpl = readServerImplementation();
|
||||
const closeStart = /close:\s*async\s*\([^)]*\)\s*=>/u.exec(serverImpl)?.index ?? -1;
|
||||
const hookStart = serverImpl.indexOf("runGlobalGatewayStopSafely", closeStart);
|
||||
const reloadStopStart = serverImpl.indexOf("await beginClosePrelude();", closeStart);
|
||||
const terminalStopStart = serverImpl.indexOf("terminalSessions.disposeAll();", closeStart);
|
||||
const hookStart = serverImpl.indexOf('name: "gateway_stop plugin hooks"', closeStart);
|
||||
const reloadStopStart = serverImpl.indexOf('name: "close prelude fence"', closeStart);
|
||||
const terminalStopStart = serverImpl.indexOf('name: "terminal sessions"', closeStart);
|
||||
const markHelperStart = serverImpl.indexOf("const markClosePreludeStarted = () => {");
|
||||
const markHelperEnd = serverImpl.indexOf("};", markHelperStart);
|
||||
const beginHelperStart = serverImpl.indexOf("const beginClosePrelude = async () => {");
|
||||
@@ -255,6 +264,7 @@ describe("gateway startup import boundaries", () => {
|
||||
expect(reloadStopStart).toBeGreaterThan(closeStart);
|
||||
expect(reloadStopStart).toBeLessThan(terminalStopStart);
|
||||
expect(reloadStopStart).toBeLessThan(hookStart);
|
||||
expect(serverImpl.slice(closeStart, hookStart)).not.toContain("await import(");
|
||||
expect(markHelperStart).toBeGreaterThan(-1);
|
||||
expect(serverImpl.slice(markHelperStart, markHelperEnd)).toContain(
|
||||
"clearPostReadyMaintenanceTimer();",
|
||||
|
||||
@@ -103,6 +103,7 @@ export async function prepareGatewayKernelRequestRuntime(params: {
|
||||
startupState,
|
||||
clearFallbackGatewayContextForServer,
|
||||
kernel,
|
||||
shutdownRuntime,
|
||||
} = runtime;
|
||||
const chatMetadataLifecycle = await createGatewayChatMetadataLifecycle({
|
||||
getConfig: getRuntimeConfig,
|
||||
@@ -221,6 +222,7 @@ export async function prepareGatewayKernelRequestRuntime(params: {
|
||||
await attachInitialGatewayLifetimeSidecars({
|
||||
chatMetadataLifecycle,
|
||||
gatewayRequestContext,
|
||||
flushPendingSessionsChangedEvents: shutdownRuntime.flushPendingSessionsChangedEvents,
|
||||
sidecars: runtimeState.gatewayLifetimeSidecars,
|
||||
});
|
||||
pluginGatewayContext.current = gatewayRequestContext;
|
||||
|
||||
@@ -256,6 +256,7 @@ describe("createGatewayKernel", () => {
|
||||
"control-ui.root",
|
||||
"tls.runtime",
|
||||
"runtime.state",
|
||||
"gateway.shutdown-runtime-import",
|
||||
"runtime.early",
|
||||
"runtime.early.discovery",
|
||||
"runtime.post-early-imports",
|
||||
|
||||
@@ -38,7 +38,9 @@ const loadGatewayStartupEarlyModule = createLazyRuntimeModule(
|
||||
const loadGatewayPluginBootstrapModule = createLazyRuntimeModule(
|
||||
() => import("./server-plugin-bootstrap.js"),
|
||||
);
|
||||
const loadGatewayCloseModule = createLazyRuntimeModule(() => import("./server-close.runtime.js"));
|
||||
const loadGatewayShutdownModule = createLazyRuntimeModule(
|
||||
() => import("./server-shutdown.runtime.js"),
|
||||
);
|
||||
|
||||
const log = createSubsystemLogger("gateway");
|
||||
const logDiscovery = log.child("discovery");
|
||||
@@ -112,16 +114,6 @@ function formatRuntimeGatewayAuthTokenWarning(): string {
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
async function closeMcpLoopbackServerOnDemand(): Promise<void> {
|
||||
const { closeMcpLoopbackServer } = await import("./mcp-http.js");
|
||||
await closeMcpLoopbackServer();
|
||||
}
|
||||
|
||||
async function stopTaskRegistryMaintenanceOnDemand(): Promise<void> {
|
||||
const { stopTaskRegistryMaintenance } = await import("../tasks/task-registry.maintenance.js");
|
||||
stopTaskRegistryMaintenance();
|
||||
}
|
||||
|
||||
export async function resetPreparedModelCatalogForTestCore(): Promise<void> {
|
||||
const { resetPreparedModelCatalogStateForTest } = await loadGatewayModelCatalogModule();
|
||||
await resetPreparedModelCatalogStateForTest();
|
||||
@@ -153,15 +145,19 @@ export async function createGatewayKernel(port = 18789, opts: GatewayServerOptio
|
||||
loadWorkerEnvironmentStartupModule,
|
||||
loadWorkerPlacementStartupModule,
|
||||
});
|
||||
// An in-place update may replace every hashed chunk before SIGTERM arrives.
|
||||
// Resolve and retain the complete shutdown graph while the install is healthy.
|
||||
const shutdownRuntime = await runtime.startupTrace.measure(
|
||||
"gateway.shutdown-runtime-import",
|
||||
async () => (await loadGatewayShutdownModule()).prepareGatewayShutdownRuntime(),
|
||||
);
|
||||
lifecycleRuntime = await prepareGatewayLifecycle({
|
||||
runtime,
|
||||
port,
|
||||
log,
|
||||
logCron,
|
||||
diagnosticsEnabled: bootstrap.diagnosticsEnabled,
|
||||
loadGatewayCloseModule,
|
||||
closeMcpLoopbackServerOnDemand,
|
||||
stopTaskRegistryMaintenanceOnDemand,
|
||||
shutdownRuntime,
|
||||
});
|
||||
if (bootstrap.cfgAtStart.gateway?.tls?.enabled && !runtime.gatewayTls.enabled) {
|
||||
throw new Error(runtime.gatewayTls.error ?? "gateway tls: failed to enable");
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
shouldRetainControlUiDeviceAuthMigrationSession,
|
||||
} from "./server-public.js";
|
||||
import type { prepareGatewayKernelState } from "./server-runtime-state-prepare.js";
|
||||
import type { GatewayShutdownRuntime } from "./server-shutdown.runtime.js";
|
||||
import {
|
||||
getHealthVersion,
|
||||
incrementPresenceVersion,
|
||||
@@ -51,20 +52,9 @@ export async function prepareGatewayLifecycle(params: {
|
||||
log: GatewayLogger;
|
||||
logCron: GatewayLogger;
|
||||
diagnosticsEnabled: boolean;
|
||||
loadGatewayCloseModule: () => Promise<typeof import("./server-close.runtime.js")>;
|
||||
closeMcpLoopbackServerOnDemand: () => Promise<void>;
|
||||
stopTaskRegistryMaintenanceOnDemand: () => Promise<void>;
|
||||
shutdownRuntime: GatewayShutdownRuntime;
|
||||
}) {
|
||||
const {
|
||||
runtime,
|
||||
port,
|
||||
log,
|
||||
logCron,
|
||||
diagnosticsEnabled,
|
||||
loadGatewayCloseModule,
|
||||
closeMcpLoopbackServerOnDemand,
|
||||
stopTaskRegistryMaintenanceOnDemand,
|
||||
} = params;
|
||||
const { runtime, port, log, logCron, diagnosticsEnabled, shutdownRuntime } = params;
|
||||
const {
|
||||
minimalTestGateway,
|
||||
controlUiDeviceAuthMigration,
|
||||
@@ -485,8 +475,7 @@ export async function prepareGatewayLifecycle(params: {
|
||||
disposeNodeConnectionNotifications(nodeRegistry);
|
||||
watchNodeHttpRuntime.close();
|
||||
clearPluginMetadataLifecycleCaches();
|
||||
const { runGatewayClosePrelude } = await loadGatewayCloseModule();
|
||||
await runGatewayClosePrelude({
|
||||
await shutdownRuntime.runGatewayClosePrelude({
|
||||
...(diagnosticsEnabled ? { stopDiagnostics: stopDiagnosticHeartbeat } : {}),
|
||||
clearSkillsRefreshTimer: () => {
|
||||
if (!runtimeState?.skillsRefreshTimer) {
|
||||
@@ -507,7 +496,7 @@ export async function prepareGatewayLifecycle(params: {
|
||||
await monitor?.waitForIdle();
|
||||
},
|
||||
stopReadinessEventLoopHealth: readinessEventLoopHealth.stop,
|
||||
closeMcpServer: closeMcpLoopbackServerOnDemand,
|
||||
closeMcpServer: shutdownRuntime.closeMcpLoopbackServer,
|
||||
});
|
||||
};
|
||||
const { getRuntimeSnapshot, startChannels, startChannel, stopChannel, markChannelLoggedOut } =
|
||||
@@ -537,11 +526,9 @@ export async function prepareGatewayLifecycle(params: {
|
||||
};
|
||||
const createCloseHandler = () => async (optsValue?: GatewayCloseOptions) => {
|
||||
const channelIds = listLoadedChannelPlugins().map((plugin) => plugin.id as ChannelId);
|
||||
const { createGatewayCloseHandler, drainActiveSessionsForShutdown } =
|
||||
await loadGatewayCloseModule();
|
||||
const transport = transportBridge.current();
|
||||
await transport?.portalService.closeAll();
|
||||
await createGatewayCloseHandler({
|
||||
await shutdownRuntime.createGatewayCloseHandler({
|
||||
bonjourStop: runtimeState.bonjourStop,
|
||||
tailscaleCleanup: runtimeState.tailscaleCleanup,
|
||||
clearSecretsRuntimeSnapshot: clearSecretsRuntimeSnapshotState,
|
||||
@@ -552,7 +539,7 @@ export async function prepareGatewayLifecycle(params: {
|
||||
cron: runtimeState.cronState.cron,
|
||||
heartbeatRunner: runtimeState.heartbeatRunner,
|
||||
updateCheckStop: runtimeState.stopGatewayUpdateCheck,
|
||||
stopTaskRegistryMaintenance: stopTaskRegistryMaintenanceOnDemand,
|
||||
stopTaskRegistryMaintenance: shutdownRuntime.stopTaskRegistryMaintenance,
|
||||
nodePresenceTimers,
|
||||
broadcast,
|
||||
tickInterval: runtimeState.tickInterval,
|
||||
@@ -584,9 +571,7 @@ export async function prepareGatewayLifecycle(params: {
|
||||
if (sessionKeys.size === 0 && sessionIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
const { markRestartAbortedMainSessions } =
|
||||
await import("../agents/main-session-recovery/main-session-restart-recovery.js");
|
||||
await markRestartAbortedMainSessions({
|
||||
await shutdownRuntime.markRestartAbortedMainSessions({
|
||||
cfg: getRuntimeConfig(),
|
||||
sessionKeys,
|
||||
sessionIds,
|
||||
@@ -605,7 +590,12 @@ export async function prepareGatewayLifecycle(params: {
|
||||
httpServers: transport.httpServers,
|
||||
}
|
||||
: {}),
|
||||
drainActiveSessionsForShutdown,
|
||||
drainActiveSessionsForShutdown: shutdownRuntime.drainActiveSessionsForShutdown,
|
||||
disposeAllBundleLspRuntimes: shutdownRuntime.disposeAllBundleLspRuntimes,
|
||||
drainRetainedOpenAiEmbeddingProviders: shutdownRuntime.drainRetainedOpenAiEmbeddingProviders,
|
||||
stopGmailWatcher: shutdownRuntime.stopGmailWatcher,
|
||||
disposeAllCodeModeRuns: shutdownRuntime.disposeAllCodeModeRuns,
|
||||
closeProviderTransportDispatcherPool: shutdownRuntime.closeProviderTransportDispatcherPool,
|
||||
})(optsValue);
|
||||
};
|
||||
let clearFallbackGatewayContextForServer = () => {};
|
||||
@@ -676,6 +666,7 @@ export async function prepareGatewayLifecycle(params: {
|
||||
unavailableGatewayMethods,
|
||||
kernel,
|
||||
pluginHostServices,
|
||||
shutdownRuntime,
|
||||
lifecycle,
|
||||
postReadyState,
|
||||
cronReconciliation,
|
||||
|
||||
@@ -7,14 +7,13 @@ type GatewayChatMetadataLifecycle = Awaited<ReturnType<typeof createGatewayChatM
|
||||
export async function attachInitialGatewayLifetimeSidecars(params: {
|
||||
chatMetadataLifecycle: GatewayChatMetadataLifecycle;
|
||||
gatewayRequestContext: GatewayRequestContext;
|
||||
flushPendingSessionsChangedEvents: (context?: object) => void;
|
||||
sidecars: GatewayPostReadySidecarHandle[];
|
||||
}): Promise<void> {
|
||||
await params.chatMetadataLifecycle.attachContext(params.gatewayRequestContext, params.sidecars);
|
||||
params.sidecars.push({
|
||||
stop: async () => {
|
||||
const { flushPendingSessionsChangedEvents } =
|
||||
await import("./server-methods/session-change-event.js");
|
||||
flushPendingSessionsChangedEvents(params.gatewayRequestContext);
|
||||
stop: () => {
|
||||
params.flushPendingSessionsChangedEvents(params.gatewayRequestContext);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
loaded: [] as string[],
|
||||
close: vi.fn(),
|
||||
flushSessionChanges: vi.fn(),
|
||||
stopPlugins: vi.fn(),
|
||||
clearPluginRegistry: vi.fn(),
|
||||
preparePluginRegistryShutdown: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("./server-close.runtime.js", () => {
|
||||
state.loaded.push("server-close");
|
||||
return {
|
||||
createGatewayCloseHandler: state.close,
|
||||
drainActiveSessionsForShutdown: vi.fn(),
|
||||
runGatewayClosePrelude: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mock("../plugins/hook-runner-global.js", () => {
|
||||
state.loaded.push("plugin-hooks");
|
||||
return { runGlobalGatewayStopSafely: state.stopPlugins };
|
||||
});
|
||||
vi.mock("./server-methods/session-change-event.js", () => {
|
||||
state.loaded.push("session-change-events");
|
||||
return { flushPendingSessionsChangedEvents: state.flushSessionChanges };
|
||||
});
|
||||
vi.mock("./mcp-http.js", () => {
|
||||
state.loaded.push("mcp-http");
|
||||
return { closeMcpLoopbackServer: vi.fn() };
|
||||
});
|
||||
vi.mock("../tasks/task-registry.maintenance.js", () => {
|
||||
state.loaded.push("task-maintenance");
|
||||
return { stopTaskRegistryMaintenance: vi.fn() };
|
||||
});
|
||||
vi.mock("../agents/main-session-recovery/main-session-restart-recovery.js", () => {
|
||||
state.loaded.push("restart-recovery");
|
||||
return { markRestartAbortedMainSessions: vi.fn() };
|
||||
});
|
||||
vi.mock("../agents/agent-bundle-lsp-runtime.js", () => {
|
||||
state.loaded.push("bundle-lsp");
|
||||
return { disposeAllBundleLspRuntimes: vi.fn() };
|
||||
});
|
||||
vi.mock("./embeddings-http.js", () => {
|
||||
state.loaded.push("embeddings");
|
||||
return { drainRetainedOpenAiEmbeddingProviders: vi.fn() };
|
||||
});
|
||||
vi.mock("../hooks/gmail-watcher.js", () => {
|
||||
state.loaded.push("gmail-watcher");
|
||||
return { stopGmailWatcher: vi.fn() };
|
||||
});
|
||||
vi.mock("../agents/code-mode-state.js", () => {
|
||||
state.loaded.push("code-mode");
|
||||
return { disposeAllCodeModeRuns: vi.fn() };
|
||||
});
|
||||
vi.mock("../agents/provider-transport-dispatcher-pool.js", () => {
|
||||
state.loaded.push("provider-transports");
|
||||
return { closeProviderTransportDispatcherPool: vi.fn() };
|
||||
});
|
||||
vi.mock("../plugins/runtime.js", () => {
|
||||
state.loaded.push("plugin-runtime");
|
||||
return {
|
||||
clearActivePluginRegistry: state.clearPluginRegistry,
|
||||
prepareActivePluginRegistryShutdown: state.preparePluginRegistryShutdown,
|
||||
};
|
||||
});
|
||||
|
||||
const { prepareGatewayShutdownRuntime } = await import("./server-shutdown.runtime.js");
|
||||
|
||||
describe("gateway shutdown runtime", () => {
|
||||
it("resolves every shutdown dependency during preparation", async () => {
|
||||
const runtime = await prepareGatewayShutdownRuntime();
|
||||
|
||||
expect(state.loaded.toSorted()).toEqual(
|
||||
[
|
||||
"server-close",
|
||||
"plugin-hooks",
|
||||
"session-change-events",
|
||||
"mcp-http",
|
||||
"task-maintenance",
|
||||
"restart-recovery",
|
||||
"bundle-lsp",
|
||||
"embeddings",
|
||||
"gmail-watcher",
|
||||
"code-mode",
|
||||
"provider-transports",
|
||||
"plugin-runtime",
|
||||
].toSorted(),
|
||||
);
|
||||
expect(runtime.createGatewayCloseHandler).toBe(state.close);
|
||||
expect(runtime.flushPendingSessionsChangedEvents).toBe(state.flushSessionChanges);
|
||||
expect(runtime.runGlobalGatewayStopSafely).toBe(state.stopPlugins);
|
||||
expect(runtime.clearActivePluginRegistry).toBe(state.clearPluginRegistry);
|
||||
expect(state.preparePluginRegistryShutdown).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
export async function prepareGatewayShutdownRuntime() {
|
||||
const [
|
||||
{ createGatewayCloseHandler, drainActiveSessionsForShutdown, runGatewayClosePrelude },
|
||||
{ runGlobalGatewayStopSafely },
|
||||
{ flushPendingSessionsChangedEvents },
|
||||
{ closeMcpLoopbackServer },
|
||||
{ stopTaskRegistryMaintenance },
|
||||
{ markRestartAbortedMainSessions },
|
||||
{ disposeAllBundleLspRuntimes },
|
||||
{ drainRetainedOpenAiEmbeddingProviders },
|
||||
{ stopGmailWatcher },
|
||||
{ disposeAllCodeModeRuns },
|
||||
{ closeProviderTransportDispatcherPool },
|
||||
{ clearActivePluginRegistry, prepareActivePluginRegistryShutdown },
|
||||
] = await Promise.all([
|
||||
import("./server-close.runtime.js"),
|
||||
import("../plugins/hook-runner-global.js"),
|
||||
import("./server-methods/session-change-event.js"),
|
||||
import("./mcp-http.js"),
|
||||
import("../tasks/task-registry.maintenance.js"),
|
||||
import("../agents/main-session-recovery/main-session-restart-recovery.js"),
|
||||
import("../agents/agent-bundle-lsp-runtime.js"),
|
||||
import("./embeddings-http.js"),
|
||||
import("../hooks/gmail-watcher.js"),
|
||||
import("../agents/code-mode-state.js"),
|
||||
import("../agents/provider-transport-dispatcher-pool.js"),
|
||||
import("../plugins/runtime.js"),
|
||||
]);
|
||||
await prepareActivePluginRegistryShutdown();
|
||||
|
||||
return {
|
||||
createGatewayCloseHandler,
|
||||
drainActiveSessionsForShutdown,
|
||||
runGatewayClosePrelude,
|
||||
runGlobalGatewayStopSafely,
|
||||
flushPendingSessionsChangedEvents,
|
||||
closeMcpLoopbackServer,
|
||||
stopTaskRegistryMaintenance,
|
||||
markRestartAbortedMainSessions,
|
||||
disposeAllBundleLspRuntimes,
|
||||
drainRetainedOpenAiEmbeddingProviders,
|
||||
stopGmailWatcher,
|
||||
disposeAllCodeModeRuns,
|
||||
closeProviderTransportDispatcherPool,
|
||||
clearActivePluginRegistry,
|
||||
};
|
||||
}
|
||||
|
||||
export type GatewayShutdownRuntime = Awaited<ReturnType<typeof prepareGatewayShutdownRuntime>>;
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { runGatewayShutdownSteps } from "./server-shutdown.js";
|
||||
|
||||
describe("gateway shutdown steps", () => {
|
||||
it("names an unavailable module step and continues the remaining shutdown", async () => {
|
||||
const missingModule = Object.assign(new Error("Cannot find module 'rotated-chunk.js'"), {
|
||||
code: "ERR_MODULE_NOT_FOUND",
|
||||
});
|
||||
const loadStopModule = vi.fn(async () => {
|
||||
throw missingModule;
|
||||
});
|
||||
const closeGateway = vi.fn(async () => {});
|
||||
const messages: string[] = [];
|
||||
|
||||
await runGatewayShutdownSteps({
|
||||
steps: [
|
||||
{ name: "gateway lifetime sidecars", run: loadStopModule },
|
||||
{ name: "gateway close", run: closeGateway },
|
||||
],
|
||||
onError: (message) => messages.push(message),
|
||||
});
|
||||
|
||||
expect(closeGateway).toHaveBeenCalledOnce();
|
||||
expect(messages).toEqual([
|
||||
"shutdown step failed (gateway lifetime sidecars): Cannot find module 'rotated-chunk.js'",
|
||||
]);
|
||||
expect(messages.join("\n")).not.toContain("shutdown error");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
|
||||
type GatewayShutdownStep = {
|
||||
name: string;
|
||||
run: () => Promise<void> | void;
|
||||
};
|
||||
|
||||
/** Run every shutdown step even when one owner fails, with the failed owner named. */
|
||||
export async function runGatewayShutdownSteps(params: {
|
||||
steps: readonly GatewayShutdownStep[];
|
||||
onError: (message: string) => void;
|
||||
}): Promise<void> {
|
||||
for (const step of params.steps) {
|
||||
try {
|
||||
await step.run();
|
||||
} catch (error) {
|
||||
params.onError(`shutdown step failed (${step.name}): ${formatErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
-18
@@ -1,3 +1,4 @@
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { createLazyRuntimeModule } from "../shared/lazy-runtime.js";
|
||||
import {
|
||||
createGatewayKernel,
|
||||
@@ -6,6 +7,7 @@ import {
|
||||
} from "./server-kernel.js";
|
||||
import type { GatewayServer, GatewayServerOptions } from "./server-public.js";
|
||||
import { createGatewayHttpTransport } from "./server-runtime-state.js";
|
||||
import { runGatewayShutdownSteps } from "./server-shutdown.js";
|
||||
import { finishGatewayStartup } from "./server-startup-finish.js";
|
||||
|
||||
const loadGatewayStartupPostAttachModule = createLazyRuntimeModule(
|
||||
@@ -36,6 +38,7 @@ export async function startGatewayServerCore(
|
||||
stopRegisteredGatewayLifetimeSidecars,
|
||||
stopRegisteredPostReadySidecars,
|
||||
terminalSessions,
|
||||
shutdownRuntime,
|
||||
} = gatewayKernel;
|
||||
try {
|
||||
const transport = await createGatewayHttpTransport(gatewayKernel.createHttpTransportOptions());
|
||||
@@ -68,24 +71,33 @@ export async function startGatewayServerCore(
|
||||
|
||||
return {
|
||||
close: async (optsLocal) => {
|
||||
try {
|
||||
await beginClosePrelude();
|
||||
// Kill any live operator shells before the socket layer tears down.
|
||||
terminalSessions.disposeAll();
|
||||
await stopRegisteredGatewayLifetimeSidecars();
|
||||
await stopRegisteredPostReadySidecars();
|
||||
// Run gateway_stop plugin hook before shutdown
|
||||
const { runGlobalGatewayStopSafely } = await import("../plugins/hook-runner-global.js");
|
||||
await runGlobalGatewayStopSafely({
|
||||
event: { reason: optsLocal?.reason ?? "gateway stopping" },
|
||||
ctx: { port },
|
||||
onError: (err) => log.warn(`gateway_stop hook failed: ${String(err)}`),
|
||||
});
|
||||
await runClosePrelude();
|
||||
await close(optsLocal);
|
||||
} finally {
|
||||
clearFallbackGatewayContextForServer.get()();
|
||||
}
|
||||
await runGatewayShutdownSteps({
|
||||
steps: [
|
||||
{ name: "close prelude fence", run: beginClosePrelude },
|
||||
// Kill any live operator shells before the socket layer tears down.
|
||||
{ name: "terminal sessions", run: () => terminalSessions.disposeAll() },
|
||||
{ name: "gateway lifetime sidecars", run: stopRegisteredGatewayLifetimeSidecars },
|
||||
{ name: "post-ready sidecars", run: stopRegisteredPostReadySidecars },
|
||||
{
|
||||
name: "gateway_stop plugin hooks",
|
||||
run: async () => {
|
||||
await shutdownRuntime.runGlobalGatewayStopSafely({
|
||||
event: { reason: optsLocal?.reason ?? "gateway stopping" },
|
||||
ctx: { port },
|
||||
onError: (error) =>
|
||||
log.warn(`gateway_stop hook failed: ${formatErrorMessage(error)}`),
|
||||
});
|
||||
},
|
||||
},
|
||||
{ name: "gateway close prelude", run: runClosePrelude },
|
||||
{ name: "gateway close", run: () => close(optsLocal) },
|
||||
{
|
||||
name: "fallback gateway context",
|
||||
run: () => clearFallbackGatewayContextForServer.get()(),
|
||||
},
|
||||
],
|
||||
onError: (message) => log.error(message),
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2518,6 +2518,11 @@ describe("startGatewayPostAttachRuntime", () => {
|
||||
stopChannel,
|
||||
pluginServices,
|
||||
postReadySidecars,
|
||||
disposeAllBundleLspRuntimes: vi.fn(async () => {}),
|
||||
drainRetainedOpenAiEmbeddingProviders: vi.fn(async () => {}),
|
||||
stopGmailWatcher: vi.fn(async () => {}),
|
||||
disposeAllCodeModeRuns: vi.fn(async () => {}),
|
||||
closeProviderTransportDispatcherPool: vi.fn(async () => {}),
|
||||
cron: { stop: vi.fn() },
|
||||
heartbeatRunner: { stop: vi.fn(), updateConfig: vi.fn() },
|
||||
nodePresenceTimers: new Map(),
|
||||
|
||||
@@ -90,7 +90,6 @@ import { resolveGlobalSingleton } from "../shared/global-singleton.js";
|
||||
import { getOrCreatePromise } from "../shared/lazy-promise.js";
|
||||
import {
|
||||
forgetActiveSessionForShutdown,
|
||||
listActiveSessionsForShutdown,
|
||||
noteActiveSessionForShutdown,
|
||||
} from "./active-sessions-shutdown-tracker.js";
|
||||
import { findDirectChildSessionsForParent } from "./session-child-sessions.js";
|
||||
@@ -295,94 +294,6 @@ export function emitGatewaySessionStartPluginHook(params: {
|
||||
});
|
||||
}
|
||||
|
||||
const SHUTDOWN_DRAIN_DEFAULT_TOTAL_TIMEOUT_MS = 2_000;
|
||||
|
||||
type DrainActiveSessionsForShutdownResult = {
|
||||
emittedSessionIds: string[];
|
||||
timedOut: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Emit a typed `session_end` for every session that received `session_start`
|
||||
* but did not yet receive a paired `session_end`. The bounded total timeout
|
||||
* mirrors the gateway lifecycle hook timeout so a slow plugin cannot block
|
||||
* SIGTERM/SIGINT past the runtime's overall shutdown grace window.
|
||||
*
|
||||
* Sessions that have already been finalized through replace / reset / delete /
|
||||
* compaction are forgotten from the tracker by `emitGatewaySessionEndPluginHook`
|
||||
* before this drain runs, so they will not be double-fired here.
|
||||
*/
|
||||
export async function drainActiveSessionsForShutdown(params: {
|
||||
reason: "shutdown" | "restart";
|
||||
totalTimeoutMs?: number;
|
||||
}): Promise<DrainActiveSessionsForShutdownResult> {
|
||||
const tracked = listActiveSessionsForShutdown();
|
||||
if (tracked.length === 0) {
|
||||
return { emittedSessionIds: [], timedOut: false };
|
||||
}
|
||||
const totalTimeoutMs = Math.max(
|
||||
100,
|
||||
Math.floor(params.totalTimeoutMs ?? SHUTDOWN_DRAIN_DEFAULT_TOTAL_TIMEOUT_MS),
|
||||
);
|
||||
const emittedSessionIds: string[] = [];
|
||||
const hookRunner = getGlobalHookRunner();
|
||||
let settledEmissions = 0;
|
||||
// Inline the session_end emission instead of calling
|
||||
// `emitGatewaySessionEndPluginHook`, because that helper uses fire-and-forget
|
||||
// (`void hookRunner.runSessionEnd(...)`). Start every tracked session's
|
||||
// emission before awaiting the bounded aggregate so one slow plugin write
|
||||
// cannot prevent later active sessions from receiving `session_end`.
|
||||
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: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeout = new Promise<"timeout">((resolve) => {
|
||||
timer = setTimeout(() => resolve("timeout"), totalTimeoutMs);
|
||||
timer.unref?.();
|
||||
});
|
||||
try {
|
||||
const result = await Promise.race([drain.then(() => "ok" as const), timeout]);
|
||||
if (result === "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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function emitSessionUnboundLifecycleEvent(params: {
|
||||
targetSessionKey: string;
|
||||
reason: "session-reset" | "session-delete";
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
const cleanupReplacedPluginHostRegistry = vi.hoisted(() => vi.fn(async () => {}));
|
||||
const cleanupReplacedPluginHostRegistry = vi.hoisted(() =>
|
||||
vi.fn(async () => ({ cleanupCount: 0, failures: [] })),
|
||||
);
|
||||
|
||||
vi.mock("./host-hook-cleanup.js", () => ({ cleanupReplacedPluginHostRegistry }));
|
||||
|
||||
@@ -16,6 +18,7 @@ import { createEmptyPluginRegistry } from "./registry-empty.js";
|
||||
import { markPluginRegistryRetired } from "./registry-lifecycle.js";
|
||||
import {
|
||||
clearActivePluginRegistry,
|
||||
prepareActivePluginRegistryShutdown,
|
||||
resetPluginRuntimeStateForTest,
|
||||
setActivePluginRegistry,
|
||||
} from "./runtime.js";
|
||||
@@ -71,6 +74,17 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("plugin command runtime", () => {
|
||||
it("prepares plugin host cleanup before gateway shutdown", async () => {
|
||||
await prepareActivePluginRegistryShutdown();
|
||||
const registry = createEmptyPluginRegistry();
|
||||
registry.plugins.push({ status: "loaded" } as never);
|
||||
setActivePluginRegistry(registry);
|
||||
|
||||
await clearActivePluginRegistry();
|
||||
|
||||
expect(cleanupReplacedPluginHostRegistry).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("binds the request-scoped registry and scopes provider aliases", async () => {
|
||||
const ambient = createEmptyPluginRegistry();
|
||||
const scoped = createEmptyPluginRegistry();
|
||||
@@ -339,8 +353,8 @@ describe("plugin command runtime", () => {
|
||||
let releaseCleanup!: () => void;
|
||||
cleanupReplacedPluginHostRegistry.mockImplementationOnce(
|
||||
async () =>
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseCleanup = resolve;
|
||||
await new Promise<{ cleanupCount: number; failures: [] }>((resolve) => {
|
||||
releaseCleanup = () => resolve({ cleanupCount: 0, failures: [] });
|
||||
}),
|
||||
);
|
||||
let detachedClear!: Promise<void>;
|
||||
|
||||
+14
-3
@@ -2,6 +2,7 @@
|
||||
import { onAgentEvent } from "../infra/agent-events.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { drainGlobalSingletonLifecycleState } from "../shared/global-singleton.js";
|
||||
import { createLazyRuntimeModule } from "../shared/lazy-runtime.js";
|
||||
import {
|
||||
getPluginCommandExecutionCount,
|
||||
isPluginCommandExecutionActiveHere,
|
||||
@@ -63,13 +64,19 @@ function isRegistryLive(registry: PluginRegistry): boolean {
|
||||
return state.activeRegistry === registry;
|
||||
}
|
||||
|
||||
async function cleanupPreviousPluginHostRegistry(params: {
|
||||
previousRegistry: PluginRegistry;
|
||||
}): Promise<void> {
|
||||
const loadPluginHostCleanupRuntime = createLazyRuntimeModule(async () => {
|
||||
const [{ getRuntimeConfig }, { cleanupReplacedPluginHostRegistry }] = await Promise.all([
|
||||
import("../config/config.js"),
|
||||
import("./host-hook-cleanup.js"),
|
||||
]);
|
||||
return { getRuntimeConfig, cleanupReplacedPluginHostRegistry };
|
||||
});
|
||||
|
||||
async function cleanupPreviousPluginHostRegistry(params: {
|
||||
previousRegistry: PluginRegistry;
|
||||
}): Promise<void> {
|
||||
const { getRuntimeConfig, cleanupReplacedPluginHostRegistry } =
|
||||
await loadPluginHostCleanupRuntime();
|
||||
const nextRegistry = asPluginRegistry(state.activeRegistry);
|
||||
if (nextRegistry === params.previousRegistry) {
|
||||
return;
|
||||
@@ -438,6 +445,10 @@ export async function clearActivePluginRegistry(): Promise<void> {
|
||||
await completion;
|
||||
}
|
||||
|
||||
export async function prepareActivePluginRegistryShutdown(): Promise<void> {
|
||||
await loadPluginHostCleanupRuntime();
|
||||
}
|
||||
|
||||
export function resetPluginRuntimeStateForTest(): void {
|
||||
state.registrationContext = undefined;
|
||||
clearActivePluginRegistryState();
|
||||
|
||||
Reference in New Issue
Block a user