From 4e76983ad64c33e8284b7d6e959c692505700627 Mon Sep 17 00:00:00 2001 From: Eva Date: Mon, 4 May 2026 03:03:48 +0700 Subject: [PATCH] fix: keep cleanup alive across registry refresh --- src/plugins/runtime.test.ts | 68 +++++++++++++++++++++++++++++++++++++ src/plugins/runtime.ts | 6 ++-- 2 files changed, 71 insertions(+), 3 deletions(-) diff --git a/src/plugins/runtime.test.ts b/src/plugins/runtime.test.ts index a2541fb60c5..26e5c2b2b24 100644 --- a/src/plugins/runtime.test.ts +++ b/src/plugins/runtime.test.ts @@ -68,6 +68,15 @@ function expectRouteRegistryState(params: { setup: () => void; assert: () => voi params.assert(); } +async function waitForCleanupSignal(signal: Promise, label: string): Promise { + await Promise.race([ + signal, + new Promise((_, reject) => { + setTimeout(() => reject(new Error(`Timed out waiting for ${label}`)), 500); + }), + ]); +} + describe("plugin runtime route registry", () => { afterEach(() => { releasePinnedPluginHttpRouteRegistry(); @@ -209,6 +218,65 @@ describe("setActivePluginRegistry", () => { expect(listImportedRuntimePluginIds()).toEqual(["runtime-plugin"]); }); + it("continues cleanup when the same active registry is refreshed", async () => { + let releaseFirstCleanup: (() => void) | undefined; + let markFirstCleanupStarted!: () => void; + let markSecondCleanupCalled!: () => void; + const firstCleanupStarted = new Promise((resolve) => { + markFirstCleanupStarted = resolve; + }); + const secondCleanupCalled = new Promise((resolve) => { + markSecondCleanupCalled = resolve; + }); + const previous = createEmptyPluginRegistry(); + previous.plugins.push( + createPluginRecord({ + id: "cleanup-refresh-race", + name: "Cleanup Refresh Race", + status: "loaded", + }), + ); + previous.runtimeLifecycles = [ + { + pluginId: "cleanup-refresh-race", + pluginName: "Cleanup Refresh Race", + lifecycle: { + id: "first-cleanup", + async cleanup() { + markFirstCleanupStarted(); + await new Promise((resolve) => { + releaseFirstCleanup = resolve; + }); + }, + }, + source: "/virtual/cleanup-refresh-race/index.ts", + rootDir: "/virtual/cleanup-refresh-race", + }, + { + pluginId: "cleanup-refresh-race", + pluginName: "Cleanup Refresh Race", + lifecycle: { + id: "second-cleanup", + cleanup() { + markSecondCleanupCalled(); + }, + }, + source: "/virtual/cleanup-refresh-race/index.ts", + rootDir: "/virtual/cleanup-refresh-race", + }, + ]; + const next = createEmptyPluginRegistry(); + + setActivePluginRegistry(previous); + setActivePluginRegistry(next); + await waitForCleanupSignal(firstCleanupStarted, "first cleanup start"); + + setActivePluginRegistry(next); + releaseFirstCleanup?.(); + + await waitForCleanupSignal(secondCleanupCalled, "second cleanup"); + }); + it("includes plugin ids imported before registration failed", () => { recordImportedPluginId("broken-plugin"); diff --git a/src/plugins/runtime.ts b/src/plugins/runtime.ts index 1c8bd9c9e09..cd4cbcd0515 100644 --- a/src/plugins/runtime.ts +++ b/src/plugins/runtime.ts @@ -74,10 +74,10 @@ async function cleanupPreviousPluginHostRegistry(params: { if (!nextRegistry || nextRegistry === params.previousRegistry) { return; } - const cleanupActiveVersion = state.activeVersion; // Async cleanup must not clear state after another registry becomes live. - const shouldCleanup = () => - state.activeVersion === cleanupActiveVersion && state.activeRegistry === nextRegistry; + // Re-activating the same registry refreshes caches, but it is still the same + // live cleanup target and must not abort the old registry cleanup. + const shouldCleanup = () => state.activeRegistry === nextRegistry; await cleanupReplacedPluginHostRegistry({ cfg: getRuntimeConfig(), previousRegistry: params.previousRegistry,