From 36095decd7457f4da1635fed80f3b1d25c8bddd1 Mon Sep 17 00:00:00 2001 From: Dax Date: Wed, 2 Sep 2026 12:50:14 -0400 Subject: [PATCH] fix(core): preserve unchanged plugin prefix (#46857) --- packages/core/src/plugin.ts | 55 ++++++++++++------------ packages/core/test/plugin.test.ts | 70 +++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 27 deletions(-) diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index 36bf0601e47..90b4c45038a 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -98,17 +98,17 @@ const layer = Layer.effect( () => lock.withPermit( Effect.gen(function* () { - if ( - active.size === definitions.length && - Array.from(active.values()).every((entry, index) => { - const definition = definitions[index] - return entry.plugin.id === definition?.id && entry.plugin.revision === definition.revision - }) - ) { - for (const definition of definitions) { - const entry = active.get(definition.id) - if (entry) active.set(definition.id, { ...entry, plugin: definition }) - } + const current = Array.from(active.values()) + const changed = definitions.findIndex((definition, index) => { + const entry = current[index] + return entry?.plugin.id !== definition.id || entry.plugin.revision !== definition.revision + }) + const prefix = changed === -1 ? definitions.length : changed + for (const definition of definitions.slice(0, prefix)) { + const entry = active.get(definition.id) + if (entry) active.set(definition.id, { ...entry, plugin: definition }) + } + if (prefix === definitions.length && active.size === definitions.length) { const nextInventory = [...definitions.map(activeInfo), ...failures] if (JSON.stringify(inventory) === JSON.stringify(nextInventory)) return inventory = nextInventory @@ -118,12 +118,19 @@ const layer = Layer.effect( yield* State.batch( Effect.gen(function* () { - const nextInventory: Plugin.Info[] = [] - for (const definition of definitions) { - const previous = active.get(definition.id) - active.delete(definition.id) - if (previous) yield* Scope.close(previous.scope, Exit.void) - + // Registrations are ordered by setup, so only the unchanged prefix can stay alive. + const previous = new Map(Array.from(active.entries()).slice(prefix)) + yield* Effect.forEach( + Array.from(previous.entries()).toReversed(), + ([id, entry]) => + Effect.gen(function* () { + active.delete(id) + yield* Scope.close(entry.scope, Exit.void) + }), + { discard: true }, + ) + const nextInventory = definitions.slice(0, prefix).map(activeInfo) + for (const definition of definitions.slice(prefix)) { const loaded = yield* load(definition) if (loaded.scope !== undefined) { active.set(definition.id, { plugin: definition, scope: loaded.scope }) @@ -137,10 +144,11 @@ const layer = Layer.effect( features: { server: true, ...definition.features }, }) - if (!previous) continue - const restored = yield* load(previous.plugin) + const fallback = previous.get(definition.id) + if (!fallback) continue + const restored = yield* load(fallback.plugin) if (restored.scope !== undefined) { - active.set(definition.id, { plugin: previous.plugin, scope: restored.scope }) + active.set(definition.id, { plugin: fallback.plugin, scope: restored.scope }) continue } yield* Effect.logError("failed to restore plugin; deactivating", { @@ -148,13 +156,6 @@ const layer = Layer.effect( }) } - const removed = Array.from(active.entries()) - .filter(([id]) => !ids.has(id)) - .toReversed() - removed.forEach(([id]) => active.delete(id)) - yield* Effect.forEach(removed, ([, entry]) => Scope.close(entry.scope, Exit.void), { - discard: true, - }) inventory = [...nextInventory, ...failures] }), ) diff --git a/packages/core/test/plugin.test.ts b/packages/core/test/plugin.test.ts index 9ef9376af00..02bb7b1f086 100644 --- a/packages/core/test/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -13,6 +13,76 @@ import { PluginTestLayer } from "./plugin/fixture" const it = testEffect(PluginTestLayer) +for (const scenario of [ + { + name: "starts only the appended plugin", + before: ["a", "b"], + after: ["a", "b", "c"], + expected: ["start:c:1"], + }, + { + name: "restarts the suffix after an insertion", + before: ["a", "b", "c"], + after: ["a", "x", "b", "c"], + expected: ["stop:c:1", "stop:b:1", "start:x:1", "start:b:1", "start:c:1"], + }, + { + name: "restarts the suffix after a revision changes", + before: ["a", "b", "c"], + after: ["a", "b", "c"], + updated: "b", + expected: ["stop:c:1", "stop:b:1", "start:b:2", "start:c:1"], + }, + { + name: "stops only the removed trailing plugin", + before: ["a", "b", "c"], + after: ["a", "b"], + expected: ["stop:c:1"], + }, +]) { + it.effect(scenario.name, () => + Effect.gen(function* () { + const plugins = yield* Plugin.Service + const events: string[] = [] + const plugin = (id: string, revision = "1"): Plugin.Generation => ({ + id, + revision, + effect: () => + Effect.gen(function* () { + events.push(`start:${id}:${revision}`) + yield* Effect.addFinalizer(() => Effect.sync(() => events.push(`stop:${id}:${revision}`))) + }), + }) + + yield* plugins.activate(scenario.before.map((id) => plugin(id))) + events.length = 0 + yield* plugins.activate(scenario.after.map((id) => plugin(id, id === scenario.updated ? "2" : "1"))) + + expect(events).toEqual(scenario.expected) + expect((yield* plugins.list()).map((plugin) => plugin.id)).toEqual(scenario.after.map((id) => Plugin.ID.make(id))) + }), + ) +} + +it.effect("updates inventory metadata without restarting an unchanged generation", () => + Effect.gen(function* () { + const plugins = yield* Plugin.Service + let loads = 0 + const plugin = { + id: "metadata", + revision: "1", + source: { type: "package" as const, target: "fixture" }, + effect: () => Effect.sync(() => loads++), + } + + yield* plugins.activate([plugin]) + yield* plugins.activate([{ ...plugin, source: { ...plugin.source, outdated: true } }]) + + expect(loads).toBe(1) + expect((yield* plugins.list())[0]?.source).toEqual({ type: "package", target: "fixture", outdated: true }) + }), +) + it.live("loads a local plugin with its configured options", () => Effect.gen(function* () { const plugins = yield* Plugin.Service