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
14 14
15## proved15## proved
16 16
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 chain17- 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 keeps mobile declarative18- manifest validation enforces semver, prevents entrypoint path escape, and keeps mobile declarative
19- the demo exercises activation, replacement, teardown, and a 250-plugin chain19- the demo exercises activation, replacement, teardown, and a 250-plugin chain
20 20
21## run21## run
packages/plugin-runtime-poc/src/contract.ts+6−2
@@ -19,8 +19,8 @@ export interface PluginActivationContext { · 18 unmodified lines
19 19
20export interface PluginRuntimeSnapshot {20export interface PluginRuntimeSnapshot {
21 readonly active: ReadonlyArray<string>;21 readonly active: ReadonlyArray<string>;
22 readonly blocked: Readonly<Record<string, string>>;22 readonly blocked: Readonly<Partial<Record<string, string>>>;
23 readonly contributions: Readonly<Record<string, ReadonlyArray<Contribution>>>;23 readonly contributions: Readonly<Partial<Record<string, ReadonlyArray<Contribution>>>>;
24}24}
25 25
26export interface PluginRuntime {26export interface PluginRuntime {
@@ -36,6 +36,10 @@ export interface PluginRuntimeOptions { · 9 unmodified lines
36 readonly phase: "activate" | "deactivate";36 readonly phase: "activate" | "deactivate";
37 readonly pluginId: string;37 readonly pluginId: string;
38 }) => void;38 }) => void;
39 readonly onCleanupError?: (event: {
40 readonly phase: "retire" | "rollback";
41 readonly error: unknown;
42 }) => void;
39}43}
40 44
41export type PluginRuntimeFactory = (options?: PluginRuntimeOptions) => PluginRuntime;45export type PluginRuntimeFactory = (options?: PluginRuntimeOptions) => PluginRuntime;
packages/plugin-runtime-poc/src/manifest.test.ts+16−0
@@ -40,9 +40,25 @@ describe("PluginManifest", () => { · 39 unmodified lines
40 40
41 it("rejects malformed versions and capability ids", () => {41 it("rejects malformed versions and capability ids", () => {
42 expect(() => decodeManifest({ ...validManifest, version: "next" })).toThrow();42 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 );
43 expect(() => decodeManifest({ ...validManifest, requires: ["t3.commands"] })).toThrow();48 expect(() => decodeManifest({ ...validManifest, requires: ["t3.commands"] })).toThrow();
44 });49 });
45 50
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
46 it("keeps mobile declarative by excluding a mobile executable entrypoint", () => {62 it("keeps mobile declarative by excluding a mobile executable entrypoint", () => {
47 const decoded = decodeManifest({63 const decoded = decodeManifest({
48 ...validManifest,64 ...validManifest,
packages/plugin-runtime-poc/src/manifest.ts+6−2
@@ -5,12 +5,16 @@ const NamespacedId = Schema.String.check( · 4 unmodified lines
5);5);
6 6
7const SemanticVersion = Schema.String.check(7const 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 ),
9);11);
10 12
11const CapabilityId = Schema.String.check(Schema.isPattern(/^[a-z0-9][a-z0-9.-]*@[1-9]\d*$/));13const CapabilityId = Schema.String.check(Schema.isPattern(/^[a-z0-9][a-z0-9.-]*@[1-9]\d*$/));
12 14
13const RelativeEntrypoint = Schema.String.check(Schema.isPattern(/^\.\/[A-Za-z0-9_./-]+$/));15const RelativeEntrypoint = Schema.String.check(
16 Schema.isPattern(/^\.\/(?!(?:\.\.(?:\/|$)|.*\/\.\.(?:\/|$)))[A-Za-z0-9_./-]+$/),
17);
14const Permission = Schema.String.check(Schema.isPattern(/^[a-z][a-z-]*:.+$/));18const Permission = Schema.String.check(Schema.isPattern(/^[a-z][a-z-]*:.+$/));
15 19
16const ContributionCatalog = Schema.Struct({20const 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
150};150};
151 151
152const sameDefinitionShape = (previous: PluginDefinition, desired: PluginDefinition) => {152const sameDefinitionShape = (previous: PluginDefinition, desired: PluginDefinition) => {
153 const previousRequires = previous.requires ?? [];153 const previousRequires = [...(previous.requires ?? [])].sort();
154 const desiredRequires = desired.requires ?? [];154 const desiredRequires = [...(desired.requires ?? [])].sort();
155 const previousProvidedServices = previous.provides ?? {};155 const previousProvidedServices = previous.provides ?? {};
156 const desiredProvidedServices = desired.provides ?? {};156 const desiredProvidedServices = desired.provides ?? {};
157 const previousProvides = Object.keys(previousProvidedServices);157 const previousProvides = Object.keys(previousProvidedServices);
@@ -159,20 +159,18 @@ const sameDefinitionShape = (previous: PluginDefinition, desired: PluginDefiniti · 1 unmodified line
159 159
160 return (160 return (
161 previous.version === desired.version &&161 previous.version === desired.version &&
162 Object.is(previous.activate, desired.activate) &&
162 previousRequires.length === desiredRequires.length &&163 previousRequires.length === desiredRequires.length &&
163 previousRequires.every((capability, index) => capability === desiredRequires[index]) &&164 previousRequires.every((capability, index) => capability === desiredRequires[index]) &&
164 previousProvides.length === desiredProvides.length &&165 previousProvides.length === desiredProvides.length &&
165 previousProvides.every(166 previousProvides.every(
166 (capability, index) =>167 (capability) =>
167 capability === desiredProvides[index] &&168 Object.hasOwn(desiredProvidedServices, capability) &&
168 Object.is(previousProvidedServices[capability], desiredProvidedServices[capability]),169 Object.is(previousProvidedServices[capability], desiredProvidedServices[capability]),
169 )170 )
170 );171 );
171};172};
172 173
173const messageOf = (error: unknown) =>
174 error instanceof Error ? error.message : "plugin activation failed";
175
176export const createPureRuntime: PluginRuntimeFactory = (options = {}): PluginRuntime => {174export const createPureRuntime: PluginRuntimeFactory = (options = {}): PluginRuntime => {
177 let current = emptyComposition();175 let current = emptyComposition();
178 let disposed = false;176 let disposed = false;
@@ -216,6 +214,16 @@ export const createPureRuntime: PluginRuntimeFactory = (options = {}): PluginRun · 37 unmodified lines
216 return errors;214 return errors;
217 };215 };
218 216
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
219 const stage = async (227 const stage = async (
220 definitions: ReadonlyArray<PluginDefinition>,228 definitions: ReadonlyArray<PluginDefinition>,
221 previous: Composition,229 previous: Composition,
@@ -303,8 +311,6 @@ export const createPureRuntime: PluginRuntimeFactory = (options = {}): PluginRun · 81 unmodified lines
303 activating = false;311 activating = false;
304 }312 }
305 313
306 instance.activated = true;
307 options.onLifecycle?.({ phase: "activate", pluginId: definition.id });
308 for (const [capability, service] of Object.entries(definition.provides ?? {})) {314 for (const [capability, service] of Object.entries(definition.provides ?? {})) {
309 services.set(capability, service);315 services.set(capability, service);
310 }316 }
@@ -323,12 +329,7 @@ export const createPureRuntime: PluginRuntimeFactory = (options = {}): PluginRun · 12 unmodified lines
323 };329 };
324 } catch (activationError) {330 } catch (activationError) {
325 const cleanupErrors = await deactivateAll(staged);331 const cleanupErrors = await deactivateAll(staged);
326 if (cleanupErrors.length > 0) {332 reportCleanupErrors("rollback", cleanupErrors);
327 throw new Error(
328 `${messageOf(activationError)}; ${cleanupErrors.length} rollback finalizer(s) also failed`,
329 { cause: activationError },
330 );
331 }
332 throw activationError;333 throw activationError;
333 }334 }
334 };335 };
@@ -352,11 +353,26 @@ export const createPureRuntime: PluginRuntimeFactory = (options = {}): PluginRun · 17 unmodified lines
352 const candidate = await stage(desired, previous);353 const candidate = await stage(desired, previous);
353 current = candidate;354 current = candidate;
354 355
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
355 const retained = new Set(candidate.instances);367 const retained = new Set(candidate.instances);
356 const retired = previous.instances.filter((instance) => !retained.has(instance));368 const retired = previous.instances.filter((instance) => !retained.has(instance));
357 const cleanupErrors = await deactivateAll(retired);369 const cleanupErrors = await deactivateAll(retired);
358 if (cleanupErrors.length > 0) {370 reportCleanupErrors("retire", cleanupErrors);
359 throw new AggregateError(cleanupErrors, "previous composition deactivation failed");371 if (lifecycleErrors.length > 0) {
372 throw new AggregateError(
373 lifecycleErrors,
374 "committed composition lifecycle reporting failed",
375 );
360 }376 }
361 return candidate.snapshot;377 return candidate.snapshot;
362 });378 });
packages/plugin-runtime-poc/src/runtimeContract.ts+143−2
@@ -116,6 +116,78 @@ export function defineRuntimeContract(name: string, createRuntime: PluginRuntime · 115 unmodified lines
116 expect(disposals).toBe(1);116 expect(disposals).toBe(1);
117 });117 });
118 118
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
119 it("restarts a changed provider and its dependents without touching independent plugins", async () => {191 it("restarts a changed provider and its dependents without touching independent plugins", async () => {
120 let independentActivations = 0;192 let independentActivations = 0;
121 const lifecycle: Array<string> = [];193 const lifecycle: Array<string> = [];
@@ -237,11 +309,16 @@ export function defineRuntimeContract(name: string, createRuntime: PluginRuntime · 115 unmodified lines
237 await runtime.dispose();309 await runtime.dispose();
238 });310 });
239 311
240 it("does not report a failed candidate as activated", async () => {312 it("does not report a rolled-back candidate as activated", async () => {
241 const lifecycle: Array<string> = [];313 const lifecycle: Array<string> = [];
242 const runtime = createRuntime({314 const runtime = createRuntime({
243 onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`),315 onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`),
244 });316 });
317 const staged: PluginDefinition = {
318 id: "acme.staged",
319 version: "1.0.0",
320 activate() {},
321 };
245 const broken: PluginDefinition = {322 const broken: PluginDefinition = {
246 id: "acme.broken",323 id: "acme.broken",
247 version: "1.0.0",324 version: "1.0.0",
@@ -250,12 +327,76 @@ export function defineRuntimeContract(name: string, createRuntime: PluginRuntime · 2 unmodified lines
250 },327 },
251 };328 };
252 329
253 await expect(runtime.reconcile([broken])).rejects.toThrow("activation failed");330 await expect(runtime.reconcile([staged, broken])).rejects.toThrow("activation failed");
254 331
255 expect(lifecycle).toEqual([]);332 expect(lifecycle).toEqual([]);
256 await runtime.dispose();333 await runtime.dispose();
257 });334 });
258 335
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
259 it("attempts every plugin cleanup when one finalizer fails", async () => {400 it("attempts every plugin cleanup when one finalizer fails", async () => {
260 const disposed: Array<string> = [];401 const disposed: Array<string> = [];
261 const runtime = createRuntime();402 const runtime = createRuntime();