refactor(gateway): unify maintenance lifecycle ownership (#128766)

This commit is contained in:
Peter Steinberger
2026-08-24 08:09:33 -07:00
committed by GitHub
parent a6a9f553d0
commit 3dec4e3472
8 changed files with 26 additions and 66 deletions
+1 -1
View File
@@ -3052,7 +3052,7 @@ src/gateway/server-plugins.ts 2
src/gateway/server-reload-restart.ts 2
src/gateway/server-reload-utils.ts 1
src/gateway/server-restart-sentinel-agent-delivery.ts 2
src/gateway/server-runtime-handles.ts 11
src/gateway/server-runtime-handles.ts 9
src/gateway/server-runtime-state-prepare.ts 2
src/gateway/server-session-events.ts 1
src/gateway/server-startup-bootstrap.ts 1
+9 -5
View File
@@ -164,12 +164,16 @@ function createGatewayCloseTestDeps(
stopTaskRegistryMaintenance: null,
nodePresenceTimers: new Map(),
broadcast: vi.fn(),
tickInterval: setInterval(() => undefined, 60_000),
healthInterval: setInterval(() => undefined, 60_000),
dedupeCleanup: setInterval(() => undefined, 60_000),
maintenance: {
tickInterval: setInterval(() => undefined, 60_000),
healthInterval: setInterval(() => undefined, 60_000),
dedupeCleanup: setInterval(() => undefined, 60_000),
startMediaCleanup: vi.fn(),
stopMediaCleanup: vi.fn(async () => "drained" as const),
worktreeCleanup: setInterval(() => undefined, 60_000),
skillCuratorCleanup: vi.fn(),
},
stopMediaCleanup: vi.fn(async () => "drained" as const),
worktreeCleanup: null,
skillCuratorCleanup: vi.fn(),
agentUnsub: null,
taskUnsub: null,
heartbeatUnsub: null,
+8 -11
View File
@@ -38,6 +38,7 @@ import {
} from "./server-chat-state.js";
import type { MediaCleanupStopResult } from "./server-media-cleanup-lifecycle.js";
import { clearSessionTypingState } from "./server-methods/session-typing-state.js";
import type { GatewayMaintenanceHandles } from "./server-runtime-services.js";
const shutdownLog = createSubsystemLogger("gateway/shutdown");
const GATEWAY_SHUTDOWN_HOOK_TIMEOUT_MS = 5_000;
@@ -728,12 +729,8 @@ export function createGatewayCloseHandler(
updateCheckStop?: (() => void) | null;
stopTaskRegistryMaintenance?: (() => Promise<void> | void) | null;
nodePresenceTimers: Map<string, ReturnType<typeof setInterval>>;
tickInterval: ReturnType<typeof setInterval>;
healthInterval: ReturnType<typeof setInterval>;
dedupeCleanup: ReturnType<typeof setInterval>;
maintenance: GatewayMaintenanceHandles | null;
stopMediaCleanup: () => Promise<MediaCleanupStopResult>;
worktreeCleanup: ReturnType<typeof setInterval> | null;
skillCuratorCleanup: () => void;
agentUnsub: (() => Promise<void> | void) | null;
heartbeatUnsub: (() => void) | null;
transcriptUnsub: (() => void) | null;
@@ -976,13 +973,13 @@ export function createGatewayCloseHandler(
reason,
restartExpectedMs,
});
clearInterval(params.tickInterval);
clearInterval(params.healthInterval);
clearInterval(params.dedupeCleanup);
if (params.worktreeCleanup) {
clearInterval(params.worktreeCleanup);
if (params.maintenance) {
clearInterval(params.maintenance.tickInterval);
clearInterval(params.maintenance.healthInterval);
clearInterval(params.maintenance.dedupeCleanup);
clearInterval(params.maintenance.worktreeCleanup);
params.maintenance.skillCuratorCleanup();
}
params.skillCuratorCleanup();
if (params.agentUnsub) {
await shutdownStep("agent-unsub", () => params.agentUnsub!(), warnings);
}
+3 -18
View File
@@ -324,20 +324,9 @@ export async function prepareGatewayLifecycle(params: {
addGatewayLifetimeSidecar: (sidecar: (typeof runtimeState.gatewayLifetimeSidecars)[number]) => {
runtimeState.gatewayLifetimeSidecars.push(sidecar);
},
setMaintenanceHandles: (handles: {
tickInterval: typeof runtimeState.tickInterval;
healthInterval: typeof runtimeState.healthInterval;
dedupeCleanup: typeof runtimeState.dedupeCleanup;
stopMediaCleanup: typeof runtimeState.stopMediaCleanup;
worktreeCleanup: typeof runtimeState.worktreeCleanup;
skillCuratorCleanup: typeof runtimeState.skillCuratorCleanup;
}) => {
runtimeState.tickInterval = handles.tickInterval;
runtimeState.healthInterval = handles.healthInterval;
runtimeState.dedupeCleanup = handles.dedupeCleanup;
setMaintenanceHandles: (handles: NonNullable<typeof runtimeState.maintenance>) => {
runtimeState.maintenance = handles;
runtimeState.stopMediaCleanup = handles.stopMediaCleanup;
runtimeState.worktreeCleanup = handles.worktreeCleanup;
runtimeState.skillCuratorCleanup = handles.skillCuratorCleanup;
},
};
runtimeState.controlUiSessionPullRequests = createControlUiSessionPullRequestSubscriptions({
@@ -524,12 +513,8 @@ export async function prepareGatewayLifecycle(params: {
stopTaskRegistryMaintenance: shutdownRuntime.stopTaskRegistryMaintenance,
nodePresenceTimers,
broadcast,
tickInterval: runtimeState.tickInterval,
healthInterval: runtimeState.healthInterval,
dedupeCleanup: runtimeState.dedupeCleanup,
maintenance: runtimeState.maintenance,
stopMediaCleanup: stopMediaCleanupForClose,
worktreeCleanup: runtimeState.worktreeCleanup,
skillCuratorCleanup: runtimeState.skillCuratorCleanup,
agentUnsub: runtimeState.agentUnsub,
heartbeatUnsub: runtimeState.heartbeatUnsub,
transcriptUnsub: runtimeState.transcriptUnsub,
@@ -30,10 +30,5 @@ describe("createGatewayServerMutableState", () => {
await vi.advanceTimersByTimeAsync(0);
const restartedState = createGatewayServerMutableState();
await expect(restartedState.stopMediaCleanup()).resolves.toBe("drained");
for (const runtimeState of [state, restartedState]) {
clearInterval(runtimeState.tickInterval);
clearInterval(runtimeState.healthInterval);
clearInterval(runtimeState.dedupeCleanup);
}
});
});
+3 -19
View File
@@ -9,6 +9,7 @@ import {
waitForMediaCleanupDrains,
} from "./server-media-cleanup-lifecycle.js";
import { createNoopHeartbeatRunner } from "./server-runtime-service-shared.js";
import type { GatewayMaintenanceHandles } from "./server-runtime-services.js";
import type { GatewayPostReadySidecarHandle } from "./server-startup-post-attach.js";
// Mutable server handles track timers, sidecars, subscriptions, and service
@@ -25,12 +26,8 @@ export type GatewayConfigReloaderHandle = {
/** Mutable handles owned by a running gateway server process. */
export type GatewayServerMutableState = {
bonjourStop: (() => Promise<void>) | null;
tickInterval: ReturnType<typeof setInterval>;
healthInterval: ReturnType<typeof setInterval>;
dedupeCleanup: ReturnType<typeof setInterval>;
maintenance: GatewayMaintenanceHandles | null;
stopMediaCleanup: () => Promise<MediaCleanupStopResult>;
worktreeCleanup: ReturnType<typeof setInterval> | null;
skillCuratorCleanup: () => void;
heartbeatRunner: HeartbeatRunner;
stopOutboundDeliveryRecovery: () => Promise<void>;
stopGatewayUpdateCheck: () => void;
@@ -41,7 +38,6 @@ export type GatewayServerMutableState = {
skillsRefreshDelayMs: number;
skillsChangeUnsub: () => Promise<void>;
channelHealthMonitor: ChannelHealthMonitor | null;
mcpServer: { port: number; close: () => Promise<void> } | undefined;
configReloader: GatewayConfigReloaderHandle;
agentUnsub: (() => Promise<void> | void) | null;
heartbeatUnsub: (() => void) | null;
@@ -52,21 +48,10 @@ export type GatewayServerMutableState = {
/** Creates gateway mutable state with inert handles that are safe to stop before startup finishes. */
export function createGatewayServerMutableState(): GatewayServerMutableState {
const noopInterval = () => {
// Dummy unref'd timers give shutdown code a concrete handle to clear even
// when startup exits before real maintenance intervals are installed.
const timer = setInterval(() => {}, 1 << 30);
timer.unref?.();
return timer;
};
return {
bonjourStop: null as (() => Promise<void>) | null,
tickInterval: noopInterval(),
healthInterval: noopInterval(),
dedupeCleanup: noopInterval(),
maintenance: null,
stopMediaCleanup: () => waitForMediaCleanupDrains({ timeoutMs: MEDIA_CLEANUP_STOP_TIMEOUT_MS }),
worktreeCleanup: null as ReturnType<typeof setInterval> | null,
skillCuratorCleanup: () => {},
heartbeatRunner: createNoopHeartbeatRunner(),
stopOutboundDeliveryRecovery: async () => {},
stopGatewayUpdateCheck: () => {},
@@ -77,7 +62,6 @@ export function createGatewayServerMutableState(): GatewayServerMutableState {
skillsRefreshDelayMs: 30_000,
skillsChangeUnsub: async () => {},
channelHealthMonitor: null as ChannelHealthMonitor | null,
mcpServer: undefined as { port: number; close: () => Promise<void> } | undefined,
configReloader: {
stop: async () => {},
notifyPluginMetadataChanged: () => {},
+1 -1
View File
@@ -76,7 +76,7 @@ export function startGatewayCronWithLogging(params: {
}).catch((err: unknown) => params.logCron.error(`failed to enter start root: ${String(err)}`));
}
async function clearGatewayMaintenanceHandles(
export async function clearGatewayMaintenanceHandles(
maintenance: GatewayMaintenanceHandles | null,
): Promise<void> {
if (!maintenance) {
+1 -6
View File
@@ -448,12 +448,7 @@ export async function finishGatewayStartup(params: {
},
applyMaintenance: async (maintenance) => {
if (lifecycle.closePreludeStarted) {
clearInterval(maintenance.tickInterval);
clearInterval(maintenance.healthInterval);
clearInterval(maintenance.dedupeCleanup);
await maintenance.stopMediaCleanup();
clearInterval(maintenance.worktreeCleanup);
maintenance.skillCuratorCleanup();
await gatewayRuntimeServices.clearGatewayMaintenanceHandles(maintenance);
return;
}
// Publish the stop owner before cleanup can touch SQLite or state paths;