perf(test): minimize gateway startup in vitest

This commit is contained in:
Peter Steinberger
2026-02-14 05:09:07 +00:00
parent db72184de6
commit 98bb4225fd
2 changed files with 224 additions and 136 deletions
+192 -136
View File
@@ -1,5 +1,6 @@
import path from "node:path"; import path from "node:path";
import type { CanvasHostServer } from "../canvas-host/server.js"; import type { CanvasHostServer } from "../canvas-host/server.js";
import type { PluginRegistry } from "../plugins/registry.js";
import type { PluginServicesHandle } from "../plugins/services.js"; import type { PluginServicesHandle } from "../plugins/services.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import type { ControlUiRootState } from "./control-ui.js"; import type { ControlUiRootState } from "./control-ui.js";
@@ -31,7 +32,7 @@ import { isDiagnosticsEnabled } from "../infra/diagnostic-events.js";
import { logAcceptedEnvOption } from "../infra/env.js"; import { logAcceptedEnvOption } from "../infra/env.js";
import { createExecApprovalForwarder } from "../infra/exec-approval-forwarder.js"; import { createExecApprovalForwarder } from "../infra/exec-approval-forwarder.js";
import { onHeartbeatEvent } from "../infra/heartbeat-events.js"; import { onHeartbeatEvent } from "../infra/heartbeat-events.js";
import { startHeartbeatRunner } from "../infra/heartbeat-runner.js"; import { startHeartbeatRunner, type HeartbeatRunner } from "../infra/heartbeat-runner.js";
import { getMachineDisplayName } from "../infra/machine-name.js"; import { getMachineDisplayName } from "../infra/machine-name.js";
import { ensureOpenClawCliOnPath } from "../infra/path-env.js"; import { ensureOpenClawCliOnPath } from "../infra/path-env.js";
import { setGatewaySigusr1RestartPolicy, setPreRestartDeferralCheck } from "../infra/restart.js"; import { setGatewaySigusr1RestartPolicy, setPreRestartDeferralCheck } from "../infra/restart.js";
@@ -161,6 +162,9 @@ export async function startGatewayServer(
port = 18789, port = 18789,
opts: GatewayServerOptions = {}, opts: GatewayServerOptions = {},
): Promise<GatewayServer> { ): Promise<GatewayServer> {
const minimalTestGateway =
process.env.VITEST === "1" && process.env.OPENCLAW_TEST_MINIMAL_GATEWAY === "1";
// Ensure all default port derivations (browser/canvas) see the actual runtime port. // Ensure all default port derivations (browser/canvas) see the actual runtime port.
process.env.OPENCLAW_GATEWAY_PORT = String(port); process.env.OPENCLAW_GATEWAY_PORT = String(port);
logAcceptedEnvOption({ logAcceptedEnvOption({
@@ -235,13 +239,30 @@ export async function startGatewayServer(
const defaultAgentId = resolveDefaultAgentId(cfgAtStart); const defaultAgentId = resolveDefaultAgentId(cfgAtStart);
const defaultWorkspaceDir = resolveAgentWorkspaceDir(cfgAtStart, defaultAgentId); const defaultWorkspaceDir = resolveAgentWorkspaceDir(cfgAtStart, defaultAgentId);
const baseMethods = listGatewayMethods(); const baseMethods = listGatewayMethods();
const { pluginRegistry, gatewayMethods: baseGatewayMethods } = loadGatewayPlugins({ const emptyPluginRegistry: PluginRegistry = {
cfg: cfgAtStart, plugins: [],
workspaceDir: defaultWorkspaceDir, tools: [],
log, hooks: [],
coreGatewayHandlers, typedHooks: [],
baseMethods, channels: [],
}); providers: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],
cliRegistrars: [],
services: [],
commands: [],
diagnostics: [],
};
const { pluginRegistry, gatewayMethods: baseGatewayMethods } = minimalTestGateway
? { pluginRegistry: emptyPluginRegistry, gatewayMethods: baseMethods }
: loadGatewayPlugins({
cfg: cfgAtStart,
workspaceDir: defaultWorkspaceDir,
log,
coreGatewayHandlers,
baseMethods,
});
const channelLogs = Object.fromEntries( const channelLogs = Object.fromEntries(
listChannelPlugins().map((plugin) => [plugin.id, logChannels.child(plugin.id)]), listChannelPlugins().map((plugin) => [plugin.id, logChannels.child(plugin.id)]),
) as Record<ChannelId, ReturnType<typeof createSubsystemLogger>>; ) as Record<ChannelId, ReturnType<typeof createSubsystemLogger>>;
@@ -402,91 +423,116 @@ export async function startGatewayServer(
const { getRuntimeSnapshot, startChannels, startChannel, stopChannel, markChannelLoggedOut } = const { getRuntimeSnapshot, startChannels, startChannel, stopChannel, markChannelLoggedOut } =
channelManager; channelManager;
const machineDisplayName = await getMachineDisplayName(); if (!minimalTestGateway) {
const discovery = await startGatewayDiscovery({ const machineDisplayName = await getMachineDisplayName();
machineDisplayName, const discovery = await startGatewayDiscovery({
port, machineDisplayName,
gatewayTls: gatewayTls.enabled port,
? { enabled: true, fingerprintSha256: gatewayTls.fingerprintSha256 } gatewayTls: gatewayTls.enabled
: undefined, ? { enabled: true, fingerprintSha256: gatewayTls.fingerprintSha256 }
wideAreaDiscoveryEnabled: cfgAtStart.discovery?.wideArea?.enabled === true, : undefined,
wideAreaDiscoveryDomain: cfgAtStart.discovery?.wideArea?.domain, wideAreaDiscoveryEnabled: cfgAtStart.discovery?.wideArea?.enabled === true,
tailscaleMode, wideAreaDiscoveryDomain: cfgAtStart.discovery?.wideArea?.domain,
mdnsMode: cfgAtStart.discovery?.mdns?.mode, tailscaleMode,
logDiscovery, mdnsMode: cfgAtStart.discovery?.mdns?.mode,
}); logDiscovery,
bonjourStop = discovery.bonjourStop; });
bonjourStop = discovery.bonjourStop;
}
setSkillsRemoteRegistry(nodeRegistry); if (!minimalTestGateway) {
void primeRemoteSkillsCache(); setSkillsRemoteRegistry(nodeRegistry);
void primeRemoteSkillsCache();
}
// Debounce skills-triggered node probes to avoid feedback loops and rapid-fire invokes. // Debounce skills-triggered node probes to avoid feedback loops and rapid-fire invokes.
// Skills changes can happen in bursts (e.g., file watcher events), and each probe // Skills changes can happen in bursts (e.g., file watcher events), and each probe
// takes time to complete. A 30-second delay ensures we batch changes together. // takes time to complete. A 30-second delay ensures we batch changes together.
let skillsRefreshTimer: ReturnType<typeof setTimeout> | null = null; let skillsRefreshTimer: ReturnType<typeof setTimeout> | null = null;
const skillsRefreshDelayMs = 30_000; const skillsRefreshDelayMs = 30_000;
const skillsChangeUnsub = registerSkillsChangeListener((event) => { const skillsChangeUnsub = minimalTestGateway
if (event.reason === "remote-node") { ? () => {}
return; : registerSkillsChangeListener((event) => {
} if (event.reason === "remote-node") {
if (skillsRefreshTimer) { return;
clearTimeout(skillsRefreshTimer); }
} if (skillsRefreshTimer) {
skillsRefreshTimer = setTimeout(() => { clearTimeout(skillsRefreshTimer);
skillsRefreshTimer = null; }
const latest = loadConfig(); skillsRefreshTimer = setTimeout(() => {
void refreshRemoteBinsForConnectedNodes(latest); skillsRefreshTimer = null;
}, skillsRefreshDelayMs); const latest = loadConfig();
}); void refreshRemoteBinsForConnectedNodes(latest);
}, skillsRefreshDelayMs);
});
const { tickInterval, healthInterval, dedupeCleanup } = startGatewayMaintenanceTimers({ const noopInterval = () => setInterval(() => {}, 1 << 30);
broadcast, let tickInterval = noopInterval();
nodeSendToAllSubscribed, let healthInterval = noopInterval();
getPresenceVersion, let dedupeCleanup = noopInterval();
getHealthVersion, if (!minimalTestGateway) {
refreshGatewayHealthSnapshot, ({ tickInterval, healthInterval, dedupeCleanup } = startGatewayMaintenanceTimers({
logHealth,
dedupe,
chatAbortControllers,
chatRunState,
chatRunBuffers,
chatDeltaSentAt,
removeChatRun,
agentRunSeq,
nodeSendToSession,
});
const agentUnsub = onAgentEvent(
createAgentEventHandler({
broadcast, broadcast,
broadcastToConnIds, nodeSendToAllSubscribed,
nodeSendToSession, getPresenceVersion,
agentRunSeq, getHealthVersion,
refreshGatewayHealthSnapshot,
logHealth,
dedupe,
chatAbortControllers,
chatRunState, chatRunState,
resolveSessionKeyForRun, chatRunBuffers,
clearAgentRunContext, chatDeltaSentAt,
toolEventRecipients, removeChatRun,
}), agentRunSeq,
); nodeSendToSession,
}));
}
const heartbeatUnsub = onHeartbeatEvent((evt) => { const agentUnsub = minimalTestGateway
broadcast("heartbeat", evt, { dropIfSlow: true }); ? null
}); : onAgentEvent(
createAgentEventHandler({
broadcast,
broadcastToConnIds,
nodeSendToSession,
agentRunSeq,
chatRunState,
resolveSessionKeyForRun,
clearAgentRunContext,
toolEventRecipients,
}),
);
let heartbeatRunner = startHeartbeatRunner({ cfg: cfgAtStart }); const heartbeatUnsub = minimalTestGateway
? null
: onHeartbeatEvent((evt) => {
broadcast("heartbeat", evt, { dropIfSlow: true });
});
void cron.start().catch((err) => logCron.error(`failed to start: ${String(err)}`)); let heartbeatRunner: HeartbeatRunner = minimalTestGateway
? {
stop: () => {},
updateConfig: () => {},
}
: startHeartbeatRunner({ cfg: cfgAtStart });
if (!minimalTestGateway) {
void cron.start().catch((err) => logCron.error(`failed to start: ${String(err)}`));
}
// Recover pending outbound deliveries from previous crash/restart. // Recover pending outbound deliveries from previous crash/restart.
void (async () => { if (!minimalTestGateway) {
const { recoverPendingDeliveries } = await import("../infra/outbound/delivery-queue.js"); void (async () => {
const { deliverOutboundPayloads } = await import("../infra/outbound/deliver.js"); const { recoverPendingDeliveries } = await import("../infra/outbound/delivery-queue.js");
const logRecovery = log.child("delivery-recovery"); const { deliverOutboundPayloads } = await import("../infra/outbound/deliver.js");
await recoverPendingDeliveries({ const logRecovery = log.child("delivery-recovery");
deliver: deliverOutboundPayloads, await recoverPendingDeliveries({
log: logRecovery, deliver: deliverOutboundPayloads,
cfg: cfgAtStart, log: logRecovery,
}); cfg: cfgAtStart,
})().catch((err) => log.error(`Delivery recovery failed: ${String(err)}`)); });
})().catch((err) => log.error(`Delivery recovery failed: ${String(err)}`));
}
const execApprovalManager = new ExecApprovalManager(); const execApprovalManager = new ExecApprovalManager();
const execApprovalForwarder = createExecApprovalForwarder(); const execApprovalForwarder = createExecApprovalForwarder();
@@ -564,30 +610,36 @@ export async function startGatewayServer(
log, log,
isNixMode, isNixMode,
}); });
scheduleGatewayUpdateCheck({ cfg: cfgAtStart, log, isNixMode }); if (!minimalTestGateway) {
const tailscaleCleanup = await startGatewayTailscaleExposure({ scheduleGatewayUpdateCheck({ cfg: cfgAtStart, log, isNixMode });
tailscaleMode, }
resetOnExit: tailscaleConfig.resetOnExit, const tailscaleCleanup = minimalTestGateway
port, ? null
controlUiBasePath, : await startGatewayTailscaleExposure({
logTailscale, tailscaleMode,
}); resetOnExit: tailscaleConfig.resetOnExit,
port,
controlUiBasePath,
logTailscale,
});
let browserControl: Awaited<ReturnType<typeof startBrowserControlServerIfEnabled>> = null; let browserControl: Awaited<ReturnType<typeof startBrowserControlServerIfEnabled>> = null;
({ browserControl, pluginServices } = await startGatewaySidecars({ if (!minimalTestGateway) {
cfg: cfgAtStart, ({ browserControl, pluginServices } = await startGatewaySidecars({
pluginRegistry, cfg: cfgAtStart,
defaultWorkspaceDir, pluginRegistry,
deps, defaultWorkspaceDir,
startChannels, deps,
log, startChannels,
logHooks, log,
logChannels, logHooks,
logBrowser, logChannels,
})); logBrowser,
}));
}
// Run gateway_start plugin hook (fire-and-forget) // Run gateway_start plugin hook (fire-and-forget)
{ if (!minimalTestGateway) {
const hookRunner = getGlobalHookRunner(); const hookRunner = getGlobalHookRunner();
if (hookRunner?.hasHooks("gateway_start")) { if (hookRunner?.hasHooks("gateway_start")) {
void hookRunner.runGatewayStart({ port }, { port }).catch((err) => { void hookRunner.runGatewayStart({ port }, { port }).catch((err) => {
@@ -596,44 +648,48 @@ export async function startGatewayServer(
} }
} }
const { applyHotReload, requestGatewayRestart } = createGatewayReloadHandlers({ const configReloader = minimalTestGateway
deps, ? { stop: async () => {} }
broadcast, : (() => {
getState: () => ({ const { applyHotReload, requestGatewayRestart } = createGatewayReloadHandlers({
hooksConfig, deps,
heartbeatRunner, broadcast,
cronState, getState: () => ({
browserControl, hooksConfig,
}), heartbeatRunner,
setState: (nextState) => { cronState,
hooksConfig = nextState.hooksConfig; browserControl,
heartbeatRunner = nextState.heartbeatRunner; }),
cronState = nextState.cronState; setState: (nextState) => {
cron = cronState.cron; hooksConfig = nextState.hooksConfig;
cronStorePath = cronState.storePath; heartbeatRunner = nextState.heartbeatRunner;
browserControl = nextState.browserControl; cronState = nextState.cronState;
}, cron = cronState.cron;
startChannel, cronStorePath = cronState.storePath;
stopChannel, browserControl = nextState.browserControl;
logHooks, },
logBrowser, startChannel,
logChannels, stopChannel,
logCron, logHooks,
logReload, logBrowser,
}); logChannels,
logCron,
logReload,
});
const configReloader = startGatewayConfigReloader({ return startGatewayConfigReloader({
initialConfig: cfgAtStart, initialConfig: cfgAtStart,
readSnapshot: readConfigFileSnapshot, readSnapshot: readConfigFileSnapshot,
onHotReload: applyHotReload, onHotReload: applyHotReload,
onRestart: requestGatewayRestart, onRestart: requestGatewayRestart,
log: { log: {
info: (msg) => logReload.info(msg), info: (msg) => logReload.info(msg),
warn: (msg) => logReload.warn(msg), warn: (msg) => logReload.warn(msg),
error: (msg) => logReload.error(msg), error: (msg) => logReload.error(msg),
}, },
watchPath: CONFIG_PATH, watchPath: CONFIG_PATH,
}); });
})();
const close = createGatewayCloseHandler({ const close = createGatewayCloseHandler({
bonjourStop, bonjourStop,
+32
View File
@@ -50,6 +50,10 @@ let previousSkipBrowserControl: string | undefined;
let previousSkipGmailWatcher: string | undefined; let previousSkipGmailWatcher: string | undefined;
let previousSkipCanvasHost: string | undefined; let previousSkipCanvasHost: string | undefined;
let previousBundledPluginsDir: string | undefined; let previousBundledPluginsDir: string | undefined;
let previousSkipChannels: string | undefined;
let previousSkipProviders: string | undefined;
let previousSkipCron: string | undefined;
let previousMinimalGateway: string | undefined;
let tempHome: string | undefined; let tempHome: string | undefined;
let tempConfigRoot: string | undefined; let tempConfigRoot: string | undefined;
@@ -90,6 +94,10 @@ async function setupGatewayTestHome() {
previousSkipGmailWatcher = process.env.OPENCLAW_SKIP_GMAIL_WATCHER; previousSkipGmailWatcher = process.env.OPENCLAW_SKIP_GMAIL_WATCHER;
previousSkipCanvasHost = process.env.OPENCLAW_SKIP_CANVAS_HOST; previousSkipCanvasHost = process.env.OPENCLAW_SKIP_CANVAS_HOST;
previousBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; previousBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
previousSkipChannels = process.env.OPENCLAW_SKIP_CHANNELS;
previousSkipProviders = process.env.OPENCLAW_SKIP_PROVIDERS;
previousSkipCron = process.env.OPENCLAW_SKIP_CRON;
previousMinimalGateway = process.env.OPENCLAW_TEST_MINIMAL_GATEWAY;
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gateway-home-")); tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gateway-home-"));
process.env.HOME = tempHome; process.env.HOME = tempHome;
process.env.USERPROFILE = tempHome; process.env.USERPROFILE = tempHome;
@@ -101,6 +109,10 @@ function applyGatewaySkipEnv() {
process.env.OPENCLAW_SKIP_BROWSER_CONTROL_SERVER = "1"; process.env.OPENCLAW_SKIP_BROWSER_CONTROL_SERVER = "1";
process.env.OPENCLAW_SKIP_GMAIL_WATCHER = "1"; process.env.OPENCLAW_SKIP_GMAIL_WATCHER = "1";
process.env.OPENCLAW_SKIP_CANVAS_HOST = "1"; process.env.OPENCLAW_SKIP_CANVAS_HOST = "1";
process.env.OPENCLAW_SKIP_CHANNELS = "1";
process.env.OPENCLAW_SKIP_PROVIDERS = "1";
process.env.OPENCLAW_SKIP_CRON = "1";
process.env.OPENCLAW_TEST_MINIMAL_GATEWAY = "1";
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = tempHome process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = tempHome
? path.join(tempHome, "openclaw-test-no-bundled-extensions") ? path.join(tempHome, "openclaw-test-no-bundled-extensions")
: "openclaw-test-no-bundled-extensions"; : "openclaw-test-no-bundled-extensions";
@@ -203,6 +215,26 @@ async function cleanupGatewayTestHome(options: { restoreEnv: boolean }) {
} else { } else {
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = previousBundledPluginsDir; process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = previousBundledPluginsDir;
} }
if (previousSkipChannels === undefined) {
delete process.env.OPENCLAW_SKIP_CHANNELS;
} else {
process.env.OPENCLAW_SKIP_CHANNELS = previousSkipChannels;
}
if (previousSkipProviders === undefined) {
delete process.env.OPENCLAW_SKIP_PROVIDERS;
} else {
process.env.OPENCLAW_SKIP_PROVIDERS = previousSkipProviders;
}
if (previousSkipCron === undefined) {
delete process.env.OPENCLAW_SKIP_CRON;
} else {
process.env.OPENCLAW_SKIP_CRON = previousSkipCron;
}
if (previousMinimalGateway === undefined) {
delete process.env.OPENCLAW_TEST_MINIMAL_GATEWAY;
} else {
process.env.OPENCLAW_TEST_MINIMAL_GATEWAY = previousMinimalGateway;
}
} }
if (options.restoreEnv && tempHome) { if (options.restoreEnv && tempHome) {
await fs.rm(tempHome, { await fs.rm(tempHome, {