History

fix: harden plugin runtime transitions

t3-code[bot] committed 2 months ago

Commit 3cb5db11 parent 52ca9af8 files · +333 −103Browse files

Unified · Split

8 files
  1. packages/plugin-runtime-poc/README.md +16 −15
  2. packages/plugin-runtime-poc/src/contract.ts +6 −2
  3. packages/plugin-runtime-poc/src/cordisRuntime.ts +52 −35
  4. packages/plugin-runtime-poc/src/effectScopeRuntime.ts +61 −30
  5. packages/plugin-runtime-poc/src/manifest.test.ts +16 −0
  6. packages/plugin-runtime-poc/src/manifest.ts +6 −2
  7. packages/plugin-runtime-poc/src/pureRuntime.ts +33 −17
  8. packages/plugin-runtime-poc/src/runtimeContract.ts +143 −2
packages/plugin-runtime-poc/README.md+16−15
@@ -21,9 +21,9 @@ it also fits t3's existing effect runtime. adding cordis did not remove the need · 20 unmodified lines
21 21
22| variant | source lines | new runtime dependency | result |22| variant | source lines | new runtime dependency | result |
23| ----------------------- | -----------: | --------------------------------------------------- | ------------------------------------------------------------------ |23| ----------------------- | -----------: | --------------------------------------------------- | ------------------------------------------------------------------ |
24| `effectScopeRuntime.ts` | 376 | none, effect is already used by t3 | best base for production |24| `effectScopeRuntime.ts` | 407 | none, effect is already used by t3 | best base for production |
25| `cordisRuntime.ts` | 472 | cordis rc.8 and about 300 kib of installed packages | works, but adds a second lifecycle runtime and the largest adapter |25| `cordisRuntime.ts` | 493 | cordis rc.8 and about 300 kib of installed packages | works, but adds a second lifecycle runtime and the largest adapter |
26| `pureRuntime.ts` | 378 | none | simplest planner, but manual finalizers are too easy to misuse |26| `pureRuntime.ts` | 394 | none | simplest planner, but manual finalizers are too easy to misuse |
27 27
28`cordisRuntime.ts` uses real cordis `Context`, `Fiber`, `inject`, `provide`, `effect`, and `isolate` behavior. it still needs its own graph analysis and differential reconciliation so unchanged fibers survive an update and failed candidates never replace the live composition.28`cordisRuntime.ts` uses real cordis `Context`, `Fiber`, `inject`, `provide`, `effect`, and `isolate` behavior. it still needs its own graph analysis and differential reconciliation so unchanged fibers survive an update and failed candidates never replace the live composition.
29 29
@@ -31,24 +31,25 @@ cordis `4.0.0-rc.8` also ships extensionless declaration re-exports that do not · 1 unmodified line
31 31
32## behavior proved32## behavior proved
33 33
34all variants run the same contract suite. the full package currently has 44 tests. it checks:34all variants run the same contract suite. the full package currently has 57 tests. it checks:
35 35
36- dependency-first activation from unordered manifests36- dependency-first activation from unordered manifests
37- blocked plugins when required capabilities are missing37- blocked plugins when required capabilities are missing
38- dependent-first teardown38- dependent-first teardown
39- lifo plugin finalizers39- lifo plugin finalizers
40- no restart for unchanged plugin scopes40- no restart for unchanged plugin scopes or reordered dependency declarations
41- provider changes restart only transitive dependents41- targeted restart when versions, provided values, or activation implementations change
42- provided service identity changes restart providers and dependents42- transitive dependent restart without touching unrelated plugins
43- failed replacement keeps the old composition active43- preflight rejection for duplicate ids, duplicate capabilities, and dependency cycles
44- cleanup attempts every plugin even when a finalizer fails44- immutable detached snapshots with prototype-safe plugin ids and contribution slots
45- dependency cycles fail before activation45- rollback that preserves both the active composition and the original activation error
46- plugin ids and contribution slots cannot collide with object prototype names46- cleanup error reporting without rejecting an already committed replacement
47- lifecycle events only for committed plugin compositions
47- deeply frozen public snapshots48- deeply frozen public snapshots
48 49
49the pure planner also proves a 20,000-plugin dependency chain without recursive stack overflow.50the pure planner also proves a 20,000-plugin dependency chain without recursive stack overflow.
50 51
51`manifest.ts` validates namespaced ids, semantic versions, versioned capabilities, permissions, surfaces, and declarative contributions. executable entry points are limited to server, web, and desktop. a mobile javascript entry point is rejected instead of silently ignored.52`manifest.ts` validates namespaced ids, semver 2.0.0 versions, versioned capabilities, permissions, surfaces, and declarative contributions. executable entry points are limited to server, web, and desktop and cannot escape the plugin directory through `..` segments. a mobile javascript entry point is rejected instead of silently ignored.
52 53
53## illustrative timings54## illustrative timings
54 55
@@ -56,9 +57,9 @@ five runs used node `24.13.1`. each run activated a chain of 250 plugins, reconc · 1 unmodified line
56 57
57| variant | activate 250 | unchanged reconcile | restart 250 |58| variant | activate 250 | unchanged reconcile | restart 250 |
58| --------------- | -----------: | ------------------: | ----------: |59| --------------- | -----------: | ------------------: | ----------: |
59| effect scope | 5.61 ms | 1.17 ms | 4.74 ms |60| effect scope | 5.44 ms | 1.16 ms | 4.31 ms |
60| cordis | 100.9 ms | 1.31 ms | 304.89 ms |61| direct cordis | 111.44 ms | 1.31 ms | 333.40 ms |
61| pure reconciler | 0.71 ms | 0.51 ms | 0.65 ms |62| pure reconciler | 0.74 ms | 0.59 ms | 0.69 ms |
62 63
63all three produced the same contributions before and after a provider upgrade.64all three produced the same contributions before and after a provider upgrade.
64 65
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/cordisRuntime.ts+52−35
@@ -181,21 +181,28 @@ function analyzeDefinitions(definitions: ReadonlyArray<PluginDefinition>) { · 180 unmodified lines
181 };181 };
182}182}
183 183
184function sameStrings(left: ReadonlyArray<string>, right: ReadonlyArray<string>) {184function sameStringsUnordered(left: ReadonlyArray<string>, right: ReadonlyArray<string>) {
185 return left.length === right.length && left.every((value, index) => value === right[index]);185 if (left.length !== right.length) return false;
186 const sortedLeft = left.toSorted();
187 const sortedRight = right.toSorted();
188 return sortedLeft.every((value, index) => value === sortedRight[index]);
186}189}
187 190
188function sameDefinition(left: PluginDefinition, right: PluginDefinition) {191function sameDefinition(left: PluginDefinition, right: PluginDefinition) {
189 const leftProvides = Object.entries(left.provides ?? {});192 const leftProvides = left.provides ?? {};
190 const rightProvides = Object.entries(right.provides ?? {});193 const rightProvides = right.provides ?? {};
194 const leftCapabilities = Object.keys(leftProvides);
195 const rightCapabilities = Object.keys(rightProvides);
191 return (196 return (
192 left.id === right.id &&197 left.id === right.id &&
193 left.version === right.version &&198 left.version === right.version &&
194 sameStrings(left.requires ?? [], right.requires ?? []) &&199 Object.is(left.activate, right.activate) &&
195 leftProvides.length === rightProvides.length &&200 sameStringsUnordered(left.requires ?? [], right.requires ?? []) &&
196 leftProvides.every(201 leftCapabilities.length === rightCapabilities.length &&
197 ([capability, service], index) =>202 leftCapabilities.every(
198 rightProvides[index]?.[0] === capability && Object.is(rightProvides[index]?.[1], service),203 (capability) =>
204 Object.hasOwn(rightProvides, capability) &&
205 Object.is(leftProvides[capability], rightProvides[capability]),
199 )206 )
200 );207 );
201}208}
@@ -280,11 +287,7 @@ function snapshotOf( · 78 unmodified lines
280 );287 );
281}288}
282 289
283function aggregateErrorWithCause(errors: ReadonlyArray<unknown>, message: string, cause: unknown) {290async function cleanupComposition(
284 return new AggregateError(errors, message, { cause });
285}
286
287async function disposeComposition(
288 composition: Composition,291 composition: Composition,
289 onDeactivate?: (pluginId: string) => void,292 onDeactivate?: (pluginId: string) => void,
290) {293) {
@@ -302,6 +305,14 @@ async function disposeComposition( · 11 unmodified lines
302 }305 }
303 errors.push(...plugin.cleanupErrors.splice(0));306 errors.push(...plugin.cleanupErrors.splice(0));
304 }307 }
308 return errors;
309}
310
311async function disposeComposition(
312 composition: Composition,
313 onDeactivate?: (pluginId: string) => void,
314) {
315 const errors = await cleanupComposition(composition, onDeactivate);
305 if (errors.length) {316 if (errors.length) {
306 throw new AggregateError(errors, "plugin composition cleanup failed");317 throw new AggregateError(errors, "plugin composition cleanup failed");
307 }318 }
@@ -322,6 +333,16 @@ export const createCordisRuntime: PluginRuntimeFactory = (options = {}): PluginR · 14 unmodified lines
322 return result;333 return result;
323 };334 };
324 335
336 const reportCleanupErrors = (phase: "retire" | "rollback", errors: ReadonlyArray<unknown>) => {
337 for (const error of errors) {
338 try {
339 options.onCleanupError?.({ phase, error });
340 } catch {
341 // Cleanup reporting must not replace activation errors or undo a committed snapshot.
342 }
343 }
344 };
345
325 const reconcile = (definitions: ReadonlyArray<PluginDefinition>) =>346 const reconcile = (definitions: ReadonlyArray<PluginDefinition>) =>
326 runExclusive(async () => {347 runExclusive(async () => {
327 if (disposed) throw new Error("plugin runtime is disposed");348 if (disposed) throw new Error("plugin runtime is disposed");
@@ -404,7 +425,6 @@ export const createCordisRuntime: PluginRuntimeFactory = (options = {}): PluginR · 76 unmodified lines
404 finalizers.push(finalizer);425 finalizers.push(finalizer);
405 },426 },
406 });427 });
407 options.onLifecycle?.({ phase: "activate", pluginId: definition.id });
408 };428 };
409 cordisPlugin.inject = [...(definition.requires ?? [])];429 cordisPlugin.inject = [...(definition.requires ?? [])];
410 cordisPlugin.provide = Object.keys(definition.provides ?? {});430 cordisPlugin.provide = Object.keys(definition.provides ?? {});
@@ -415,29 +435,21 @@ export const createCordisRuntime: PluginRuntimeFactory = (options = {}): PluginR · 4 unmodified lines
415 try {435 try {
416 await fiber;436 await fiber;
417 } catch (error) {437 } catch (error) {
418 try {438 const cleanupErrors = await cleanupComposition({
419 await disposeComposition({ plugins: [plugin], snapshot: emptySnapshot });439 plugins: [plugin],
420 } catch (cleanupError) {440 snapshot: emptySnapshot,
421 throw aggregateErrorWithCause(441 });
422 [error, cleanupError],442 reportCleanupErrors("rollback", cleanupErrors);
423 `plugin ${definition.id} activation and cleanup failed`,
424 cleanupError,
425 );
426 }
427 throw error;443 throw error;
428 }444 }
429 staged.set(definition.id, plugin);445 staged.set(definition.id, plugin);
430 }446 }
431 } catch (error) {447 } catch (error) {
432 try {448 const cleanupErrors = await cleanupComposition({
433 await disposeComposition({ plugins: [...staged.values()], snapshot: emptySnapshot });449 plugins: [...staged.values()],
434 } catch (cleanupError) {450 snapshot: emptySnapshot,
435 throw aggregateErrorWithCause(451 });
436 [error, cleanupError],452 reportCleanupErrors("rollback", cleanupErrors);
437 "plugin composition activation and rollback failed",
438 cleanupError,
439 );
440 }
441 throw error;453 throw error;
442 }454 }
443 455
@@ -449,9 +461,14 @@ export const createCordisRuntime: PluginRuntimeFactory = (options = {}): PluginR · 5 unmodified lines
449 const candidate = { plugins, snapshot: snapshotOf(plugins, blocked) };461 const candidate = { plugins, snapshot: snapshotOf(plugins, blocked) };
450 const previous = composition.plugins.filter((plugin) => !plugins.includes(plugin));462 const previous = composition.plugins.filter((plugin) => !plugins.includes(plugin));
451 composition = candidate;463 composition = candidate;
452 await disposeComposition({ plugins: previous, snapshot: emptySnapshot }, (pluginId) =>464 for (const plugin of staged.values()) {
453 options.onLifecycle?.({ phase: "deactivate", pluginId }),465 options.onLifecycle?.({ phase: "activate", pluginId: plugin.definition.id });
466 }
467 const cleanupErrors = await cleanupComposition(
468 { plugins: previous, snapshot: emptySnapshot },
469 (pluginId) => options.onLifecycle?.({ phase: "deactivate", pluginId }),
454 );470 );
471 reportCleanupErrors("retire", cleanupErrors);
455 return candidate.snapshot;472 return candidate.snapshot;
456 });473 });
457 474
packages/plugin-runtime-poc/src/effectScopeRuntime.ts+61−30
@@ -132,20 +132,33 @@ const planComposition = (definitions: ReadonlyArray<PluginDefinition>): PlannedC · 131 unmodified lines
132 return { blocked: blockedRecord, definitions: ordered };132 return { blocked: blockedRecord, definitions: ordered };
133};133};
134 134
135const sameStrings = (left: ReadonlyArray<string>, right: ReadonlyArray<string>) =>135const sameStrings = (left: ReadonlyArray<string>, right: ReadonlyArray<string>) => {
136 left.length === right.length && left.every((value, index) => value === right[index]);136 if (left.length !== right.length) return false;
137 const sortedLeft = [...left].sort();
138 const sortedRight = [...right].sort();
139 return sortedLeft.every((value, index) => value === sortedRight[index]);
140};
137 141
138const sameDefinition = (left: PluginDefinition, right: PluginDefinition): boolean => {142const sameDefinition = (left: PluginDefinition, right: PluginDefinition): boolean => {
139 if (left.id !== right.id || left.version !== right.version) return false;143 if (
144 left.id !== right.id ||
145 left.version !== right.version ||
146 !Object.is(left.activate, right.activate)
147 ) {
148 return false;
149 }
140 if (!sameStrings(left.requires ?? [], right.requires ?? [])) return false;150 if (!sameStrings(left.requires ?? [], right.requires ?? [])) return false;
141 151
142 const leftProvides = Object.entries(left.provides ?? {});152 const leftProvides = left.provides ?? {};
143 const rightProvides = Object.entries(right.provides ?? {});153 const rightProvides = right.provides ?? {};
154 const leftCapabilities = Object.keys(leftProvides);
155 const rightCapabilities = Object.keys(rightProvides);
144 return (156 return (
145 leftProvides.length === rightProvides.length &&157 leftCapabilities.length === rightCapabilities.length &&
146 leftProvides.every(158 leftCapabilities.every(
147 ([capability, service], index) =>159 (capability) =>
148 rightProvides[index]?.[0] === capability && Object.is(rightProvides[index]?.[1], service),160 Object.hasOwn(rightProvides, capability) &&
161 Object.is(leftProvides[capability], rightProvides[capability]),
149 )162 )
150 );163 );
151};164};
@@ -210,7 +223,10 @@ const affectedPluginIds = ( · 58 unmodified lines
210 return affected;223 return affected;
211};224};
212 225
213const closePlugins = async (plugins: ReadonlyArray<LivePlugin>): Promise<void> => {226const closePlugins = async (
227 plugins: ReadonlyArray<LivePlugin>,
228 onDeactivate?: (pluginId: string) => void,
229): Promise<ReadonlyArray<unknown>> => {
214 const errors: Array<unknown> = [];230 const errors: Array<unknown> = [];
215 for (const plugin of plugins.toReversed()) {231 for (const plugin of plugins.toReversed()) {
216 try {232 try {
@@ -218,10 +234,13 @@ const closePlugins = async (plugins: ReadonlyArray<LivePlugin>): Promise<void> = · 1 unmodified line
218 } catch (error) {234 } catch (error) {
219 errors.push(error);235 errors.push(error);
220 }236 }
237 try {
238 onDeactivate?.(plugin.definition.id);
239 } catch (error) {
240 errors.push(error);
241 }
221 }242 }
222 if (errors.length > 0) {243 return errors;
223 throw new AggregateError(errors, "Failed to close plugin scopes");
224 }
225};244};
226 245
227const snapshotOf = (246const snapshotOf = (
@@ -252,22 +271,23 @@ export const createEffectScopeRuntime: PluginRuntimeFactory = (options = {}): Pl · 24 unmodified lines
252 let disposed = false;271 let disposed = false;
253 let transition: Promise<void> = Promise.resolve();272 let transition: Promise<void> = Promise.resolve();
254 273
274 const reportCleanupErrors = (phase: "retire" | "rollback", errors: ReadonlyArray<unknown>) => {
275 for (const error of errors) {
276 try {
277 options.onCleanupError?.({ phase, error });
278 } catch {
279 // Cleanup reporting must not replace activation errors or undo a committed snapshot.
280 }
281 }
282 };
283
255 const activatePlugin = async (284 const activatePlugin = async (
256 definition: PluginDefinition,285 definition: PluginDefinition,
257 capabilities: ReadonlyMap<string, unknown>,286 capabilities: ReadonlyMap<string, unknown>,
258 ): Promise<LivePlugin> => {287 ): Promise<LivePlugin> => {
259 const scope = Effect.runSync(Scope.make("sequential"));288 const scope = Effect.runSync(Scope.make("sequential"));
260 const contributions = new Map<string, Array<Contribution>>();289 const contributions = new Map<string, Array<Contribution>>();
261 let activated = false;290 const plugin = { definition, scope, contributions };
262
263 Effect.runSync(
264 Scope.addFinalizer(
265 scope,
266 Effect.sync(() => {
267 if (activated) options.onLifecycle?.({ phase: "deactivate", pluginId: definition.id });
268 }),
269 ),
270 );
271 291
272 const context: PluginActivationContext = {292 const context: PluginActivationContext = {
273 resolve: <Service>(capability: string): Service => {293 resolve: <Service>(capability: string): Service => {
@@ -299,11 +319,10 @@ export const createEffectScopeRuntime: PluginRuntimeFactory = (options = {}): Pl · 25 unmodified lines
299 Effect.provideService(Scope.Scope, scope),319 Effect.provideService(Scope.Scope, scope),
300 ),320 ),
301 );321 );
302 activated = true;322 return plugin;
303 options.onLifecycle?.({ phase: "activate", pluginId: definition.id });
304 return { definition, scope, contributions };
305 } catch (error) {323 } catch (error) {
306 await Effect.runPromise(Scope.close(scope, Exit.void));324 const cleanupErrors = await closePlugins([plugin]);
325 reportCleanupErrors("rollback", cleanupErrors);
307 throw error;326 throw error;
308 }327 }
309 };328 };
@@ -346,7 +365,8 @@ export const createEffectScopeRuntime: PluginRuntimeFactory = (options = {}): Pl · 36 unmodified lines
346 }365 }
347 }366 }
348 } catch (error) {367 } catch (error) {
349 await closePlugins([...staged.values()]);368 const cleanupErrors = await closePlugins([...staged.values()]);
369 reportCleanupErrors("rollback", cleanupErrors);
350 throw error;370 throw error;
351 }371 }
352 372
@@ -360,7 +380,13 @@ export const createEffectScopeRuntime: PluginRuntimeFactory = (options = {}): Pl · 7 unmodified lines
360 plugins: nextPlugins,380 plugins: nextPlugins,
361 snapshot: snapshotOf(nextPlugins, plan.blocked),381 snapshot: snapshotOf(nextPlugins, plan.blocked),
362 };382 };
363 await closePlugins(previous);383 for (const plugin of staged.values()) {
384 options.onLifecycle?.({ phase: "activate", pluginId: plugin.definition.id });
385 }
386 const cleanupErrors = await closePlugins(previous, (pluginId) =>
387 options.onLifecycle?.({ phase: "deactivate", pluginId }),
388 );
389 reportCleanupErrors("retire", cleanupErrors);
364 return current.snapshot;390 return current.snapshot;
365 }),391 }),
366 snapshot: () => current.snapshot,392 snapshot: () => current.snapshot,
@@ -370,7 +396,12 @@ export const createEffectScopeRuntime: PluginRuntimeFactory = (options = {}): Pl · 3 unmodified lines
370 disposed = true;396 disposed = true;
371 const previous = current.plugins;397 const previous = current.plugins;
372 current = { plugins: [], snapshot: emptySnapshot() };398 current = { plugins: [], snapshot: emptySnapshot() };
373 await closePlugins(previous);399 const cleanupErrors = await closePlugins(previous, (pluginId) =>
400 options.onLifecycle?.({ phase: "deactivate", pluginId }),
401 );
402 if (cleanupErrors.length > 0) {
403 throw new AggregateError(cleanupErrors, "Failed to close plugin scopes");
404 }
374 }),405 }),
375 };406 };
376};407};
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();