History

fix: harden plugin runtime transitions

t3-code[bot] committed 2 months ago

Commit 39f328f1 parent a065b7b6 files · +206 −25Browse files

Unified · Split

6 files
  1. packages/plugin-runtime-poc/README.md +2 −2
  2. packages/plugin-runtime-poc/src/contract.ts +6 −2
  3. packages/plugin-runtime-poc/src/manifest.test.ts +16 −0
  4. packages/plugin-runtime-poc/src/manifest.ts +6 −2
  5. packages/plugin-runtime-poc/src/pureRuntime.ts +33 −17
  6. packages/plugin-runtime-poc/src/runtimeContract.ts +143 −2
packages/plugin-runtime-poc/README.md+2−2
@@ -14,8 +14,8 @@ this is the smallest and fastest planner in the experiment. for production, its · 13 unmodified lines
1414
1515 ## proved
1616
17−- 18 tests cover dependency order, blocking, rollback, cycles, unchanged instance reuse, provider-value replacement, unusual ids and slots, cleanup failures, immutable snapshots, and a 20,000-plugin stack-safe chain
18−- manifest validation keeps mobile declarative
17+- 23 tests cover dependency order, blocking, rollback, cycles, unchanged instance reuse, order-insensitive declarations, activation implementation replacement, cleanup reporting, unusual ids and slots, immutable snapshots, and a 20,000-plugin stack-safe chain
18+- manifest validation enforces semver, prevents entrypoint path escape, and keeps mobile declarative
1919 - the demo exercises activation, replacement, teardown, and a 250-plugin chain
2020
2121 ## run
packages/plugin-runtime-poc/src/contract.ts+6−2
@@ -19,8 +19,8 @@ export interface PluginActivationContext { · 18 unmodified lines
1919
2020 export interface PluginRuntimeSnapshot {
2121 readonly active: ReadonlyArray<string>;
22− readonly blocked: Readonly<Record<string, string>>;
23− readonly contributions: Readonly<Record<string, ReadonlyArray<Contribution>>>;
22+ readonly blocked: Readonly<Partial<Record<string, string>>>;
23+ readonly contributions: Readonly<Partial<Record<string, ReadonlyArray<Contribution>>>>;
2424 }
2525
2626 export interface PluginRuntime {
@@ -36,6 +36,10 @@ export interface PluginRuntimeOptions { · 9 unmodified lines
3636 readonly phase: "activate" | "deactivate";
3737 readonly pluginId: string;
3838 }) => void;
39+ readonly onCleanupError?: (event: {
40+ readonly phase: "retire" | "rollback";
41+ readonly error: unknown;
42+ }) => void;
3943 }
4044
4145 export type PluginRuntimeFactory = (options?: PluginRuntimeOptions) => PluginRuntime;
packages/plugin-runtime-poc/src/manifest.test.ts+16−0
@@ -40,9 +40,25 @@ describe("PluginManifest", () => { · 39 unmodified lines
4040
4141 it("rejects malformed versions and capability ids", () => {
4242 expect(() => decodeManifest({ ...validManifest, version: "next" })).toThrow();
43+ expect(() => decodeManifest({ ...validManifest, version: "01.2.3" })).toThrow();
44+ expect(() => decodeManifest({ ...validManifest, version: "1.2.3-.." })).toThrow();
45+ expect(decodeManifest({ ...validManifest, version: "1.2.3+build.7" }).version).toBe(
46+ "1.2.3+build.7",
47+ );
4348 expect(() => decodeManifest({ ...validManifest, requires: ["t3.commands"] })).toThrow();
4449 });
4550
51+ it("rejects entrypoints that escape the plugin directory", () => {
52+ for (const server of ["./../outside.js", "./dist/../../outside.js"]) {
53+ expect(() =>
54+ decodeManifest({
55+ ...validManifest,
56+ entrypoints: { ...validManifest.entrypoints, server },
57+ }),
58+ ).toThrow();
59+ }
60+ });
61+
4662 it("keeps mobile declarative by excluding a mobile executable entrypoint", () => {
4763 const decoded = decodeManifest({
4864 ...validManifest,
packages/plugin-runtime-poc/src/manifest.ts+6−2
@@ -5,12 +5,16 @@ const NamespacedId = Schema.String.check( · 4 unmodified lines
55 );
66
77 const SemanticVersion = Schema.String.check(
8− Schema.isPattern(/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/),
8+ Schema.isPattern(
9+ /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:0|[1-9]\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/,
10+ ),
911 );
1012
1113 const CapabilityId = Schema.String.check(Schema.isPattern(/^[a-z0-9][a-z0-9.-]*@[1-9]\d*$/));
1214
13−const RelativeEntrypoint = Schema.String.check(Schema.isPattern(/^\.\/[A-Za-z0-9_./-]+$/));
15+const RelativeEntrypoint = Schema.String.check(
16+ Schema.isPattern(/^\.\/(?!(?:\.\.(?:\/|$)|.*\/\.\.(?:\/|$)))[A-Za-z0-9_./-]+$/),
17+);
1418 const Permission = Schema.String.check(Schema.isPattern(/^[a-z][a-z-]*:.+$/));
1519
1620 const ContributionCatalog = Schema.Struct({
packages/plugin-runtime-poc/src/pureRuntime.ts+33−17
@@ -150,8 +150,8 @@ const planComposition = (definitions: ReadonlyArray<PluginDefinition>): Composit · 149 unmodified lines
150150 };
151151
152152 const sameDefinitionShape = (previous: PluginDefinition, desired: PluginDefinition) => {
153− const previousRequires = previous.requires ?? [];
154− const desiredRequires = desired.requires ?? [];
153+ const previousRequires = [...(previous.requires ?? [])].sort();
154+ const desiredRequires = [...(desired.requires ?? [])].sort();
155155 const previousProvidedServices = previous.provides ?? {};
156156 const desiredProvidedServices = desired.provides ?? {};
157157 const previousProvides = Object.keys(previousProvidedServices);
@@ -159,20 +159,18 @@ const sameDefinitionShape = (previous: PluginDefinition, desired: PluginDefiniti · 1 unmodified line
159159
160160 return (
161161 previous.version === desired.version &&
162+ Object.is(previous.activate, desired.activate) &&
162163 previousRequires.length === desiredRequires.length &&
163164 previousRequires.every((capability, index) => capability === desiredRequires[index]) &&
164165 previousProvides.length === desiredProvides.length &&
165166 previousProvides.every(
166− (capability, index) =>
167− capability === desiredProvides[index] &&
167+ (capability) =>
168+ Object.hasOwn(desiredProvidedServices, capability) &&
168169 Object.is(previousProvidedServices[capability], desiredProvidedServices[capability]),
169170 )
170171 );
171172 };
172173
173−const messageOf = (error: unknown) =>
174− error instanceof Error ? error.message : "plugin activation failed";
175−
176174 export const createPureRuntime: PluginRuntimeFactory = (options = {}): PluginRuntime => {
177175 let current = emptyComposition();
178176 let disposed = false;
@@ -216,6 +214,16 @@ export const createPureRuntime: PluginRuntimeFactory = (options = {}): PluginRun · 37 unmodified lines
216214 return errors;
217215 };
218216
217+ const reportCleanupErrors = (phase: "retire" | "rollback", errors: ReadonlyArray<unknown>) => {
218+ for (const error of errors) {
219+ try {
220+ options.onCleanupError?.({ phase, error });
221+ } catch {
222+ // Cleanup reporting must not replace an activation failure or invalidate a commit.
223+ }
224+ }
225+ };
226+
219227 const stage = async (
220228 definitions: ReadonlyArray<PluginDefinition>,
221229 previous: Composition,
@@ -303,8 +311,6 @@ export const createPureRuntime: PluginRuntimeFactory = (options = {}): PluginRun · 81 unmodified lines
303311 activating = false;
304312 }
305313
306− instance.activated = true;
307− options.onLifecycle?.({ phase: "activate", pluginId: definition.id });
308314 for (const [capability, service] of Object.entries(definition.provides ?? {})) {
309315 services.set(capability, service);
310316 }
@@ -323,12 +329,7 @@ export const createPureRuntime: PluginRuntimeFactory = (options = {}): PluginRun · 12 unmodified lines
323329 };
324330 } catch (activationError) {
325331 const cleanupErrors = await deactivateAll(staged);
326− if (cleanupErrors.length > 0) {
327− throw new Error(
328− `${messageOf(activationError)}; ${cleanupErrors.length} rollback finalizer(s) also failed`,
329− { cause: activationError },
330− );
331− }
332+ reportCleanupErrors("rollback", cleanupErrors);
332333 throw activationError;
333334 }
334335 };
@@ -352,11 +353,26 @@ export const createPureRuntime: PluginRuntimeFactory = (options = {}): PluginRun · 17 unmodified lines
352353 const candidate = await stage(desired, previous);
353354 current = candidate;
354355
356+ const lifecycleErrors: Array<unknown> = [];
357+ for (const instance of candidate.instances) {
358+ if (instance.activated) continue;
359+ instance.activated = true;
360+ try {
361+ options.onLifecycle?.({ phase: "activate", pluginId: instance.definition.id });
362+ } catch (error) {
363+ lifecycleErrors.push(error);
364+ }
365+ }
366+
355367 const retained = new Set(candidate.instances);
356368 const retired = previous.instances.filter((instance) => !retained.has(instance));
357369 const cleanupErrors = await deactivateAll(retired);
358− if (cleanupErrors.length > 0) {
359− throw new AggregateError(cleanupErrors, "previous composition deactivation failed");
370+ reportCleanupErrors("retire", cleanupErrors);
371+ if (lifecycleErrors.length > 0) {
372+ throw new AggregateError(
373+ lifecycleErrors,
374+ "committed composition lifecycle reporting failed",
375+ );
360376 }
361377 return candidate.snapshot;
362378 });
packages/plugin-runtime-poc/src/runtimeContract.ts+143−2
@@ -116,6 +116,78 @@ export function defineRuntimeContract(name: string, createRuntime: PluginRuntime · 115 unmodified lines
116116 expect(disposals).toBe(1);
117117 });
118118
119+ it("treats reordered requirements and providers as the same definition", async () => {
120+ let activations = 0;
121+ const firstService = {};
122+ const secondService = {};
123+ const activate: PluginDefinition["activate"] = () => {
124+ activations += 1;
125+ };
126+ const dependencies: ReadonlyArray<PluginDefinition> = [
127+ {
128+ id: "acme.first-dependency",
129+ version: "1.0.0",
130+ provides: { "acme.first@1": true },
131+ activate() {},
132+ },
133+ {
134+ id: "acme.second-dependency",
135+ version: "1.0.0",
136+ provides: { "acme.second@1": true },
137+ activate() {},
138+ },
139+ ];
140+ const runtime = createRuntime();
141+ await runtime.reconcile([
142+ ...dependencies,
143+ {
144+ id: "acme.stable-order",
145+ version: "1.0.0",
146+ requires: ["acme.first@1", "acme.second@1"],
147+ provides: { "acme.output-one@1": firstService, "acme.output-two@1": secondService },
148+ activate,
149+ },
150+ ]);
151+
152+ await runtime.reconcile([
153+ ...dependencies,
154+ {
155+ id: "acme.stable-order",
156+ version: "1.0.0",
157+ requires: ["acme.second@1", "acme.first@1"],
158+ provides: { "acme.output-two@1": secondService, "acme.output-one@1": firstService },
159+ activate,
160+ },
161+ ]);
162+
163+ expect(activations).toBe(1);
164+ await runtime.dispose();
165+ });
166+
167+ it("restarts a plugin when its activation implementation changes", async () => {
168+ const runtime = createRuntime();
169+ const first: PluginDefinition = {
170+ id: "acme.implementation",
171+ version: "1.0.0",
172+ activate(context) {
173+ context.register("commands", { id: "implementation", label: "first" });
174+ },
175+ };
176+ const second: PluginDefinition = {
177+ id: "acme.implementation",
178+ version: "1.0.0",
179+ activate(context) {
180+ context.register("commands", { id: "implementation", label: "second" });
181+ },
182+ };
183+
184+ await runtime.reconcile([first]);
185+ const snapshot = await runtime.reconcile([second]);
186+
187+ expect(contributionLabels(snapshot, "commands")).toEqual(["second"]);
188+ await runtime.dispose();
189+ });
190+
119191 it("restarts a changed provider and its dependents without touching independent plugins", async () => {
120192 let independentActivations = 0;
121193 const lifecycle: Array<string> = [];
@@ -237,11 +309,16 @@ export function defineRuntimeContract(name: string, createRuntime: PluginRuntime · 115 unmodified lines
237309 await runtime.dispose();
238310 });
239311
240− it("does not report a failed candidate as activated", async () => {
312+ it("does not report a rolled-back candidate as activated", async () => {
241313 const lifecycle: Array<string> = [];
242314 const runtime = createRuntime({
243315 onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`),
244316 });
317+ const staged: PluginDefinition = {
318+ id: "acme.staged",
319+ version: "1.0.0",
320+ activate() {},
321+ };
245322 const broken: PluginDefinition = {
246323 id: "acme.broken",
247324 version: "1.0.0",
@@ -250,12 +327,76 @@ export function defineRuntimeContract(name: string, createRuntime: PluginRuntime · 2 unmodified lines
250327 },
251328 };
252329
253− await expect(runtime.reconcile([broken])).rejects.toThrow("activation failed");
330+ await expect(runtime.reconcile([staged, broken])).rejects.toThrow("activation failed");
254331
255332 expect(lifecycle).toEqual([]);
256333 await runtime.dispose();
257334 });
258335
336+ it("preserves activation failures when rollback cleanup also fails", async () => {
337+ const activationError = new Error("activation failed");
338+ const cleanupError = new Error("rollback cleanup failed");
339+ const cleanupEvents: Array<{ readonly phase: string; readonly error: unknown }> = [];
340+ const runtime = createRuntime({
341+ onCleanupError: (event) => {
342+ cleanupEvents.push(event);
343+ throw new Error("cleanup observer failed");
344+ },
345+ });
346+ const broken: PluginDefinition = {
347+ id: "acme.rollback-error",
348+ version: "1.0.0",
349+ activate(context) {
350+ context.onDispose(() => {
351+ throw cleanupError;
352+ });
353+ throw activationError;
354+ },
355+ };
356+
357+ await expect(runtime.reconcile([broken])).rejects.toBe(activationError);
358+ expect(cleanupEvents).toEqual([{ phase: "rollback", error: cleanupError }]);
359+ await runtime.dispose();
360+ });
361+
362+ it("returns the committed snapshot when retiring an old plugin reports cleanup errors", async () => {
363+ const cleanupError = new Error("retirement cleanup failed");
364+ const cleanupEvents: Array<{ readonly phase: string; readonly error: unknown }> = [];
365+ const runtime = createRuntime({
366+ onCleanupError: (event) => {
367+ cleanupEvents.push(event);
368+ throw new Error("cleanup observer failed");
369+ },
370+ });
371+ await runtime.reconcile([
372+ {
373+ id: "acme.retirement-error",
374+ version: "1.0.0",
375+ activate(context) {
376+ context.register("commands", { id: "retirement", label: "old" });
377+ context.onDispose(() => {
378+ throw cleanupError;
379+ });
380+ },
381+ },
382+ ]);
383+
384+ const snapshot = await runtime.reconcile([
385+ {
386+ id: "acme.retirement-error",
387+ version: "2.0.0",
388+ activate(context) {
389+ context.register("commands", { id: "retirement", label: "new" });
390+ },
391+ },
392+ ]);
393+
394+ expect(contributionLabels(snapshot, "commands")).toEqual(["new"]);
395+ expect(runtime.snapshot()).toBe(snapshot);
396+ expect(cleanupEvents).toEqual([{ phase: "retire", error: cleanupError }]);
397+ await runtime.dispose();
398+ });
399+
259400 it("attempts every plugin cleanup when one finalizer fails", async () => {
260401 const disposed: Array<string> = [];
261402 const runtime = createRuntime();