From 195a620e38cb31f2cd693378823cb18e9393714a Mon Sep 17 00:00:00 2001 From: Elliot Braem Date: Fri, 30 Oct 2026 22:40:47 -0500 Subject: [PATCH] =?UTF-8?q?fix(host):=20isolate=20any=20per-plugin=20boots?= =?UTF-8?q?trap=20failure=20=E2=80=94=20defects=20and=20config=20errors=20?= =?UTF-8?q?no=20longer=20take=20down=20the=20whole=20plugin=20system=20(#6?= =?UTF-8?q?72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - secrets resolution and auth base-variable derivation moved inside the guarded per-plugin load; failures and thrown defects are normalized into PluginBootstrapError (pluginFailureCause) and recorded per plugin - failure entries now feed every kind (auth/api included) a failedPlugins client entry so dependents fail loudly instead of the loop dying - interrupts-only causes are re-raised; a caught defect names the plugin and its failing stage - regression coverage: pluginFailureCause unit tests + an initializePlugins integration suite booting a failing plugin beside a healthy one --- host/src/services/plugins.ts | 100 +++++++++----- .../plugin-failure-isolation.test.ts | 123 ++++++++++++++++++ host/tests/unit/plugins-failure-cause.test.ts | 43 ++++++ 3 files changed, 234 insertions(+), 32 deletions(-) create mode 100644 host/tests/integration/plugin-failure-isolation.test.ts create mode 100644 host/tests/unit/plugins-failure-cause.test.ts diff --git a/host/src/services/plugins.ts b/host/src/services/plugins.ts index 548792f6d..dec2efd6d 100644 --- a/host/src/services/plugins.ts +++ b/host/src/services/plugins.ts @@ -1,6 +1,6 @@ import { createInstance, getInstance } from "@module-federation/enhanced/runtime"; import { setGlobalFederationInstance } from "@module-federation/runtime-core"; -import { Config, Context, Data, Effect, Layer, Option, Redacted } from "effect"; +import { Cause, Config, Context, Data, Effect, Layer, Option, Redacted } from "effect"; import { createPluginRuntime } from "every-plugin"; import type { PluginLoadFailureInfo, PluginWarningInfo } from "every-plugin/errors"; import { @@ -29,7 +29,7 @@ import { originMatches } from "../utils/origin-match"; import { ConfigService, readCorsOrigins } from "./config"; import { PluginError } from "./errors"; -class PluginBootstrapError extends Data.TaggedError("PluginBootstrapError")<{ +export class PluginBootstrapError extends Data.TaggedError("PluginBootstrapError")<{ pluginKey: string; pluginUrl?: string; stage: "load" | "init" | "db-preflight" | "db-migration"; @@ -420,15 +420,38 @@ const logBootstrapError = Effect.fn("logBootstrapError")(function* (err: PluginB } }); +/** + * Normalize any per-plugin failure — a typed `PluginBootstrapError`, a foreign + * typed failure (config reads, auth origin derivation), or a thrown defect — + * into the `PluginBootstrapError` the load loop records. A typed + * `PluginBootstrapError` passes through unchanged so stage attribution and + * failure classification are preserved. + */ +export function pluginFailureCause( + entry: { key: string; config: { url?: string } }, + cause: Cause.Cause, +): PluginBootstrapError { + const fail = cause.reasons.find(Cause.isFailReason); + if (fail?.error instanceof PluginBootstrapError) return fail.error; + return new PluginBootstrapError({ + pluginKey: entry.key, + pluginUrl: entry.config?.url ?? "unknown", + stage: "init", + execution: "local-host-process", + cause: Cause.squash(cause), + }); +} + const loadPluginEntryEffect = Effect.fn("loadPluginEntryEffect")(function* (params: { runtime: any; entry: RuntimePluginEntry; integrityRegistry: IntegrityRegistry; env: BosEnv; + config: RuntimeConfig; + corsOrigins: string[]; pluginsClient?: Record; - baseVariables?: Record; }) { - const { runtime, entry, integrityRegistry, env, pluginsClient, baseVariables } = params; + const { runtime, entry, integrityRegistry, env, config, corsOrigins, pluginsClient } = params; if (entry.config.integrity) { integrityRegistry.registerEntry(entry.config.url, entry.config.integrity); } @@ -443,8 +466,22 @@ const loadPluginEntryEffect = Effect.fn("loadPluginEntryEffect")(function* (para : "API_DATABASE_URL" : pluginDbSecretKey; - const variables: Record = { ...baseVariables, ...entry.config.variables }; - const secrets = yield* resolveSecretsWithDatabaseFallback(entry.config.secrets ?? []); + const variables: Record = { + ...(entry.key === "auth" ? yield* buildAuthBaseVariables(config, corsOrigins) : undefined), + ...entry.config.variables, + }; + const secrets = yield* resolveSecretsWithDatabaseFallback(entry.config.secrets ?? []).pipe( + Effect.mapError( + (cause) => + new PluginBootstrapError({ + pluginKey: entry.key, + pluginUrl: entry.config.url, + stage: "init", + execution: "local-host-process", + cause, + }), + ), + ); const dbSecret = secretKey ? secrets[secretKey] ? Redacted.make(secrets[secretKey]) @@ -631,6 +668,26 @@ export const initializePlugins = Effect.fn("initializePlugins")( let authClient: ((ctx?: unknown) => unknown) | null = null; let baseApi: HostPluginEntry | null = null; + const recordPluginFailure = Effect.fn("recordPluginFailure")(function* ( + err: PluginBootstrapError, + ) { + yield* logBootstrapError(err); + errors.push(err.message); + failures.push({ + pluginKey: err.pluginKey, + pluginUrl: err.pluginUrl, + operation: err.operation, + kind: err.classification.kind, + retryable: err.classification.retryable, + message: err.message, + suggestion: err.classification.suggestion, + dbSecret: err.dbSecret, + dbUrlMasked: err.dbUrlMasked, + }); + pluginsClient[err.pluginKey] = failedPluginsClientEntry(err.message); + return null; + }); + for (const key of loadableEntries) { const entry = entryMap.get(key)!; const node = dag.nodes.get(key)!; @@ -661,11 +718,6 @@ export const initializePlugins = Effect.fn("initializePlugins")( } } - let baseVariables: Record | undefined; - if (node.kind === "auth") { - baseVariables = yield* buildAuthBaseVariables(config, corsOrigins); - } - yield* Effect.logInfo(`[Plugins][${key}] Loading (${entry.config.name})`); const result = yield* loadPluginEntryEffect({ @@ -673,29 +725,13 @@ export const initializePlugins = Effect.fn("initializePlugins")( entry, integrityRegistry, env: config.env, + config, + corsOrigins, pluginsClient: Object.keys(nodePluginsClient).length > 0 ? nodePluginsClient : undefined, - baseVariables, }).pipe( - Effect.catchTag("PluginBootstrapError", (err: PluginBootstrapError) => - Effect.gen(function* () { - yield* logBootstrapError(err); - errors.push(err.message); - failures.push({ - pluginKey: err.pluginKey, - pluginUrl: err.pluginUrl, - operation: err.operation, - kind: err.classification.kind, - retryable: err.classification.retryable, - message: err.message, - suggestion: err.classification.suggestion, - dbSecret: err.dbSecret, - dbUrlMasked: err.dbUrlMasked, - }); - if (node.kind === "plugin") { - pluginsClient[key] = failedPluginsClientEntry(err.message); - } - return null; - }), + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => recordPluginFailure(pluginFailureCause(entry, cause)), ), ); diff --git a/host/tests/integration/plugin-failure-isolation.test.ts b/host/tests/integration/plugin-failure-isolation.test.ts new file mode 100644 index 000000000..605ac63e2 --- /dev/null +++ b/host/tests/integration/plugin-failure-isolation.test.ts @@ -0,0 +1,123 @@ +import { ConfigProvider, Effect } from "effect"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { RuntimeConfig } from "../../src/services/config"; + +const usePluginMock = vi.fn(); + +vi.mock("@module-federation/enhanced/runtime", () => ({ + createInstance: () => null, +})); +vi.mock("every-plugin", () => ({ + createPluginRuntime: () => ({ __mfInstance: undefined, usePlugin: usePluginMock }), +})); +vi.mock("every-plugin/remote-entry", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + loadRemoteWithRetry: (options: Parameters[0]) => + original.loadRemoteWithRetry({ + // Tests stub `load`; the cold-compile poll would hit the network. + ...options, + readinessTimeoutMs: 0, + }), + }; +}); +vi.mock("everything-dev/integrity", () => ({ + IntegrityRegistry: class { + registerEntry() {} + }, + verifyConfigAgainstChain: async () => ({ verified: true, mismatches: [] }), +})); + +const { initializePlugins } = await import("../../src/services/plugins"); +const { ConfigService } = await import("../../src/services/config"); + +function makeLoaded(key: string) { + return { + createClient: (): unknown => ({}), + router: { key }, + metadata: { remoteUrl: `https://cdn.example.com/${key}`, version: "1.0.0" }, + name: key, + }; +} + +function slot(name: string) { + return { + name, + url: `https://cdn.example.com/${name}`, + entry: `https://cdn.example.com/${name}/mf-manifest.json`, + entryUrl: `https://cdn.example.com/${name}/remoteEntry.js`, + source: "remote" as const, + }; +} + +function makeConfig(overrides: Partial, pluginNames: string[]): RuntimeConfig { + return { + env: "production", + account: "test.near", + networkId: "mainnet", + ui: slot("ui"), + api: { name: "api" }, + plugins: Object.fromEntries(pluginNames.map((name) => [name, slot(name)])), + ...overrides, + } as unknown as RuntimeConfig; +} + +function run(currentConfig: RuntimeConfig) { + return Effect.runPromise( + Effect.scoped( + initializePlugins().pipe( + Effect.provideService( + // Hermetic env — vitest's ambient "/" BASE_URL must not leak into the + // origin derivation under test. + ConfigProvider.ConfigProvider, + ConfigProvider.fromEnv({}), + ), + Effect.provideService(ConfigService, currentConfig), + ), + ), + ); +} + +beforeEach(() => { + usePluginMock.mockReset(); +}); + +describe("plugin failure isolation", () => { + it("still loads a healthy plugin when another plugin's bundle fails to load", async () => { + usePluginMock.mockImplementation((runtimeId: string) => + runtimeId === "dead" + ? Promise.reject(new Error("Module not found: dead/exploded-remote")) + : Promise.resolve(makeLoaded(runtimeId)), + ); + + const result = await run(makeConfig({}, ["dead", "votes"])); + + expect(result.status.loadedPlugins).toContain("votes"); + expect(result.status.loadedPlugins).not.toContain("dead"); + expect(result.plugins.votes).toBeDefined(); + expect(result.status.failures).toHaveLength(1); + expect(result.status.failures[0]?.pluginKey).toBe("dead"); + }); + + it("isolates a plugin whose bootstrap dies fatally (production origin misconfig)", async () => { + usePluginMock.mockImplementation((runtimeId: string) => Promise.resolve(makeLoaded(runtimeId))); + + const result = await run( + makeConfig( + { + host: undefined, + domain: undefined, + auth: { ...slot("auth"), name: "auth" }, + } as Partial, + ["votes"], + ), + ); + + expect(result.auth).toBeNull(); + expect(result.status.loadedPlugins).toContain("votes"); + expect(result.status.failures).toHaveLength(1); + expect(result.status.failures[0]?.pluginKey).toBe("auth"); + expect(result.status.failures[0]?.message).toContain("No reachable origin"); + }); +}); diff --git a/host/tests/unit/plugins-failure-cause.test.ts b/host/tests/unit/plugins-failure-cause.test.ts new file mode 100644 index 000000000..c03c3991e --- /dev/null +++ b/host/tests/unit/plugins-failure-cause.test.ts @@ -0,0 +1,43 @@ +import { Cause } from "effect"; +import { describe, expect, it } from "vitest"; +import { PluginBootstrapError, pluginFailureCause } from "../../src/services/plugins"; + +const entry = { key: "votes", config: { url: "http://cdn.example.com/votes" } }; + +describe("pluginFailureCause", () => { + it("passes a typed PluginBootstrapError through unchanged", () => { + const typed = new PluginBootstrapError({ + pluginKey: "votes", + pluginUrl: entry.config.url, + stage: "db-migration", + dbSecret: "VOTES_DATABASE_URL", + dbUrlMasked: "postgres://…", + execution: "local-host-process", + cause: new Error("migration boom"), + }); + + expect(pluginFailureCause(entry, Cause.fail(typed))).toBe(typed); + }); + + it("synthesizes a stage-init error naming the plugin when a defect escapes", () => { + const defect = new TypeError("undefined is not a function"); + + const err = pluginFailureCause(entry, Cause.die(defect)); + + expect(err._tag).toBe("PluginBootstrapError"); + expect(err.pluginKey).toBe("votes"); + expect(err.pluginUrl).toBe(entry.config.url); + expect(err.stage).toBe("init"); + expect(err.cause).toBe(defect); + }); + + it("synthesizes the same way for foreign typed failures", () => { + const foreign = new Error("config fell over"); + + const err = pluginFailureCause(entry, Cause.fail(foreign)); + + expect(err.pluginKey).toBe("votes"); + expect(err.stage).toBe("init"); + expect(err.cause).toBe(foreign); + }); +});