From ec78e8e0d69bcfe9fcc5aab3d218ce2b193a2b4b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 24 Sep 2026 13:18:36 -0700 Subject: [PATCH] fix(test): prevent plugin cache accumulation in shared workers (#157470) * fix(test): settle retired plugin caches between cases Join the canonical plugin cache retirement owner after shared registry resets and final suite cleanup, preserving borrowed caches and reporting cleanup failures. Retired metadata previously accumulated across Gateway test files until the shared worker exhausted its heap. * test(gateway): settle expected plugin retirement failures --- src/gateway/server-close.model-acquisition.test.ts | 5 +++++ src/gateway/server-close.plugin-failure.test.ts | 7 +++++++ test/setup-openclaw-runtime.ts | 12 ++++++++++++ 3 files changed, 24 insertions(+) diff --git a/src/gateway/server-close.model-acquisition.test.ts b/src/gateway/server-close.model-acquisition.test.ts index 4da832e07acc..ac4bad9a2b2b 100644 --- a/src/gateway/server-close.model-acquisition.test.ts +++ b/src/gateway/server-close.model-acquisition.test.ts @@ -10,6 +10,7 @@ import { registerPreparedModelRuntimePublicationListener } from "../agents/prepa import { registerPreparedModelRuntimeClose } from "../agents/prepared-model-runtime.lifecycle.js"; import { getPreparedModelRuntimeStartupStatus } from "../agents/prepared-model-runtime.startup-status.js"; import { GATEWAY_SHUTDOWN_TIMEOUT_MS } from "../infra/gateway-shutdown-budget.js"; +import { waitForPluginCacheRetirement } from "../plugins/plugin-cache.js"; import { getPluginValueInstance } from "../plugins/plugin-instance-scope.js"; import { createDeferredCore } from "../shared/deferred.js"; import { createGatewayMetadataCloseFixture } from "./server-close.metadata.test-support.js"; @@ -189,6 +190,10 @@ it.each(["static catalog", "synthetic auth"] as const)( if (cleanupFailure) { // Other shutdown work must not hide a discarded plugin cleanup outcome. expect(collectNestedErrorCandidates(closeError)).toContain(cleanupFailure); + // The process-cache reset retains the same outcome for its next observer. + expect((await waitForPluginCacheRetirement()).failures).toEqual([ + { pluginId: fixture.pluginId, hookId: "instance", error: cleanupFailure }, + ]); } else { expect(closeError).toBeUndefined(); } diff --git a/src/gateway/server-close.plugin-failure.test.ts b/src/gateway/server-close.plugin-failure.test.ts index 369f76fd2cfc..c9469129cabd 100644 --- a/src/gateway/server-close.plugin-failure.test.ts +++ b/src/gateway/server-close.plugin-failure.test.ts @@ -11,6 +11,7 @@ import { import { SUPERVISOR_HINT_ENV_VARS } from "../infra/supervisor-markers.js"; import { flushLogger, setLoggerOverride } from "../logging/logger.js"; import { getGatewayPluginMetadataSnapshot } from "../plugins/current-plugin-metadata-state.js"; +import { waitForPluginCacheRetirement } from "../plugins/plugin-cache.js"; import { getPluginValueInstance } from "../plugins/plugin-instance-scope.js"; import { PluginInstance } from "../plugins/plugin-instance.js"; import type { MemoryPluginRuntime } from "../plugins/registry-contribution-types.js"; @@ -218,6 +219,12 @@ it.each(["final", "sibling", "cache", "restart", "memory-and-plugin", "memory-on if (hasPluginFailure) { expect.soft(collectNestedErrorCandidates(error)).toContain(pluginFailure); expect(pluginSawOpenDatabase).toBe(true); + if (mode === "cache" || mode === "restart") { + // The process-cache reset retains the same outcome for its next observer. + expect((await waitForPluginCacheRetirement()).failures).toEqual([ + { pluginId: fixture.pluginId, hookId: "instance", error: pluginFailure }, + ]); + } } else { expect(error).toBeUndefined(); } diff --git a/test/setup-openclaw-runtime.ts b/test/setup-openclaw-runtime.ts index 172f55b651fc..082df76f2f6e 100644 --- a/test/setup-openclaw-runtime.ts +++ b/test/setup-openclaw-runtime.ts @@ -340,6 +340,16 @@ function resolveDefaultPluginRegistryProxy(): PluginRegistry { return workerRuntimeState.defaultPluginRegistry; } +async function settlePluginCacheRetirements(): Promise { + const { waitForPluginCacheRetirement } = await vi.importActual< + typeof import("../src/plugins/plugin-cache.js") + >("../src/plugins/plugin-cache.js"); + const { failures } = await waitForPluginCacheRetirement(); + if (failures.length > 0) { + throw new AggregateError(failures, "Plugin cache retirement failed during test cleanup"); + } +} + async function installDefaultPluginRegistry(): Promise { // Worker module resets retire the lifecycle maps. Activate through the current // real module, never a cached closure or a suite's partial runtime mock. @@ -349,6 +359,7 @@ async function installDefaultPluginRegistry(): Promise { workerRuntimeState.materializedDefaultPluginRegistry = null; resetPluginRuntimeStateForTest(); setActivePluginRegistry(resolveDefaultPluginRegistryProxy()); + await settlePluginCacheRetirements(); } // Some suites import channel/plugin consumers at module top level, before @@ -393,4 +404,5 @@ afterAll(async () => { await drainSessionStoreWriterQueuesForTest(); clearSessionStoreCacheForTest(); await drainFileLockStateForTest(); + await settlePluginCacheRetirements(); });