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
2121
2222 | variant | source lines | new runtime dependency | result |
2323 | ----------------------- | -----------: | --------------------------------------------------- | ------------------------------------------------------------------ |
24−| `effectScopeRuntime.ts` | 376 | 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 |
26−| `pureRuntime.ts` | 378 | none | simplest planner, but manual finalizers are too easy to misuse |
24+| `effectScopeRuntime.ts` | 407 | none, effect is already used by t3 | best base for production |
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` | 394 | none | simplest planner, but manual finalizers are too easy to misuse |
2727
2828 `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.
2929
@@ -31,24 +31,25 @@ cordis `4.0.0-rc.8` also ships extensionless declaration re-exports that do not · 1 unmodified line
3131
3232 ## behavior proved
3333
34−all variants run the same contract suite. the full package currently has 44 tests. it checks:
34+all variants run the same contract suite. the full package currently has 57 tests. it checks:
3535
3636 - dependency-first activation from unordered manifests
3737 - blocked plugins when required capabilities are missing
3838 - dependent-first teardown
3939 - lifo plugin finalizers
40−- no restart for unchanged plugin scopes
41−- provider changes restart only transitive dependents
42−- provided service identity changes restart providers and dependents
43−- failed replacement keeps the old composition active
44−- cleanup attempts every plugin even when a finalizer fails
45−- dependency cycles fail before activation
46−- plugin ids and contribution slots cannot collide with object prototype names
40+- no restart for unchanged plugin scopes or reordered dependency declarations
41+- targeted restart when versions, provided values, or activation implementations change
42+- transitive dependent restart without touching unrelated plugins
43+- preflight rejection for duplicate ids, duplicate capabilities, and dependency cycles
44+- immutable detached snapshots with prototype-safe plugin ids and contribution slots
45+- rollback that preserves both the active composition and the original activation error
46+- cleanup error reporting without rejecting an already committed replacement
47+- lifecycle events only for committed plugin compositions
4748 - deeply frozen public snapshots
4849
4950 the pure planner also proves a 20,000-plugin dependency chain without recursive stack overflow.
5051
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.
5253
5354 ## illustrative timings
5455
@@ -56,9 +57,9 @@ five runs used node `24.13.1`. each run activated a chain of 250 plugins, reconc · 1 unmodified line
5657
5758 | variant | activate 250 | unchanged reconcile | restart 250 |
5859 | --------------- | -----------: | ------------------: | ----------: |
59−| effect scope | 5.61 ms | 1.17 ms | 4.74 ms |
60−| cordis | 100.9 ms | 1.31 ms | 304.89 ms |
61−| pure reconciler | 0.71 ms | 0.51 ms | 0.65 ms |
60+| effect scope | 5.44 ms | 1.16 ms | 4.31 ms |
61+| direct cordis | 111.44 ms | 1.31 ms | 333.40 ms |
62+| pure reconciler | 0.74 ms | 0.59 ms | 0.69 ms |
6263
6364 all three produced the same contributions before and after a provider upgrade.
6465
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/cordisRuntime.ts+52−35
@@ -181,21 +181,28 @@ function analyzeDefinitions(definitions: ReadonlyArray<PluginDefinition>) { · 180 unmodified lines
181181 };
182182 }
183183
184−function sameStrings(left: ReadonlyArray<string>, right: ReadonlyArray<string>) {
185− return left.length === right.length && left.every((value, index) => value === right[index]);
184+function sameStringsUnordered(left: ReadonlyArray<string>, right: ReadonlyArray<string>) {
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]);
186189 }
187190
188191 function sameDefinition(left: PluginDefinition, right: PluginDefinition) {
189− const leftProvides = Object.entries(left.provides ?? {});
190− const rightProvides = Object.entries(right.provides ?? {});
192+ const leftProvides = left.provides ?? {};
193+ const rightProvides = right.provides ?? {};
194+ const leftCapabilities = Object.keys(leftProvides);
195+ const rightCapabilities = Object.keys(rightProvides);
191196 return (
192197 left.id === right.id &&
193198 left.version === right.version &&
194− sameStrings(left.requires ?? [], right.requires ?? []) &&
195− leftProvides.length === rightProvides.length &&
196− leftProvides.every(
197− ([capability, service], index) =>
198− rightProvides[index]?.[0] === capability && Object.is(rightProvides[index]?.[1], service),
199+ Object.is(left.activate, right.activate) &&
200+ sameStringsUnordered(left.requires ?? [], right.requires ?? []) &&
201+ leftCapabilities.length === rightCapabilities.length &&
202+ leftCapabilities.every(
203+ (capability) =>
204+ Object.hasOwn(rightProvides, capability) &&
205+ Object.is(leftProvides[capability], rightProvides[capability]),
199206 )
200207 );
201208 }
@@ -280,11 +287,7 @@ function snapshotOf( · 78 unmodified lines
280287 );
281288 }
282289
283−function aggregateErrorWithCause(errors: ReadonlyArray<unknown>, message: string, cause: unknown) {
284− return new AggregateError(errors, message, { cause });
285−}
286−
287−async function disposeComposition(
290+async function cleanupComposition(
288291 composition: Composition,
289292 onDeactivate?: (pluginId: string) => void,
290293 ) {
@@ -302,6 +305,14 @@ async function disposeComposition( · 11 unmodified lines
302305 }
303306 errors.push(...plugin.cleanupErrors.splice(0));
304307 }
308+ return errors;
309+}
310+
311+async function disposeComposition(
312+ composition: Composition,
313+ onDeactivate?: (pluginId: string) => void,
314+) {
315+ const errors = await cleanupComposition(composition, onDeactivate);
305316 if (errors.length) {
306317 throw new AggregateError(errors, "plugin composition cleanup failed");
307318 }
@@ -322,6 +333,16 @@ export const createCordisRuntime: PluginRuntimeFactory = (options = {}): PluginR · 14 unmodified lines
322333 return result;
323334 };
324335
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+
325346 const reconcile = (definitions: ReadonlyArray<PluginDefinition>) =>
326347 runExclusive(async () => {
327348 if (disposed) throw new Error("plugin runtime is disposed");
@@ -404,7 +425,6 @@ export const createCordisRuntime: PluginRuntimeFactory = (options = {}): PluginR · 76 unmodified lines
404425 finalizers.push(finalizer);
405426 },
406427 });
407− options.onLifecycle?.({ phase: "activate", pluginId: definition.id });
408428 };
409429 cordisPlugin.inject = [...(definition.requires ?? [])];
410430 cordisPlugin.provide = Object.keys(definition.provides ?? {});
@@ -415,29 +435,21 @@ export const createCordisRuntime: PluginRuntimeFactory = (options = {}): PluginR · 4 unmodified lines
415435 try {
416436 await fiber;
417437 } catch (error) {
418− try {
419− await disposeComposition({ plugins: [plugin], snapshot: emptySnapshot });
420− } catch (cleanupError) {
421− throw aggregateErrorWithCause(
422− [error, cleanupError],
423− `plugin ${definition.id} activation and cleanup failed`,
424− cleanupError,
425− );
426− }
438+ const cleanupErrors = await cleanupComposition({
439+ plugins: [plugin],
440+ snapshot: emptySnapshot,
441+ });
442+ reportCleanupErrors("rollback", cleanupErrors);
427443 throw error;
428444 }
429445 staged.set(definition.id, plugin);
430446 }
431447 } catch (error) {
432− try {
433− await disposeComposition({ plugins: [...staged.values()], snapshot: emptySnapshot });
434− } catch (cleanupError) {
435− throw aggregateErrorWithCause(
436− [error, cleanupError],
437− "plugin composition activation and rollback failed",
438− cleanupError,
439− );
440− }
448+ const cleanupErrors = await cleanupComposition({
449+ plugins: [...staged.values()],
450+ snapshot: emptySnapshot,
451+ });
452+ reportCleanupErrors("rollback", cleanupErrors);
441453 throw error;
442454 }
443455
@@ -449,9 +461,14 @@ export const createCordisRuntime: PluginRuntimeFactory = (options = {}): PluginR · 5 unmodified lines
449461 const candidate = { plugins, snapshot: snapshotOf(plugins, blocked) };
450462 const previous = composition.plugins.filter((plugin) => !plugins.includes(plugin));
451463 composition = candidate;
452− await disposeComposition({ plugins: previous, snapshot: emptySnapshot }, (pluginId) =>
453− options.onLifecycle?.({ phase: "deactivate", pluginId }),
464+ for (const plugin of staged.values()) {
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 }),
454470 );
471+ reportCleanupErrors("retire", cleanupErrors);
455472 return candidate.snapshot;
456473 });
457474
packages/plugin-runtime-poc/src/effectScopeRuntime.ts+61−30
@@ -132,20 +132,33 @@ const planComposition = (definitions: ReadonlyArray<PluginDefinition>): PlannedC · 131 unmodified lines
132132 return { blocked: blockedRecord, definitions: ordered };
133133 };
134134
135−const sameStrings = (left: ReadonlyArray<string>, right: ReadonlyArray<string>) =>
136− left.length === right.length && left.every((value, index) => value === right[index]);
135+const sameStrings = (left: ReadonlyArray<string>, right: ReadonlyArray<string>) => {
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+};
137141
138142 const 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+ }
140150 if (!sameStrings(left.requires ?? [], right.requires ?? [])) return false;
141151
142− const leftProvides = Object.entries(left.provides ?? {});
143− const rightProvides = Object.entries(right.provides ?? {});
152+ const leftProvides = left.provides ?? {};
153+ const rightProvides = right.provides ?? {};
154+ const leftCapabilities = Object.keys(leftProvides);
155+ const rightCapabilities = Object.keys(rightProvides);
144156 return (
145− leftProvides.length === rightProvides.length &&
146− leftProvides.every(
147− ([capability, service], index) =>
148− rightProvides[index]?.[0] === capability && Object.is(rightProvides[index]?.[1], service),
157+ leftCapabilities.length === rightCapabilities.length &&
158+ leftCapabilities.every(
159+ (capability) =>
160+ Object.hasOwn(rightProvides, capability) &&
161+ Object.is(leftProvides[capability], rightProvides[capability]),
149162 )
150163 );
151164 };
@@ -210,7 +223,10 @@ const affectedPluginIds = ( · 58 unmodified lines
210223 return affected;
211224 };
212225
213−const closePlugins = async (plugins: ReadonlyArray<LivePlugin>): Promise<void> => {
226+const closePlugins = async (
227+ plugins: ReadonlyArray<LivePlugin>,
228+ onDeactivate?: (pluginId: string) => void,
229+): Promise<ReadonlyArray<unknown>> => {
214230 const errors: Array<unknown> = [];
215231 for (const plugin of plugins.toReversed()) {
216232 try {
@@ -218,10 +234,13 @@ const closePlugins = async (plugins: ReadonlyArray<LivePlugin>): Promise<void> = · 1 unmodified line
218234 } catch (error) {
219235 errors.push(error);
220236 }
237+ try {
238+ onDeactivate?.(plugin.definition.id);
239+ } catch (error) {
240+ errors.push(error);
241+ }
221242 }
222− if (errors.length > 0) {
223− throw new AggregateError(errors, "Failed to close plugin scopes");
224− }
243+ return errors;
225244 };
226245
227246 const snapshotOf = (
@@ -252,22 +271,23 @@ export const createEffectScopeRuntime: PluginRuntimeFactory = (options = {}): Pl · 24 unmodified lines
252271 let disposed = false;
253272 let transition: Promise<void> = Promise.resolve();
254273
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+
255284 const activatePlugin = async (
256285 definition: PluginDefinition,
257286 capabilities: ReadonlyMap<string, unknown>,
258287 ): Promise<LivePlugin> => {
259288 const scope = Effect.runSync(Scope.make("sequential"));
260289 const contributions = new Map<string, Array<Contribution>>();
261− let activated = false;
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− );
290+ const plugin = { definition, scope, contributions };
271291
272292 const context: PluginActivationContext = {
273293 resolve: <Service>(capability: string): Service => {
@@ -299,11 +319,10 @@ export const createEffectScopeRuntime: PluginRuntimeFactory = (options = {}): Pl · 25 unmodified lines
299319 Effect.provideService(Scope.Scope, scope),
300320 ),
301321 );
302− activated = true;
303− options.onLifecycle?.({ phase: "activate", pluginId: definition.id });
304− return { definition, scope, contributions };
322+ return plugin;
305323 } catch (error) {
306− await Effect.runPromise(Scope.close(scope, Exit.void));
324+ const cleanupErrors = await closePlugins([plugin]);
325+ reportCleanupErrors("rollback", cleanupErrors);
307326 throw error;
308327 }
309328 };
@@ -346,7 +365,8 @@ export const createEffectScopeRuntime: PluginRuntimeFactory = (options = {}): Pl · 36 unmodified lines
346365 }
347366 }
348367 } catch (error) {
349− await closePlugins([...staged.values()]);
368+ const cleanupErrors = await closePlugins([...staged.values()]);
369+ reportCleanupErrors("rollback", cleanupErrors);
350370 throw error;
351371 }
352372
@@ -360,7 +380,13 @@ export const createEffectScopeRuntime: PluginRuntimeFactory = (options = {}): Pl · 7 unmodified lines
360380 plugins: nextPlugins,
361381 snapshot: snapshotOf(nextPlugins, plan.blocked),
362382 };
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);
364390 return current.snapshot;
365391 }),
366392 snapshot: () => current.snapshot,
@@ -370,7 +396,12 @@ export const createEffectScopeRuntime: PluginRuntimeFactory = (options = {}): Pl · 3 unmodified lines
370396 disposed = true;
371397 const previous = current.plugins;
372398 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+ }
374405 }),
375406 };
376407 };
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();