mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:10:44 +00:00
fix(gateway): clear fallback context on close
Fixes gateway fallback request context cleanup on close/startup failure and shards the full gateway Vitest lane to avoid the observed memory hang.\n\nValidation:\n- Testbox: OPENCLAW_TESTBOX=1 pnpm check:changed\n- Testbox: env OPENCLAW_VITEST_MAX_WORKERS=1 /usr/bin/time -v pnpm test:gateway (254 files, 2950 tests, max RSS 4144692 KB)
This commit is contained in:
@@ -1533,7 +1533,7 @@
|
||||
"test:extensions:package-boundary:compile": "node scripts/check-extension-package-tsc-boundary.mjs --mode=compile",
|
||||
"test:fast": "node scripts/run-vitest.mjs run --config test/vitest/vitest.unit.config.ts",
|
||||
"test:force": "node --import tsx scripts/test-force.ts",
|
||||
"test:gateway": "node scripts/run-vitest.mjs run --config test/vitest/vitest.gateway.config.ts",
|
||||
"test:gateway": "OPENCLAW_GATEWAY_PROJECT_SHARDS=1 node scripts/run-vitest.mjs run --config test/vitest/vitest.gateway.config.ts",
|
||||
"test:gateway:watch-regression": "node scripts/check-gateway-watch-regression.mjs",
|
||||
"test:install:e2e": "bash scripts/test-install-sh-e2e-docker.sh",
|
||||
"test:install:e2e:anthropic": "OPENCLAW_E2E_MODELS=anthropic bash scripts/test-install-sh-e2e-docker.sh",
|
||||
@@ -1564,7 +1564,7 @@
|
||||
"test:perf:imports:changed": "OPENCLAW_VITEST_IMPORT_DURATIONS=1 OPENCLAW_VITEST_PRINT_IMPORT_BREAKDOWN=1 node scripts/test-projects.mjs --changed origin/main",
|
||||
"test:perf:profile:main": "node scripts/run-vitest-profile.mjs main",
|
||||
"test:perf:profile:runner": "node scripts/run-vitest-profile.mjs runner",
|
||||
"test:sectriage": "node scripts/run-vitest.mjs run --config test/vitest/vitest.gateway.config.ts && node scripts/run-vitest.mjs run --config test/vitest/vitest.unit.config.ts --exclude src/daemon/launchd.integration.test.ts --exclude src/process/exec.test.ts",
|
||||
"test:sectriage": "OPENCLAW_GATEWAY_PROJECT_SHARDS=1 node scripts/run-vitest.mjs run --config test/vitest/vitest.gateway.config.ts && node scripts/run-vitest.mjs run --config test/vitest/vitest.unit.config.ts --exclude src/daemon/launchd.integration.test.ts --exclude src/process/exec.test.ts",
|
||||
"test:serial": "OPENCLAW_TEST_PROJECTS_SERIAL=1 OPENCLAW_VITEST_MAX_WORKERS=1 node scripts/test-projects.mjs",
|
||||
"test:stability:gateway": "OPENCLAW_VITEST_MAX_WORKERS=1 node scripts/run-vitest.mjs run --config test/vitest/vitest.gateway.config.ts src/gateway/gateway-stability.test.ts && OPENCLAW_VITEST_MAX_WORKERS=1 node scripts/run-vitest.mjs run --config test/vitest/vitest.logging.config.ts src/logging/diagnostic-stability-bundle.test.ts && OPENCLAW_VITEST_MAX_WORKERS=1 node scripts/run-vitest.mjs run --config test/vitest/vitest.infra.config.ts src/infra/fatal-error-hooks.test.ts",
|
||||
"test:startup:bench": "node --import tsx scripts/bench-cli-startup.ts",
|
||||
|
||||
28
src/gateway/server-plugins.lifecycle.test.ts
Normal file
28
src/gateway/server-plugins.lifecycle.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { clearFallbackGatewayContext, createGatewaySubagentRuntime } from "./server-plugins.js";
|
||||
import { installGatewayTestHooks, startServer } from "./test-helpers.server.js";
|
||||
|
||||
installGatewayTestHooks();
|
||||
|
||||
afterEach(() => {
|
||||
clearFallbackGatewayContext();
|
||||
});
|
||||
|
||||
describe("gateway plugin fallback context lifecycle", () => {
|
||||
it("clears the fallback gateway context after server close", async () => {
|
||||
const runtime = createGatewaySubagentRuntime();
|
||||
const started = await startServer();
|
||||
|
||||
try {
|
||||
await expect(
|
||||
runtime.getSessionMessages({ sessionKey: "agent:main:main", limit: 1 }),
|
||||
).resolves.toEqual({ messages: [] });
|
||||
} finally {
|
||||
await started.server.close({ reason: "fallback context lifecycle test done" });
|
||||
}
|
||||
|
||||
await expect(
|
||||
runtime.getSessionMessages({ sessionKey: "agent:main:main", limit: 1 }),
|
||||
).rejects.toThrow("No scope set and no fallback context available");
|
||||
});
|
||||
});
|
||||
@@ -344,6 +344,7 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
serverPluginsModule.clearFallbackGatewayContext();
|
||||
runtimeModule.clearGatewaySubagentRuntime();
|
||||
runtimeRegistryModule.resetPluginRuntimeStateForTest();
|
||||
});
|
||||
@@ -1388,4 +1389,52 @@ describe("loadGatewayPlugins", () => {
|
||||
await runtime.run({ sessionKey: "s-5", message: "prefer resolver" });
|
||||
expect(getLastDispatchedContext()).toBe(freshContext);
|
||||
});
|
||||
|
||||
test("clears fallback context snapshots when a resolver is registered", async () => {
|
||||
const serverPlugins = serverPluginsModule;
|
||||
const runtime = await createSubagentRuntime(serverPlugins);
|
||||
const staleContext = createTestContext("stale-snapshot");
|
||||
|
||||
serverPlugins.setFallbackGatewayContext(staleContext);
|
||||
serverPlugins.setFallbackGatewayContextResolver(() => undefined);
|
||||
|
||||
await expect(runtime.run({ sessionKey: "s-6", message: "stale fallback" })).rejects.toThrow(
|
||||
"No scope set and no fallback context available",
|
||||
);
|
||||
});
|
||||
|
||||
test("clears fallback context and resolver state", async () => {
|
||||
const serverPlugins = serverPluginsModule;
|
||||
const runtime = await createSubagentRuntime(serverPlugins);
|
||||
const context = createTestContext("clear-context");
|
||||
|
||||
serverPlugins.setFallbackGatewayContextResolver(() => context);
|
||||
await runtime.run({ sessionKey: "s-7", message: "before clear" });
|
||||
expect(getLastDispatchedContext()).toBe(context);
|
||||
|
||||
serverPlugins.clearFallbackGatewayContext();
|
||||
|
||||
await expect(runtime.run({ sessionKey: "s-7", message: "after clear" })).rejects.toThrow(
|
||||
"No scope set and no fallback context available",
|
||||
);
|
||||
});
|
||||
|
||||
test("resolver cleanup only clears the resolver it registered", async () => {
|
||||
const serverPlugins = serverPluginsModule;
|
||||
const runtime = await createSubagentRuntime(serverPlugins);
|
||||
const firstContext = createTestContext("first-owner");
|
||||
const secondContext = createTestContext("second-owner");
|
||||
|
||||
const clearFirst = serverPlugins.setFallbackGatewayContextResolver(() => firstContext);
|
||||
const clearSecond = serverPlugins.setFallbackGatewayContextResolver(() => secondContext);
|
||||
|
||||
clearFirst();
|
||||
await runtime.run({ sessionKey: "s-8", message: "after first cleanup" });
|
||||
expect(getLastDispatchedContext()).toBe(secondContext);
|
||||
|
||||
clearSecond();
|
||||
await expect(
|
||||
runtime.run({ sessionKey: "s-8", message: "after second cleanup" }),
|
||||
).rejects.toThrow("No scope set and no fallback context available");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,17 +44,40 @@ const getFallbackGatewayContextState = () =>
|
||||
resolveContext: undefined,
|
||||
}));
|
||||
|
||||
export function setFallbackGatewayContext(ctx: GatewayRequestContext): void {
|
||||
export function setFallbackGatewayContext(ctx: GatewayRequestContext): () => void {
|
||||
const fallbackGatewayContextState = getFallbackGatewayContextState();
|
||||
fallbackGatewayContextState.context = ctx;
|
||||
fallbackGatewayContextState.resolveContext = undefined;
|
||||
return () => {
|
||||
const currentFallbackGatewayContextState = getFallbackGatewayContextState();
|
||||
if (
|
||||
currentFallbackGatewayContextState.context === ctx &&
|
||||
currentFallbackGatewayContextState.resolveContext === undefined
|
||||
) {
|
||||
currentFallbackGatewayContextState.context = undefined;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function setFallbackGatewayContextResolver(
|
||||
resolveContext: () => GatewayRequestContext | undefined,
|
||||
): void {
|
||||
): () => void {
|
||||
const fallbackGatewayContextState = getFallbackGatewayContextState();
|
||||
fallbackGatewayContextState.context = undefined;
|
||||
fallbackGatewayContextState.resolveContext = resolveContext;
|
||||
return () => {
|
||||
const currentFallbackGatewayContextState = getFallbackGatewayContextState();
|
||||
if (currentFallbackGatewayContextState.resolveContext === resolveContext) {
|
||||
currentFallbackGatewayContextState.context = undefined;
|
||||
currentFallbackGatewayContextState.resolveContext = undefined;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function clearFallbackGatewayContext(): void {
|
||||
const fallbackGatewayContextState = getFallbackGatewayContextState();
|
||||
fallbackGatewayContextState.context = undefined;
|
||||
fallbackGatewayContextState.resolveContext = undefined;
|
||||
}
|
||||
|
||||
function getFallbackGatewayContext(): GatewayRequestContext | undefined {
|
||||
|
||||
@@ -121,6 +121,9 @@ const mocks = vi.hoisted(() => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.unmock("./server-restart-sentinel.js");
|
||||
vi.resetModules();
|
||||
|
||||
vi.mock("../agents/agent-scope.js", () => ({
|
||||
resolveSessionAgentId: mocks.resolveSessionAgentId,
|
||||
}));
|
||||
|
||||
@@ -726,9 +726,14 @@ export async function startGatewayServer(
|
||||
httpServers,
|
||||
})(opts);
|
||||
};
|
||||
let clearFallbackGatewayContextForServer = () => {};
|
||||
const closeOnStartupFailure = async () => {
|
||||
await runClosePrelude();
|
||||
await createCloseHandler()({ reason: "gateway startup failed" });
|
||||
try {
|
||||
await runClosePrelude();
|
||||
await createCloseHandler()({ reason: "gateway startup failed" });
|
||||
} finally {
|
||||
clearFallbackGatewayContextForServer();
|
||||
}
|
||||
};
|
||||
const broadcastVoiceWakeRoutingChanged = (config: VoiceWakeRoutingConfig) => {
|
||||
broadcast("voicewake.routing.changed", { config }, { dropIfSlow: true });
|
||||
@@ -888,7 +893,15 @@ export async function startGatewayServer(
|
||||
broadcastVoiceWakeRoutingChanged,
|
||||
});
|
||||
|
||||
setFallbackGatewayContextResolver(() => gatewayRequestContext);
|
||||
const fallbackGatewayContextCleanup: unknown = setFallbackGatewayContextResolver(
|
||||
() => gatewayRequestContext,
|
||||
);
|
||||
clearFallbackGatewayContextForServer =
|
||||
typeof fallbackGatewayContextCleanup === "function"
|
||||
? () => {
|
||||
fallbackGatewayContextCleanup();
|
||||
}
|
||||
: () => {};
|
||||
|
||||
if (!minimalTestGateway) {
|
||||
if (deferredConfiguredChannelPluginIds.length > 0) {
|
||||
@@ -1039,14 +1052,18 @@ export async function startGatewayServer(
|
||||
|
||||
return {
|
||||
close: async (opts) => {
|
||||
// Run gateway_stop plugin hook before shutdown
|
||||
await runGlobalGatewayStopSafely({
|
||||
event: { reason: opts?.reason ?? "gateway stopping" },
|
||||
ctx: { port },
|
||||
onError: (err) => log.warn(`gateway_stop hook failed: ${String(err)}`),
|
||||
});
|
||||
await runClosePrelude();
|
||||
await close(opts);
|
||||
try {
|
||||
// Run gateway_stop plugin hook before shutdown
|
||||
await runGlobalGatewayStopSafely({
|
||||
event: { reason: opts?.reason ?? "gateway stopping" },
|
||||
ctx: { port },
|
||||
onError: (err) => log.warn(`gateway_stop hook failed: ${String(err)}`),
|
||||
});
|
||||
await runClosePrelude();
|
||||
await close(opts);
|
||||
} finally {
|
||||
clearFallbackGatewayContextForServer();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -237,7 +237,7 @@ describe("test scripts", () => {
|
||||
expect(pkg.scripts?.["test"]).toBe("node scripts/test-projects.mjs");
|
||||
expect(pkg.scripts?.["test:force"]).toBe("node --import tsx scripts/test-force.ts");
|
||||
expect(pkg.scripts?.["test:gateway"]).toBe(
|
||||
"node scripts/run-vitest.mjs run --config test/vitest/vitest.gateway.config.ts",
|
||||
"OPENCLAW_GATEWAY_PROJECT_SHARDS=1 node scripts/run-vitest.mjs run --config test/vitest/vitest.gateway.config.ts",
|
||||
);
|
||||
expect(pkg.scripts?.["test:single"]).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { createProjectShardVitestConfig } from "./vitest.project-shard-config.ts";
|
||||
import { createScopedVitestConfig } from "./vitest.scoped-config.ts";
|
||||
|
||||
const gatewayProjectConfigs = [
|
||||
"test/vitest/vitest.gateway-core.config.ts",
|
||||
"test/vitest/vitest.gateway-client.config.ts",
|
||||
"test/vitest/vitest.gateway-methods.config.ts",
|
||||
"test/vitest/vitest.gateway-server.config.ts",
|
||||
] as const;
|
||||
|
||||
export function createGatewayVitestConfig(env?: Record<string, string | undefined>) {
|
||||
return createScopedVitestConfig(["src/gateway/**/*.test.ts"], {
|
||||
dir: "src/gateway",
|
||||
@@ -13,4 +21,10 @@ export function createGatewayVitestConfig(env?: Record<string, string | undefine
|
||||
});
|
||||
}
|
||||
|
||||
export default createGatewayVitestConfig();
|
||||
export function createGatewayProjectShardVitestConfig() {
|
||||
return createProjectShardVitestConfig(gatewayProjectConfigs);
|
||||
}
|
||||
|
||||
export default process.env.OPENCLAW_GATEWAY_PROJECT_SHARDS === "1"
|
||||
? createGatewayProjectShardVitestConfig()
|
||||
: createGatewayVitestConfig();
|
||||
|
||||
Reference in New Issue
Block a user