mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-29 10:19:36 +08:00
fix(plugins): keep live callbacks working after another instance retires (#145804)
Bind migration and compaction calls to the selected registration record, and HTTP calls to the captured route owner and selected registry. Preserve raw descriptors, admitted work, retry handoffs, and upgrade fallback. Validation: 196 focused owner/sibling tests, final HTTP loader and disposal checks, full changed gate, and independent P2 review.
This commit is contained in:
1 parent
629e2a9a65
commit
b25510f18d
7 files changed
+564
-51
No files matched your search
@@ -0,0 +1,295 @@
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createPluginRuntimeCapabilityLease } from "../../plugins/capability-lease.js";
|
||||
import {
|
||||
createPluginHttpRouteHandoff,
|
||||
registerPluginHttpRoute,
|
||||
withPluginHttpRouteRegistry,
|
||||
} from "../../plugins/http-registry.js";
|
||||
import { runPluginRegisterSyncInRegistry } from "../../plugins/loader-module-runtime.js";
|
||||
import { resolvePluginModuleExport } from "../../plugins/module-export.js";
|
||||
import {
|
||||
getPluginInstance,
|
||||
type PluginInstanceHandle,
|
||||
} from "../../plugins/plugin-instance-scope.js";
|
||||
import { loadBundledPluginPublicArtifactModuleSync } from "../../plugins/public-surface-loader.js";
|
||||
import { projectPluginContributions } from "../../plugins/registry-contributions.js";
|
||||
import {
|
||||
createEmptyPluginRegistry,
|
||||
createPluginRegistry,
|
||||
type PluginRegistry,
|
||||
} from "../../plugins/registry.js";
|
||||
import { getPluginRuntimeGatewayRequestScope } from "../../plugins/runtime/gateway-request-scope.js";
|
||||
import type { PluginRuntime } from "../../plugins/runtime/types.js";
|
||||
import { createPluginRecord } from "../../plugins/status.test-helpers.js";
|
||||
import { createDeferredCore } from "../../shared/deferred.js";
|
||||
import { makeMockHttpResponse } from "../test-http-response.js";
|
||||
import {
|
||||
createGatewayPluginRequestHandler,
|
||||
createGatewayPluginUpgradeHandler,
|
||||
} from "./plugins-http.js";
|
||||
|
||||
const instances = new Set<PluginInstanceHandle>();
|
||||
|
||||
function createOwner(
|
||||
id = "shared-route",
|
||||
builder = createPluginRegistry({
|
||||
logger: { info() {}, warn() {}, error() {}, debug() {} },
|
||||
runtime: {} as PluginRuntime,
|
||||
activateGlobalSideEffects: false,
|
||||
}),
|
||||
) {
|
||||
const record = createPluginRecord({ id });
|
||||
builder.registry.plugins.push(record);
|
||||
const api = builder.createApi(record, { config: {} });
|
||||
const instance = getPluginInstance(record)!;
|
||||
instances.add(instance);
|
||||
return { builder, registry: builder.registry, record, api, instance };
|
||||
}
|
||||
|
||||
async function dispatch(
|
||||
registry: PluginRegistry,
|
||||
transport: "HTTP" | "WebSocket" = "HTTP",
|
||||
path = "/owned",
|
||||
) {
|
||||
const warn = vi.fn();
|
||||
const log = { warn } as unknown as Parameters<typeof createGatewayPluginRequestHandler>[0]["log"];
|
||||
const req = { url: path, method: "GET", headers: {} } as IncomingMessage;
|
||||
const response = makeMockHttpResponse();
|
||||
const socket = new PassThrough();
|
||||
const context = {
|
||||
gatewayAuthSatisfied: true,
|
||||
gatewayRequestOperatorScopes: ["operator.read"],
|
||||
gatewayRequestAuth: { authMethod: "token" as const, trustDeclaredOperatorScopes: false },
|
||||
};
|
||||
try {
|
||||
const handled =
|
||||
transport === "HTTP"
|
||||
? await createGatewayPluginRequestHandler({ registry, log })(
|
||||
req,
|
||||
response.res,
|
||||
undefined,
|
||||
context,
|
||||
)
|
||||
: await createGatewayPluginUpgradeHandler({ registry, log })(
|
||||
req,
|
||||
socket,
|
||||
Buffer.alloc(0),
|
||||
undefined,
|
||||
context,
|
||||
);
|
||||
return { ...response, handled, warn, socketDestroyed: socket.destroyed };
|
||||
} finally {
|
||||
socket.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all([...instances].map((instance) => instance.dispose()));
|
||||
instances.clear();
|
||||
});
|
||||
|
||||
describe("plugin HTTP route instance ownership", () => {
|
||||
it.each(["HTTP", "WebSocket"] as const)(
|
||||
"keeps a shared raw %s handler with its selected registration after another instance retires",
|
||||
async (transport) => {
|
||||
const observed: Array<PluginRegistry | undefined> = [];
|
||||
const handler = () => {
|
||||
observed.push(getPluginRuntimeGatewayRequestScope()?.pluginRegistry);
|
||||
return true;
|
||||
};
|
||||
const first = createOwner();
|
||||
const second = createOwner();
|
||||
for (const { api } of [first, second]) {
|
||||
api.registerHttpRoute({ path: "/owned", auth: "plugin", handler, handleUpgrade: handler });
|
||||
}
|
||||
expect(first.registry.httpRoutes[0]?.handler).toBe(second.registry.httpRoutes[0]?.handler);
|
||||
|
||||
const beforeRetirement = await dispatch(first.registry, transport);
|
||||
await second.instance.dispose();
|
||||
const afterRetirement = await dispatch(first.registry, transport);
|
||||
|
||||
expect(observed).toHaveLength(2);
|
||||
expect(observed[0]).toBe(first.registry);
|
||||
expect(observed[1]).toBe(first.registry);
|
||||
expect(beforeRetirement.warn).not.toHaveBeenCalled();
|
||||
expect(afterRetirement.warn).not.toHaveBeenCalled();
|
||||
expect(afterRetirement.res.statusCode).toBe(200);
|
||||
expect(afterRetirement.socketDestroyed).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not transfer a route to an instance whose duplicate registration was rejected", async () => {
|
||||
const observed: Array<string | undefined> = [];
|
||||
const handler = () => {
|
||||
observed.push(getPluginRuntimeGatewayRequestScope()?.pluginId);
|
||||
return true;
|
||||
};
|
||||
const first = createOwner("accepted");
|
||||
const rejected = createOwner("rejected", first.builder);
|
||||
first.api.registerHttpRoute({ path: "/owned", auth: "plugin", handler });
|
||||
rejected.api.registerHttpRoute({ path: "/owned", auth: "plugin", handler });
|
||||
expect(first.registry.httpRoutes).toHaveLength(1);
|
||||
expect(first.registry.diagnostics).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ pluginId: "rejected", level: "error" })]),
|
||||
);
|
||||
await rejected.instance.dispose();
|
||||
|
||||
const response = await dispatch(first.registry);
|
||||
expect(observed).toEqual(["accepted"]);
|
||||
expect(response.warn).not.toHaveBeenCalled();
|
||||
expect(response.res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it.each(["static", "dynamic", "anonymous"] as const)(
|
||||
"keeps a projected %s route's selected registry through nested managed calls",
|
||||
async (registration) => {
|
||||
const owner = createOwner();
|
||||
const observed: Array<PluginRegistry | undefined> = [];
|
||||
const nested = owner.instance.wrap(() => {
|
||||
observed.push(getPluginRuntimeGatewayRequestScope()?.pluginRegistry);
|
||||
return true;
|
||||
});
|
||||
if (registration === "static") {
|
||||
owner.api.registerHttpRoute({ path: "/owned", auth: "plugin", handler: () => nested() });
|
||||
} else {
|
||||
owner.instance.run(() =>
|
||||
registerPluginHttpRoute({
|
||||
registry: owner.registry,
|
||||
path: "/owned",
|
||||
auth: "plugin",
|
||||
handler: () => nested(),
|
||||
...(registration === "dynamic" ? { pluginId: owner.record.id } : {}),
|
||||
throwOnFailure: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const selected = createEmptyPluginRegistry();
|
||||
selected.plugins.push(owner.record);
|
||||
projectPluginContributions(owner.registry, owner.record, selected);
|
||||
|
||||
const response = await dispatch(selected);
|
||||
expect(observed).toHaveLength(1);
|
||||
expect(observed[0]).toBe(selected);
|
||||
expect(response.warn).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("uses the invoked upgrade callback's owner for an untracked route", async () => {
|
||||
const http = createOwner("http");
|
||||
const upgrade = createOwner("upgrade");
|
||||
const handleUpgrade = upgrade.instance.adopt(vi.fn(() => true));
|
||||
const registry = createEmptyPluginRegistry();
|
||||
registry.httpRoutes.push({
|
||||
path: "/owned",
|
||||
auth: "plugin",
|
||||
match: "exact",
|
||||
handler: http.instance.adopt(() => true),
|
||||
handleUpgrade,
|
||||
});
|
||||
await upgrade.instance.dispose();
|
||||
|
||||
const response = await dispatch(registry, "WebSocket");
|
||||
expect(handleUpgrade).not.toHaveBeenCalled();
|
||||
expect(response.socketDestroyed).toBe(true);
|
||||
expect(response.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Plugin upgrade was reloaded"),
|
||||
);
|
||||
});
|
||||
|
||||
it("joins the selected route's in-flight response before disposing its instance", async () => {
|
||||
const entered = createDeferredCore();
|
||||
const release = createDeferredCore();
|
||||
const events: string[] = [];
|
||||
const handler = async () => {
|
||||
events.push("entered");
|
||||
entered.resolve();
|
||||
await release.promise;
|
||||
events.push("returned");
|
||||
return true;
|
||||
};
|
||||
const first = createOwner();
|
||||
const second = createOwner();
|
||||
first.api.lifecycle.onDispose!(() => {
|
||||
events.push("disposed");
|
||||
});
|
||||
for (const { api } of [first, second]) {
|
||||
api.registerHttpRoute({ path: "/owned", auth: "plugin", handler });
|
||||
}
|
||||
const pending = dispatch(first.registry);
|
||||
await entered.promise;
|
||||
const retirement = first.instance.dispose();
|
||||
try {
|
||||
await setImmediate();
|
||||
expect(events).toEqual(["entered"]);
|
||||
} finally {
|
||||
release.resolve();
|
||||
await Promise.all([pending, retirement]);
|
||||
}
|
||||
expect(events).toEqual(["entered", "returned", "disposed"]);
|
||||
});
|
||||
|
||||
it("serves the host retry response after a dynamic route's instance retires", async () => {
|
||||
const owner = createOwner();
|
||||
const lease = createPluginRuntimeCapabilityLease("route-test");
|
||||
const handler = vi.fn(() => true);
|
||||
owner.instance.run(() =>
|
||||
withPluginHttpRouteRegistry(
|
||||
owner.registry,
|
||||
() =>
|
||||
registerPluginHttpRoute({
|
||||
path: "/owned",
|
||||
auth: "plugin",
|
||||
handler,
|
||||
throwOnFailure: true,
|
||||
}),
|
||||
lease,
|
||||
),
|
||||
);
|
||||
const handoff = createPluginHttpRouteHandoff();
|
||||
handoff.park(lease);
|
||||
lease.revoke();
|
||||
await owner.instance.dispose();
|
||||
try {
|
||||
const response = await dispatch(owner.registry);
|
||||
expect(response.res.statusCode).toBe(503);
|
||||
expect(response.setHeader).toHaveBeenCalledWith("Retry-After", "1");
|
||||
expect(response.end).toHaveBeenCalledWith("plugin route is restarting; retry");
|
||||
expect(response.warn).not.toHaveBeenCalled();
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
handoff.release();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the bundled Prometheus singleton scrape live after another registration retires", async () => {
|
||||
const pluginId = "diagnostics-prometheus";
|
||||
const plugin = resolvePluginModuleExport(
|
||||
loadBundledPluginPublicArtifactModuleSync({
|
||||
dirName: pluginId,
|
||||
artifactBasename: "index.js",
|
||||
}),
|
||||
);
|
||||
expect(plugin.definition?.id).toBe(pluginId);
|
||||
const register = expectDefined(plugin.register, "Prometheus registration");
|
||||
const first = createOwner(pluginId);
|
||||
const second = createOwner(pluginId);
|
||||
for (const { api, registry, record } of [first, second]) {
|
||||
runPluginRegisterSyncInRegistry(register, api, registry, record.id);
|
||||
}
|
||||
expect(first.registry.httpRoutes[0]?.handler).toBe(second.registry.httpRoutes[0]?.handler);
|
||||
await second.instance.dispose();
|
||||
|
||||
const response = await dispatch(first.registry, "HTTP", "/api/diagnostics/prometheus");
|
||||
expect(response.handled).toBe(true);
|
||||
expect(response.res.statusCode).toBe(200);
|
||||
expect(response.setHeader).toHaveBeenCalledWith(
|
||||
"Content-Type",
|
||||
"text/plain; version=0.0.4; charset=utf-8",
|
||||
);
|
||||
expect(response.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from "../../../packages/gateway-protocol/src/client-info.js";
|
||||
import { PROTOCOL_VERSION } from "../../../packages/gateway-protocol/src/index.js";
|
||||
import type { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
import { getPluginValueInstance } from "../../plugins/plugin-instance-scope.js";
|
||||
import { runPluginHttpRoute } from "../../plugins/http-route-owner.js";
|
||||
import type { PluginHttpRouteRegistration, PluginRegistry } from "../../plugins/registry.js";
|
||||
import { withPluginRuntimeGatewayRequestScope } from "../../plugins/runtime/gateway-request-scope.js";
|
||||
import { rejectWebSocketUpgrade } from "../../shared/websocket-upgrade-reject.js";
|
||||
@@ -44,11 +44,6 @@ export {
|
||||
type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
|
||||
type PluginRouteRuntimeScope = Parameters<typeof withPluginRuntimeGatewayRequestScope>[0];
|
||||
|
||||
function runPluginRouteValue<T>(value: object, run: () => T): T {
|
||||
const instance = getPluginValueInstance(value);
|
||||
return instance ? instance.runConsumer(run) : run();
|
||||
}
|
||||
|
||||
function resolvePluginRoutePathContextForRequest(
|
||||
req: IncomingMessage,
|
||||
providedPathContext: PluginRoutePathContext | undefined,
|
||||
@@ -275,7 +270,8 @@ export function createGatewayPluginRequestHandler(params: {
|
||||
gatewayRequestOperatorScopes,
|
||||
gatewayRequestClientIp: dispatchContext?.gatewayRequestClientIp,
|
||||
}),
|
||||
async () => runPluginRouteValue(route.handler, () => route.handler(req, res)),
|
||||
async () =>
|
||||
runPluginHttpRoute(registry, route, route.handler, () => route.handler(req, res)),
|
||||
)) !== false;
|
||||
// Entitled trusted-operator routes delegate substantive work through Gateway dispatch.
|
||||
// An outer root would make gateway.suspend.prepare nested and permanently unreachable.
|
||||
@@ -357,7 +353,9 @@ export function createGatewayPluginUpgradeHandler(params: {
|
||||
}),
|
||||
async () => {
|
||||
const handleUpgrade = route.handleUpgrade!;
|
||||
return runPluginRouteValue(handleUpgrade, () => handleUpgrade(req, socket, head));
|
||||
return runPluginHttpRoute(registry, route, handleUpgrade, () =>
|
||||
handleUpgrade(req, socket, head),
|
||||
);
|
||||
},
|
||||
)) !== false,
|
||||
);
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
/** Covers canonical plugin compaction provider registration and runtime lookup. */
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { afterEach, describe, expect, it, onTestFinished } from "vitest";
|
||||
import { createPluginRuntimeStore } from "../plugin-sdk/runtime-store.js";
|
||||
import { getCompactionProvider, type CompactionProvider } from "./compaction-provider.js";
|
||||
import { runPluginRegisterSyncInRegistry } from "./loader-module-runtime.js";
|
||||
import { createPluginRecord } from "./loader-records.js";
|
||||
import { getPluginInstance } from "./plugin-instance-scope.js";
|
||||
import { createPluginRegistry } from "./registry.js";
|
||||
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "./runtime.js";
|
||||
import { getPluginRuntimeGatewayRequestScope } from "./runtime/gateway-request-scope.js";
|
||||
import type { PluginRuntime } from "./runtime/types.js";
|
||||
|
||||
afterEach(() => {
|
||||
@@ -44,12 +48,36 @@ function makeProvider(id: string, label?: string): CompactionProvider {
|
||||
};
|
||||
}
|
||||
|
||||
function registerProvider(
|
||||
builder: ReturnType<typeof createTestRegistry>,
|
||||
pluginId: string,
|
||||
provider: CompactionProvider,
|
||||
initialize?: () => void,
|
||||
) {
|
||||
const record = createRecord(pluginId);
|
||||
const api = builder.createApi(record, { config: {} });
|
||||
runPluginRegisterSyncInRegistry(
|
||||
(registeredApi) => {
|
||||
initialize?.();
|
||||
registeredApi.registerCompactionProvider(provider);
|
||||
},
|
||||
api,
|
||||
builder.registry,
|
||||
pluginId,
|
||||
);
|
||||
builder.registry.plugins.push(record);
|
||||
const instance = expectDefined(getPluginInstance(record), "compaction provider instance");
|
||||
onTestFinished(async () => {
|
||||
await instance.dispose();
|
||||
});
|
||||
return instance;
|
||||
}
|
||||
|
||||
describe("compaction provider registry", () => {
|
||||
it("reads providers registered through the plugin API from the active registry", async () => {
|
||||
const pluginRegistry = createTestRegistry();
|
||||
const record = createRecord("owner");
|
||||
const provider = makeProvider("owned");
|
||||
pluginRegistry.createApi(record, { config: {} }).registerCompactionProvider(provider);
|
||||
const owner = registerProvider(pluginRegistry, "owner", provider);
|
||||
setActivePluginRegistry(pluginRegistry.registry);
|
||||
|
||||
expect(pluginRegistry.registry.compactionProviders).toEqual([
|
||||
@@ -57,7 +85,7 @@ describe("compaction provider registry", () => {
|
||||
]);
|
||||
const resolved = getCompactionProvider("owned");
|
||||
await expect(resolved?.summarize({ messages: [] })).resolves.toBe("summary-from-owned");
|
||||
await getPluginInstance(record)?.dispose();
|
||||
await owner.dispose();
|
||||
expect(() => resolved?.summarize({ messages: [] })).toThrow(/reloaded|disabled|retiring/);
|
||||
});
|
||||
|
||||
@@ -65,12 +93,8 @@ describe("compaction provider registry", () => {
|
||||
const pluginRegistry = createTestRegistry();
|
||||
const first = makeProvider("shared", "first");
|
||||
const second = makeProvider("shared", "second");
|
||||
pluginRegistry
|
||||
.createApi(createRecord("first-owner"), { config: {} })
|
||||
.registerCompactionProvider(first);
|
||||
pluginRegistry
|
||||
.createApi(createRecord("second-owner"), { config: {} })
|
||||
.registerCompactionProvider(second);
|
||||
registerProvider(pluginRegistry, "first-owner", first);
|
||||
registerProvider(pluginRegistry, "second-owner", second);
|
||||
|
||||
expect(pluginRegistry.registry.compactionProviders).toEqual([
|
||||
{ provider: first, ownerPluginId: "first-owner" },
|
||||
@@ -83,4 +107,34 @@ describe("compaction provider registry", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["another registry", "rejected duplicate"])(
|
||||
"keeps the selected provider owner when a shared descriptor is adopted by %s",
|
||||
async (registration) => {
|
||||
const active = createTestRegistry();
|
||||
const store = createPluginRuntimeStore<string>("compaction runtime missing");
|
||||
const provider: CompactionProvider = {
|
||||
id: "shared",
|
||||
label: "Shared",
|
||||
async summarize() {
|
||||
expect(getPluginRuntimeGatewayRequestScope()?.pluginRegistry).toBe(active.registry);
|
||||
return store.getRuntime();
|
||||
},
|
||||
};
|
||||
registerProvider(active, "first-owner", provider, () => store.setRuntime("first runtime"));
|
||||
setActivePluginRegistry(active.registry);
|
||||
const other = registration === "another registry" ? createTestRegistry() : active;
|
||||
const otherOwner = registerProvider(other, "second-owner", provider, () =>
|
||||
store.setRuntime("second runtime"),
|
||||
);
|
||||
expect(active.registry.compactionProviders[0]?.provider).toBe(provider);
|
||||
await expect(getCompactionProvider("shared")?.summarize({ messages: [] })).resolves.toBe(
|
||||
"first runtime",
|
||||
);
|
||||
await otherOwner.dispose();
|
||||
await expect(getCompactionProvider("shared")?.summarize({ messages: [] })).resolves.toBe(
|
||||
"first runtime",
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,12 +1,16 @@
|
||||
import { getPluginValueInstance } from "./plugin-instance-scope.js";
|
||||
import { getPluginInstance } from "./plugin-instance-scope.js";
|
||||
import type { CompactionProvider } from "./registry-contribution-types.js";
|
||||
import { requireActivePluginRegistry } from "./runtime.js";
|
||||
|
||||
export type { CompactionProvider } from "./registry-contribution-types.js";
|
||||
|
||||
export function getCompactionProvider(id: string): CompactionProvider | undefined {
|
||||
const provider = requireActivePluginRegistry().compactionProviders.find(
|
||||
(entry) => entry.provider.id === id,
|
||||
)?.provider;
|
||||
return provider ? (getPluginValueInstance(provider)?.wrap(provider) ?? provider) : undefined;
|
||||
const registry = requireActivePluginRegistry();
|
||||
const registration = registry.compactionProviders.find((entry) => entry.provider.id === id);
|
||||
if (!registration) {
|
||||
return undefined;
|
||||
}
|
||||
const record = registry.plugins.find((entry) => entry.id === registration.ownerPluginId);
|
||||
const instance = record && getPluginInstance(record);
|
||||
return instance?.wrap(registration.provider) ?? registration.provider;
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { resolveGlobalSingleton } from "../shared/global-singleton.js";
|
||||
import { pluginInstanceInvocation } from "./plugin-instance-invocation.js";
|
||||
import { pluginInstanceState } from "./plugin-instance-scope.js";
|
||||
import {
|
||||
getPluginInstanceOwner,
|
||||
getPluginValueInstance,
|
||||
pluginInstanceState,
|
||||
} from "./plugin-instance-scope.js";
|
||||
import type { PluginInstanceResource } from "./plugin-instance.types.js";
|
||||
import { isPluginRegistryRetired } from "./registry-lifecycle.js";
|
||||
import type {
|
||||
@@ -8,6 +12,7 @@ import type {
|
||||
PluginRecord,
|
||||
PluginRegistry,
|
||||
} from "./registry-types.js";
|
||||
import { withPluginRuntimeGenerationRegistryScope } from "./runtime/generation-state.js";
|
||||
|
||||
type RouteViews = Set<WeakRef<PluginRegistry>>;
|
||||
type RouteOwner = PluginInstanceResource | string | undefined;
|
||||
@@ -36,6 +41,26 @@ function resolveEntryOwner(registry: PluginRegistry, entry: PluginHttpRouteRegis
|
||||
: resolveOwner(registry, entry.pluginId, pluginInstanceState.values.get(entry.handler));
|
||||
}
|
||||
|
||||
/** Shared raw callbacks keep the exact owner captured when their route was registered. */
|
||||
export function runPluginHttpRoute<T>(
|
||||
registry: PluginRegistry,
|
||||
entry: PluginHttpRouteRegistration,
|
||||
value: object,
|
||||
run: () => T,
|
||||
): T {
|
||||
// Retry responses are host code; their retired owner remains only for route cleanup.
|
||||
if (entry.handoff) {
|
||||
return run();
|
||||
}
|
||||
const owner = entryViews.get(entry)?.owner;
|
||||
const instance =
|
||||
(owner && typeof owner !== "string" ? getPluginInstanceOwner(owner)?.instance : undefined) ??
|
||||
getPluginValueInstance(value);
|
||||
return instance
|
||||
? withPluginRuntimeGenerationRegistryScope(registry, () => instance.runConsumer(run))
|
||||
: run();
|
||||
}
|
||||
|
||||
function viewsByOwner(registry: PluginRegistry) {
|
||||
let owners = registryViews.get(registry);
|
||||
if (!owners) {
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
// Covers migration provider runtime hooks supplied by plugins.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from "vitest";
|
||||
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { createPluginRuntimeStore } from "../plugin-sdk/runtime-store.js";
|
||||
import { createDeferredCore } from "../shared/deferred.js";
|
||||
import { runPluginRegisterSyncInRegistry } from "./loader-module-runtime.js";
|
||||
import { createPluginRecord } from "./loader-records.js";
|
||||
import { PluginInstance } from "./plugin-instance.js";
|
||||
import { getPluginInstance, getPluginValueInstance } from "./plugin-instance-scope.js";
|
||||
import { clearPluginMetadataLifecycleCaches } from "./plugin-metadata-lifecycle.js";
|
||||
import type { PluginRegistry } from "./registry-types.js";
|
||||
import { createEmptyPluginRegistry } from "./registry.js";
|
||||
import { createEmptyPluginRegistry, createPluginRegistry } from "./registry.js";
|
||||
import { getPluginRuntimeGatewayRequestScope } from "./runtime/gateway-request-scope.js";
|
||||
import { createPluginRuntime } from "./runtime/index.js";
|
||||
import type { MigrationPlan, MigrationProviderContext } from "./types.js";
|
||||
|
||||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
|
||||
@@ -125,8 +131,14 @@ function createMigrationProvider(id: string) {
|
||||
function createOwnedMigrationRegistry(
|
||||
pluginId: string,
|
||||
provider: ReturnType<typeof createMigrationProvider>,
|
||||
initialize?: () => void,
|
||||
) {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
const builder = createPluginRegistry({
|
||||
runtime: createPluginRuntime(),
|
||||
activateGlobalSideEffects: false,
|
||||
logger: { info() {}, warn() {}, error() {} },
|
||||
});
|
||||
const { registry } = builder;
|
||||
const record = createPluginRecord({
|
||||
id: pluginId,
|
||||
source: `/plugins/${pluginId}/index.js`,
|
||||
@@ -134,13 +146,18 @@ function createOwnedMigrationRegistry(
|
||||
enabled: true,
|
||||
configSchema: false,
|
||||
});
|
||||
const instance = new PluginInstance(pluginId, { record, registry });
|
||||
registry.plugins.push(record);
|
||||
registry.migrationProviders.push({
|
||||
const api = builder.createApi(record, { config: {} });
|
||||
runPluginRegisterSyncInRegistry(
|
||||
(registeredApi) => {
|
||||
initialize?.();
|
||||
registeredApi.registerMigrationProvider(provider);
|
||||
},
|
||||
api,
|
||||
registry,
|
||||
pluginId,
|
||||
source: record.source,
|
||||
provider: instance.wrap(provider),
|
||||
});
|
||||
);
|
||||
registry.plugins.push(record);
|
||||
const instance = expectDefined(getPluginInstance(record), "migration provider instance");
|
||||
const release = async () => {
|
||||
await instance.dispose();
|
||||
};
|
||||
@@ -185,6 +202,115 @@ describe("migration provider runtime", () => {
|
||||
withPluginMigrationProviders = runtime.withPluginMigrationProviders;
|
||||
});
|
||||
|
||||
it.each(["active provider", "loaded registry", "acquired registry"] as const)(
|
||||
"retains managed migration execution through %s",
|
||||
async (route) => {
|
||||
const provider = createMigrationProvider("managed-import");
|
||||
const store = createPluginRuntimeStore<{ plan: MigrationPlan }>("migration runtime missing");
|
||||
const runtime = {
|
||||
plan: {
|
||||
providerId: provider.id,
|
||||
source: "fixture",
|
||||
items: [],
|
||||
summary: {
|
||||
total: 0,
|
||||
planned: 0,
|
||||
migrated: 0,
|
||||
skipped: 0,
|
||||
conflicts: 0,
|
||||
errors: 0,
|
||||
sensitive: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
const registry = createOwnedMigrationRegistry("managed-migration", provider, () =>
|
||||
store.setRuntime(runtime),
|
||||
);
|
||||
const owner = expectDefined(getPluginValueInstance(provider), "registered provider owner");
|
||||
expect(registry.migrationProviders[0]?.provider).toBe(provider);
|
||||
createOwnedMigrationRegistry("managed-migration", provider, () =>
|
||||
store.setRuntime({ plan: { ...runtime.plan, source: "another instance" } }),
|
||||
);
|
||||
const otherOwner = expectDefined(getPluginValueInstance(provider), "other provider owner");
|
||||
mocks.release.mockImplementation(async () => {
|
||||
await owner.dispose();
|
||||
});
|
||||
expect(store.tryGetRuntime()).toBeNull();
|
||||
mocks.loadPluginRegistrySnapshot.mockReturnValue(
|
||||
createMockPluginIndex([
|
||||
{ pluginId: "managed-migration", origin: "installed", enabled: true },
|
||||
]),
|
||||
);
|
||||
mocks.loadPluginManifestRegistry.mockReturnValue({
|
||||
plugins: [
|
||||
{
|
||||
id: "managed-migration",
|
||||
origin: "installed",
|
||||
contracts: { migrationProviders: [provider.id] },
|
||||
},
|
||||
],
|
||||
diagnostics: [],
|
||||
});
|
||||
mocks.resolveRuntimePluginRegistry.mockReturnValue(
|
||||
route === "acquired registry" ? undefined : registry,
|
||||
);
|
||||
mocks.acquirePluginRegistryForInspection.mockResolvedValue({
|
||||
registry,
|
||||
release: mocks.release,
|
||||
});
|
||||
const context: MigrationProviderContext = {
|
||||
config: {},
|
||||
stateDir: tempDirs.make("openclaw-managed-migration-"),
|
||||
logger: { info() {}, warn() {}, error() {} },
|
||||
};
|
||||
provider.plan.mockImplementation(() => {
|
||||
const current = store.getRuntime();
|
||||
expect(current).toBe(runtime);
|
||||
expect(getPluginRuntimeGatewayRequestScope()?.pluginRegistry).toBe(registry);
|
||||
return current.plan;
|
||||
});
|
||||
const resume = createDeferredCore();
|
||||
try {
|
||||
await withPluginMigrationProviders(
|
||||
route === "active provider" ? { providerId: provider.id } : {},
|
||||
async (providers) => {
|
||||
const selected = expectDefined(
|
||||
providers.find((entry) => entry.id === provider.id),
|
||||
"managed migration provider",
|
||||
);
|
||||
const retainedPlan = selected.plan;
|
||||
expect(retainedPlan(context)).toBe(runtime.plan);
|
||||
await otherOwner.dispose();
|
||||
expect(retainedPlan(context)).toBe(runtime.plan);
|
||||
provider.plan.mockImplementationOnce(async () => {
|
||||
await resume.promise;
|
||||
expect(store.getRuntime()).toBe(runtime);
|
||||
expect(getPluginRuntimeGatewayRequestScope()?.pluginRegistry).toBe(registry);
|
||||
return runtime.plan;
|
||||
});
|
||||
const pending = selected.plan(context);
|
||||
const cleaned = vi.fn();
|
||||
const retirement = owner.dispose().then(cleaned);
|
||||
await Promise.resolve();
|
||||
expect(cleaned).not.toHaveBeenCalled();
|
||||
expect(() => retainedPlan(context)).toThrow("reloaded or disabled");
|
||||
resume.resolve();
|
||||
await expect(pending).resolves.toBe(runtime.plan);
|
||||
await retirement;
|
||||
expect(cleaned).toHaveBeenCalledOnce();
|
||||
expect(() => retainedPlan(context)).toThrow("reloaded or disabled");
|
||||
},
|
||||
);
|
||||
expect(mocks.acquirePluginRegistryForInspection).toHaveBeenCalledTimes(
|
||||
route === "acquired registry" ? 1 : 0,
|
||||
);
|
||||
} finally {
|
||||
resume.resolve();
|
||||
await owner.dispose();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ origin: "global", policy: "enabled", allowed: true },
|
||||
{ origin: "global", policy: "disabled", allowed: false },
|
||||
@@ -399,12 +525,13 @@ describe("migration provider runtime", () => {
|
||||
{ providerId: "external-import", cfg },
|
||||
async (providers) => {
|
||||
const resolved = providers.find((entry) => entry.id === "external-import");
|
||||
expect(resolved).toBe(loaded.migrationProviders[0]?.provider);
|
||||
expect(resolved?.id).toBe(provider.id);
|
||||
provider.plan.mockImplementationOnce(() => {
|
||||
expect(getPluginRuntimeGatewayRequestScope()?.pluginRegistry).toBe(loaded);
|
||||
return {} as never;
|
||||
});
|
||||
await resolved?.plan({} as never);
|
||||
expect(provider.plan).toHaveBeenCalledOnce();
|
||||
},
|
||||
);
|
||||
expect(mocks.loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledWith({
|
||||
@@ -455,9 +582,13 @@ describe("migration provider runtime", () => {
|
||||
] as never);
|
||||
|
||||
await withPluginMigrationProviders({ providerId: "hermes" }, async (providers) => {
|
||||
expect(providers.find((entry) => entry.id === "hermes")).toBe(
|
||||
loaded.migrationProviders[0]?.provider,
|
||||
);
|
||||
expect(providers.map((entry) => entry.id)).toEqual(["hermes"]);
|
||||
await providers[0]?.plan({
|
||||
config: {},
|
||||
stateDir: tempDirs.make("openclaw-bundled-migration-"),
|
||||
logger: { info() {}, warn() {}, error() {} },
|
||||
});
|
||||
expect(provider.plan).toHaveBeenCalledOnce();
|
||||
});
|
||||
expect(mocks.listBundledPluginMetadata).toHaveBeenCalledWith({
|
||||
includeChannelConfigs: false,
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
resolveMigrationProviderPublicArtifacts,
|
||||
type MigrationProviderArtifactPlugin,
|
||||
} from "./migration-provider-public-artifacts.js";
|
||||
import { getPluginValueInstance } from "./plugin-instance-scope.js";
|
||||
import { getPluginInstance } from "./plugin-instance-scope.js";
|
||||
import type { PluginRegistry } from "./registry-types.js";
|
||||
import type { MigrationProviderPlugin } from "./types.js";
|
||||
|
||||
type MigrationProviderPluginResolution = {
|
||||
@@ -86,13 +87,22 @@ function resolveMigrationProviderPluginResolution(params: {
|
||||
}
|
||||
|
||||
function mergeMigrationProviders(
|
||||
left: ReadonlyArray<{ provider: MigrationProviderPlugin }>,
|
||||
right: ReadonlyArray<{ provider: MigrationProviderPlugin }>,
|
||||
...registries: Array<
|
||||
| {
|
||||
plugins?: PluginRegistry["plugins"];
|
||||
migrationProviders: ReadonlyArray<{ pluginId: string; provider: MigrationProviderPlugin }>;
|
||||
}
|
||||
| undefined
|
||||
>
|
||||
): MigrationProviderPlugin[] {
|
||||
const merged = new Map<string, MigrationProviderPlugin>();
|
||||
for (const entry of [...left, ...right]) {
|
||||
if (!merged.has(entry.provider.id)) {
|
||||
merged.set(entry.provider.id, entry.provider);
|
||||
for (const registry of registries) {
|
||||
for (const { pluginId, provider } of registry?.migrationProviders ?? []) {
|
||||
if (!merged.has(provider.id)) {
|
||||
const record = registry?.plugins?.find((entry) => entry.id === pluginId);
|
||||
const instance = record && getPluginInstance(record);
|
||||
merged.set(provider.id, instance?.wrap(provider) ?? provider);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...merged.values()].toSorted((a, b) => a.id.localeCompare(b.id));
|
||||
@@ -114,7 +124,7 @@ export async function withPluginMigrationProviders<T>(
|
||||
params.providerId &&
|
||||
activeProviders.some(({ provider }) => provider.id === params.providerId)
|
||||
) {
|
||||
return await run(mergeMigrationProviders(activeProviders, []));
|
||||
return await run(mergeMigrationProviders(activeRegistry));
|
||||
}
|
||||
const resolution = resolveMigrationProviderPluginResolution(params);
|
||||
if (params.providerId) {
|
||||
@@ -123,14 +133,14 @@ export async function withPluginMigrationProviders<T>(
|
||||
providerId: params.providerId,
|
||||
});
|
||||
if (providers.length > 0) {
|
||||
return await run(mergeMigrationProviders(activeProviders, providers));
|
||||
return await run(mergeMigrationProviders(activeRegistry, { migrationProviders: providers }));
|
||||
}
|
||||
}
|
||||
if (
|
||||
resolution.pluginIds.length === 0 ||
|
||||
getLoadedRuntimePluginRegistry({ requiredPluginIds: resolution.pluginIds })
|
||||
) {
|
||||
return await run(mergeMigrationProviders(activeProviders, []));
|
||||
return await run(mergeMigrationProviders(activeRegistry));
|
||||
}
|
||||
const compatConfig = withBundledPluginEnablementCompat({
|
||||
config: params.cfg,
|
||||
@@ -140,13 +150,9 @@ export async function withPluginMigrationProviders<T>(
|
||||
...(compatConfig === undefined ? {} : { config: compatConfig }),
|
||||
onlyPluginIds: resolution.pluginIds,
|
||||
});
|
||||
const providers = mergeMigrationProviders(
|
||||
activeProviders,
|
||||
acquisition.registry.migrationProviders,
|
||||
).map((provider) => getPluginValueInstance(provider)?.wrap(provider) ?? provider);
|
||||
let result: T;
|
||||
try {
|
||||
result = await run(providers);
|
||||
result = await run(mergeMigrationProviders(activeRegistry, acquisition.registry));
|
||||
} catch (error) {
|
||||
const failures = [error];
|
||||
try {
|
||||
|
||||
Reference in new issue
Block a user