From dc8b881c1166ba9c8076d7dc5eb80d5c4a91fd49 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 21:50:29 -0700 Subject: [PATCH] fix(gateway): defer startup runtime imports --- CHANGELOG.md | 1 + .../server-methods/nodes-wake-state.ts | 25 +++++++++++ src/gateway/server-methods/nodes.ts | 44 +++++++------------ src/gateway/server-plugin-bootstrap.ts | 10 ++++- src/gateway/server-plugins.ts | 12 +++-- src/gateway/server-startup-plugins.ts | 3 +- src/gateway/server.impl.ts | 10 ++--- src/gateway/server/ws-connection.ts | 2 +- .../server/ws-connection/message-handler.ts | 2 +- src/plugins/loader.ts | 14 +++++- src/plugins/registry-types.ts | 1 + src/plugins/registry.ts | 5 ++- 12 files changed, 84 insertions(+), 45 deletions(-) create mode 100644 src/gateway/server-methods/nodes-wake-state.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 40a2e0c6cda..9fda1a8145e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ Docs: https://docs.openclaw.ai - Exec/node: synthesize a local approval plan when a paired node advertises `system.run` without `system.run.prepare`, unblocking approval-required `host=node` exec on current macOS companion nodes while preserving remote prepare for node hosts that support it. Fixes #37591 and duplicate #66839; carries forward #69725. Thanks @soloclz. - Memory/QMD: prefer QMD's `--mask` collection pattern flag so root memory indexing stays scoped to `MEMORY.md` instead of widening to every markdown file in the workspace. Thanks @codex. - Gateway/memory: defer QMD startup for implicit non-default agents and scope memory runtime loading to the selected memory slot so Gateway boot and first memory recall avoid broad plugin runtime fanout. Thanks @vincentkoc. +- Gateway/startup: keep core request handlers and channel runtime helpers off the boot path until the first matching request or channel start, reducing no-plugin Gateway ready RSS and avoidable startup imports. Thanks @vincentkoc. - CLI/Gateway: use a parse-only config snapshot for plain `gateway status` reads and reuse same-path service config context so status no longer spends tens of seconds in full config validation before printing. Thanks @vincentkoc. - Lobster/Gateway: memoize repeated Ajv schema compilation before loading the embedded Lobster runtime so scheduled workflows and `llm.invoke` loops stop growing gateway heap on content-identical schemas. Fixes #71148. Thanks @cmi525, @vsolaz, and @vincentkoc. - Codex harness: normalize cached input tokens before session/context accounting so prompt cache reads are not double-counted in `/status`, `session_status`, or persisted `sessionEntry.totalTokens`. Fixes #69298. Thanks @richardmqq. diff --git a/src/gateway/server-methods/nodes-wake-state.ts b/src/gateway/server-methods/nodes-wake-state.ts new file mode 100644 index 00000000000..8bb1c13fbd8 --- /dev/null +++ b/src/gateway/server-methods/nodes-wake-state.ts @@ -0,0 +1,25 @@ +export const NODE_WAKE_RECONNECT_WAIT_MS = 3_000; +export const NODE_WAKE_RECONNECT_RETRY_WAIT_MS = 12_000; +export const NODE_WAKE_RECONNECT_POLL_MS = 150; + +export type NodeWakeAttempt = { + available: boolean; + throttled: boolean; + path: "throttled" | "no-registration" | "no-auth" | "sent" | "send-error"; + durationMs: number; + apnsStatus?: number; + apnsReason?: string; +}; + +export type NodeWakeState = { + lastWakeAtMs: number; + inFlight?: Promise; +}; + +export const nodeWakeById = new Map(); +export const nodeWakeNudgeById = new Map(); + +export function clearNodeWakeState(nodeId: string): void { + nodeWakeById.delete(nodeId); + nodeWakeNudgeById.delete(nodeId); +} diff --git a/src/gateway/server-methods/nodes.ts b/src/gateway/server-methods/nodes.ts index c5c712ee118..3b677a82661 100644 --- a/src/gateway/server-methods/nodes.ts +++ b/src/gateway/server-methods/nodes.ts @@ -47,6 +47,14 @@ import { validateNodePairVerifyParams, validateNodeRenameParams, } from "../protocol/index.js"; +import { + NODE_WAKE_RECONNECT_POLL_MS, + NODE_WAKE_RECONNECT_RETRY_WAIT_MS, + NODE_WAKE_RECONNECT_WAIT_MS, + nodeWakeById, + nodeWakeNudgeById, + type NodeWakeAttempt, +} from "./nodes-wake-state.js"; import { handleNodeInvokeResult } from "./nodes.handlers.invoke-result.js"; import { respondInvalidParams, @@ -56,31 +64,18 @@ import { } from "./nodes.helpers.js"; import type { GatewayRequestHandlers } from "./types.js"; -export const NODE_WAKE_RECONNECT_WAIT_MS = 3_000; -export const NODE_WAKE_RECONNECT_RETRY_WAIT_MS = 12_000; -export const NODE_WAKE_RECONNECT_POLL_MS = 150; +export { + clearNodeWakeState, + NODE_WAKE_RECONNECT_POLL_MS, + NODE_WAKE_RECONNECT_RETRY_WAIT_MS, + NODE_WAKE_RECONNECT_WAIT_MS, +} from "./nodes-wake-state.js"; + const NODE_WAKE_THROTTLE_MS = 15_000; const NODE_WAKE_NUDGE_THROTTLE_MS = 10 * 60_000; const NODE_PENDING_ACTION_TTL_MS = 10 * 60_000; const NODE_PENDING_ACTION_MAX_PER_NODE = 64; -type NodeWakeState = { - lastWakeAtMs: number; - inFlight?: Promise; -}; - -const nodeWakeById = new Map(); -const nodeWakeNudgeById = new Map(); - -type NodeWakeAttempt = { - available: boolean; - throttled: boolean; - path: "throttled" | "no-registration" | "no-auth" | "sent" | "send-error"; - durationMs: number; - apnsStatus?: number; - apnsReason?: string; -}; - type NodeWakeNudgeAttempt = { sent: boolean; throttled: boolean; @@ -518,15 +513,6 @@ export async function waitForNodeReconnect(params: { return Boolean(params.context.nodeRegistry.get(params.nodeId)); } -/** - * Remove cached wake/nudge state for a node that has disconnected. - * Called from the WS close handler to prevent unbounded growth. - */ -export function clearNodeWakeState(nodeId: string): void { - nodeWakeById.delete(nodeId); - nodeWakeNudgeById.delete(nodeId); -} - export const nodeHandlers: GatewayRequestHandlers = { "node.pair.request": async ({ params, respond, context }) => { if (!validateNodePairRequestParams(params)) { diff --git a/src/gateway/server-plugin-bootstrap.ts b/src/gateway/server-plugin-bootstrap.ts index 63c4a35c7fa..ffa0cfe25f8 100644 --- a/src/gateway/server-plugin-bootstrap.ts +++ b/src/gateway/server-plugin-bootstrap.ts @@ -27,7 +27,8 @@ type GatewayPluginBootstrapParams = { activationSourceConfig?: OpenClawConfig; workspaceDir: string; log: GatewayPluginBootstrapLog; - coreGatewayHandlers: Record; + coreGatewayHandlers?: Record; + coreGatewayMethodNames?: readonly string[]; baseMethods: string[]; pluginIds?: string[]; preferSetupRuntimeForChannelPlugins?: boolean; @@ -78,7 +79,12 @@ export function prepareGatewayPluginLoad(params: GatewayPluginBootstrapParams) { autoEnabledReasons: autoEnabled.autoEnabledReasons, workspaceDir: params.workspaceDir, log: params.log, - coreGatewayHandlers: params.coreGatewayHandlers, + ...(params.coreGatewayHandlers !== undefined && { + coreGatewayHandlers: params.coreGatewayHandlers, + }), + ...(params.coreGatewayMethodNames !== undefined && { + coreGatewayMethodNames: params.coreGatewayMethodNames, + }), baseMethods: params.baseMethods, pluginIds: params.pluginIds, preferSetupRuntimeForChannelPlugins: params.preferSetupRuntimeForChannelPlugins, diff --git a/src/gateway/server-plugins.ts b/src/gateway/server-plugins.ts index e830ed68134..2da368661e1 100644 --- a/src/gateway/server-plugins.ts +++ b/src/gateway/server-plugins.ts @@ -16,7 +16,6 @@ import { ADMIN_SCOPE, WRITE_SCOPE } from "./method-scopes.js"; import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "./protocol/client-info.js"; import type { ErrorShape } from "./protocol/index.js"; import { PROTOCOL_VERSION } from "./protocol/index.js"; -import { handleGatewayRequest } from "./server-methods.js"; import type { GatewayRequestContext, GatewayRequestHandler, @@ -266,6 +265,7 @@ async function dispatchGatewayMethod( } let result: { ok: boolean; payload?: unknown; error?: ErrorShape } | undefined; + const { handleGatewayRequest } = await import("./server-methods.js"); await handleGatewayRequest({ req: { type: "req", @@ -442,7 +442,8 @@ export function loadGatewayPlugins(params: { error: (msg: string) => void; debug: (msg: string) => void; }; - coreGatewayHandlers: Record; + coreGatewayHandlers?: Record; + coreGatewayMethodNames?: readonly string[]; baseMethods: string[]; pluginIds?: string[]; preferSetupRuntimeForChannelPlugins?: boolean; @@ -499,7 +500,12 @@ export function loadGatewayPlugins(params: { logger: createGatewayPluginRegistrationLogger({ suppressInfoLogs: params.suppressPluginInfoLogs, }), - coreGatewayHandlers: params.coreGatewayHandlers, + ...(params.coreGatewayHandlers !== undefined && { + coreGatewayHandlers: params.coreGatewayHandlers, + }), + ...(params.coreGatewayMethodNames !== undefined && { + coreGatewayMethodNames: params.coreGatewayMethodNames, + }), runtimeOptions: { allowGatewaySubagentBinding: true, }, diff --git a/src/gateway/server-startup-plugins.ts b/src/gateway/server-startup-plugins.ts index 7666c6c3026..8f31c99b5b7 100644 --- a/src/gateway/server-startup-plugins.ts +++ b/src/gateway/server-startup-plugins.ts @@ -10,7 +10,6 @@ import { import { createEmptyPluginRegistry } from "../plugins/registry.js"; import { getActivePluginRegistry, setActivePluginRegistry } from "../plugins/runtime.js"; import { listGatewayMethods } from "./server-methods-list.js"; -import { coreGatewayHandlers } from "./server-methods.js"; import { loadGatewayStartupPlugins } from "./server-plugin-bootstrap.js"; import { runStartupSessionMigration } from "./server-startup-session-migration.js"; @@ -94,7 +93,7 @@ export async function prepareGatewayPluginBootstrap(params: { activationSourceConfig: params.cfgAtStart, workspaceDir: defaultWorkspaceDir, log: params.log, - coreGatewayHandlers, + coreGatewayMethodNames: baseMethods, baseMethods, pluginIds: startupPluginIds, preferSetupRuntimeForChannelPlugins: deferredConfiguredChannelPluginIds.length > 0, diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 525c957c39e..b7e90bf1bc5 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -31,7 +31,8 @@ import type { VoiceWakeRoutingConfig } from "../infra/voicewake-routing.js"; import { startDiagnosticHeartbeat, stopDiagnosticHeartbeat } from "../logging/diagnostic.js"; import { createSubsystemLogger, runtimeForLogger } from "../logging/subsystem.js"; import { runGlobalGatewayStopSafely } from "../plugins/hook-runner-global.js"; -import { createPluginRuntime } from "../plugins/runtime/index.js"; +import { createRuntimeChannel } from "../plugins/runtime/runtime-channel.js"; +import type { PluginRuntime } from "../plugins/runtime/types.js"; import { getTotalQueueSize } from "../process/command-queue.js"; import type { RuntimeEnv } from "../runtime.js"; import { @@ -54,7 +55,6 @@ import { buildGatewayCronService } from "./server-cron.js"; import { applyGatewayLaneConcurrency } from "./server-lanes.js"; import { createGatewayServerLiveState, type GatewayServerLiveState } from "./server-live-state.js"; import { GATEWAY_EVENTS } from "./server-methods-list.js"; -import { coreGatewayHandlers } from "./server-methods.js"; import { loadGatewayModelCatalog } from "./server-model-catalog.js"; import { bootstrapGatewayNetworkRuntime } from "./server-network-runtime.js"; import { createGatewayNodeSessionRuntime } from "./server-node-session-runtime.js"; @@ -118,10 +118,10 @@ const logDiscovery = log.child("discovery"); const logTailscale = log.child("tailscale"); const logChannels = log.child("channels"); -let cachedChannelRuntime: ReturnType["channel"] | null = null; +let cachedChannelRuntime: PluginRuntime["channel"] | null = null; function getChannelRuntime() { - cachedChannelRuntime ??= createPluginRuntime().channel; + cachedChannelRuntime ??= createRuntimeChannel(); return cachedChannelRuntime; } @@ -788,7 +788,7 @@ export async function startGatewayServer( cfg: gatewayPluginConfigAtStart, workspaceDir: defaultWorkspaceDir, log, - coreGatewayHandlers, + coreGatewayMethodNames: baseMethods, baseMethods, pluginIds: startupPluginIds, logDiagnostics: false, diff --git a/src/gateway/server/ws-connection.ts b/src/gateway/server/ws-connection.ts index 0f84d3979e5..5136d83f36e 100644 --- a/src/gateway/server/ws-connection.ts +++ b/src/gateway/server/ws-connection.ts @@ -14,7 +14,7 @@ import type { ResolvedGatewayAuth } from "../auth.js"; import { getPreauthHandshakeTimeoutMsFromEnv } from "../handshake-timeouts.js"; import { isLoopbackAddress } from "../net.js"; import { MAX_PAYLOAD_BYTES, MAX_PREAUTH_PAYLOAD_BYTES } from "../server-constants.js"; -import { clearNodeWakeState } from "../server-methods/nodes.js"; +import { clearNodeWakeState } from "../server-methods/nodes-wake-state.js"; import type { GatewayRequestContext, GatewayRequestHandlers } from "../server-methods/types.js"; import { formatError } from "../server-utils.js"; import { logWs } from "../ws-log.js"; diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index e9406df7e01..634627bede4 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -103,7 +103,6 @@ import { MAX_PREAUTH_PAYLOAD_BYTES, TICK_INTERVAL_MS, } from "../../server-constants.js"; -import { handleGatewayRequest } from "../../server-methods.js"; import type { GatewayRequestContext, GatewayRequestHandlers } from "../../server-methods/types.js"; import { formatError } from "../../server-utils.js"; import { formatForLog, logWs } from "../../ws-log.js"; @@ -1561,6 +1560,7 @@ export function attachGatewayWsMessageHandler(params: { }; void (async () => { + const { handleGatewayRequest } = await import("../../server-methods.js"); await handleGatewayRequest({ req, respond, diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 23987803dda..61a1f49782e 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -147,6 +147,7 @@ export type PluginLoadOptions = { env?: NodeJS.ProcessEnv; logger?: PluginLogger; coreGatewayHandlers?: Record; + coreGatewayMethodNames?: readonly string[]; runtimeOptions?: CreatePluginRuntimeOptions; pluginSdkResolution?: PluginSdkResolutionPreference; cache?: boolean; @@ -1200,7 +1201,12 @@ function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) { const preferSetupRuntimeForChannelPlugins = options.preferSetupRuntimeForChannelPlugins === true; const shouldInstallBundledRuntimeDeps = options.installBundledRuntimeDeps !== false; const runtimeSubagentMode = resolveRuntimeSubagentMode(options.runtimeOptions); - const coreGatewayMethodNames = Object.keys(options.coreGatewayHandlers ?? {}).toSorted(); + const coreGatewayMethodNames = Array.from( + new Set([ + ...(options.coreGatewayMethodNames ?? []), + ...Object.keys(options.coreGatewayHandlers ?? {}), + ]), + ).toSorted(); const installRecords = { ...loadInstalledPluginIndexInstallRecordsSync({ env }), ...cfg.plugins?.installs, @@ -2286,6 +2292,9 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi logger, runtime, coreGatewayHandlers: options.coreGatewayHandlers as Record, + ...(options.coreGatewayMethodNames !== undefined && { + coreGatewayMethodNames: options.coreGatewayMethodNames, + }), activateGlobalSideEffects: shouldActivate, }); @@ -3189,6 +3198,9 @@ export async function loadOpenClawPluginCliRegistry( logger, runtime: {} as PluginRuntime, coreGatewayHandlers: options.coreGatewayHandlers as Record, + ...(options.coreGatewayMethodNames !== undefined && { + coreGatewayMethodNames: options.coreGatewayMethodNames, + }), activateGlobalSideEffects: false, }); diff --git a/src/plugins/registry-types.ts b/src/plugins/registry-types.ts index 0bc47895f82..f77e805a37f 100644 --- a/src/plugins/registry-types.ts +++ b/src/plugins/registry-types.ts @@ -336,6 +336,7 @@ export type PluginRegistry = { export type PluginRegistryParams = { logger: PluginLogger; coreGatewayHandlers?: GatewayRequestHandlers; + coreGatewayMethodNames?: readonly string[]; runtime: PluginRuntime; activateGlobalSideEffects?: boolean; }; diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 901eacd6a18..86e43cb9d85 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -224,7 +224,10 @@ function resolvePluginRegistrationCapabilities( export function createPluginRegistry(registryParams: PluginRegistryParams) { const registry = createEmptyPluginRegistry(); - const coreGatewayMethods = new Set(Object.keys(registryParams.coreGatewayHandlers ?? {})); + const coreGatewayMethods = new Set([ + ...(registryParams.coreGatewayMethodNames ?? []), + ...Object.keys(registryParams.coreGatewayHandlers ?? {}), + ]); const pluginHookRollback = new Map(); const pluginsWithChannelRegistrationConflict = new Set();