History

refactor: expose plugin runtime as effect service

t3-code[bot] committed 2 months ago

Commit 14cd84c1 parent 60a9cb38 files · +1385 −1138Browse files

Unified · Split

8 files
  1. packages/plugin-runtime/README.md +2 −2
  2. packages/plugin-runtime/package.json +1 −0
  3. packages/plugin-runtime/src/contract.ts +0 −10
  4. packages/plugin-runtime/src/index.ts +7 −3
  5. packages/plugin-runtime/src/runtime.ts +289 −276
  6. packages/plugin-runtime/test/runtime.test.ts +80 −33
  7. packages/plugin-runtime/test/runtimeContract.ts +1003 −814
  8. pnpm-lock.yaml +3 −0
packages/plugin-runtime/README.md+2−2
@@ -1,7 +1,7 @@
11 # plugin runtime
22
3−internal runtime for t3 product plugins.
3+internal effect service for t3 product plugins.
44
5−it uses a deterministic, stack-safe reconciliation planner and one effect child scope per active plugin. updates stage changed plugins and their dependents, publish contributions atomically, and roll back without replacing the live composition when activation fails.
5+it uses a deterministic, stack-safe reconciliation planner and one effect child scope per active plugin. `PluginRuntime` is provided through `layer()`, so the composing effect scope owns shutdown. updates stage changed plugins and their dependents, publish contributions atomically, and roll back without replacing the live composition when activation fails.
66
77 cordis is not a dependency. a pure-only executor was rejected because plugin lifetimes and async cleanup should be owned by effect scopes.
packages/plugin-runtime/package.json+1−0
@@ -20,6 +20,7 @@ · 19 unmodified lines
2020 "effect": "catalog:"
2121 },
2222 "devDependencies": {
23+ "@effect/vitest": "catalog:",
2324 "@types/node": "catalog:",
2425 "vite-plus": "catalog:"
2526 }
packages/plugin-runtime/src/contract.ts+0−10
@@ -23,14 +23,6 @@ export interface PluginRuntimeSnapshot { · 22 unmodified lines
2323 readonly contributions: Readonly<Partial<Record<string, ReadonlyArray<Contribution>>>>;
2424 }
2525
26−export interface PluginRuntime {
27− readonly reconcile: (
28− definitions: ReadonlyArray<PluginDefinition>,
29− ) => Promise<PluginRuntimeSnapshot>;
30− readonly snapshot: () => PluginRuntimeSnapshot;
31− readonly dispose: () => Promise<void>;
32−}
33−
3426 export interface PluginRuntimeOptions {
3527 readonly onLifecycle?: (event: {
3628 readonly phase: "activate" | "deactivate";
@@ -46,5 +38,3 @@ export interface PluginRuntimeOptions { · 9 unmodified lines
4638 readonly error: unknown;
4739 }) => void;
4840 }
49−
50−export type PluginRuntimeFactory = (options?: PluginRuntimeOptions) => PluginRuntime;
packages/plugin-runtime/src/index.ts+7−3
@@ -2,9 +2,13 @@ export type { · 1 unmodified line
22 Contribution,
33 PluginActivationContext,
44 PluginDefinition,
5− PluginRuntime,
6− PluginRuntimeFactory,
75 PluginRuntimeOptions,
86 PluginRuntimeSnapshot,
97 } from "./contract.ts";
10−export { createPluginRuntime } from "./runtime.ts";
8+export {
9+ layer,
10+ make,
11+ PluginRuntime,
12+ type PluginRuntimeDisposeError,
13+ type PluginRuntimeReconcileError,
14+} from "./runtime.ts";
packages/plugin-runtime/src/runtime.ts+289−276
@@ -1,17 +1,19 @@
11 import * as NodeAsyncHooks from "node:async_hooks";
22
33 import * as Cause from "effect/Cause";
4+import * as Context from "effect/Context";
45 import * as Effect from "effect/Effect";
56 import * as Exit from "effect/Exit";
7+import * as Layer from "effect/Layer";
68 import * as Schema from "effect/Schema";
9+import * as Semaphore from "effect/Semaphore";
710 import * as Scope from "effect/Scope";
811
912 import type {
1013 Contribution,
1114 PluginActivationContext,
1215 PluginDefinition,
13− PluginRuntime,
14− PluginRuntimeFactory,
16+ PluginRuntimeOptions,
1517 PluginRuntimeSnapshot,
1618 } from "./contract.ts";
1719 import {
@@ -137,12 +139,26 @@ class PluginRuntimeCleanupError extends Schema.TaggedErrorClass<PluginRuntimeCle · 119 unmodified lines
137139 }
138140 }
139141
140−type PluginReconcileError =
142+export type PluginRuntimeReconcileError =
141143 | PluginPlanningError
142144 | PluginCallbackError
143145 | PluginRuntimeDisposedError
146+ | PluginRuntimeReentrancyError
144147 | PluginStagingError;
145148
149+export type PluginRuntimeDisposeError = PluginRuntimeCleanupError | PluginRuntimeReentrancyError;
150+
151+export class PluginRuntime extends Context.Service<
152+ PluginRuntime,
153+ {
154+ readonly reconcile: (
155+ definitions: ReadonlyArray<PluginDefinition>,
156+ ) => Effect.Effect<PluginRuntimeSnapshot, PluginRuntimeReconcileError>;
157+ readonly snapshot: Effect.Effect<PluginRuntimeSnapshot>;
158+ readonly dispose: Effect.Effect<void, PluginRuntimeDisposeError>;
159+ }
160+>()("@t3tools/plugin-runtime/runtime/PluginRuntime") {}
161+
146162 const createNullPrototypeRecord = <Value>(): Record<string, Value> =>
147163 Object.create(null) as Record<string, Value>;
148164
@@ -195,300 +211,297 @@ const snapshotOf = ( · 46 unmodified lines
195211 });
196212 };
197213
198−export const createPluginRuntime: PluginRuntimeFactory = (options = {}): PluginRuntime => {
199− let current: LiveComposition = { plugins: [], snapshot: emptySnapshot() };
200− let disposed = false;
201− let transition: Promise<void> = Promise.resolve();
202− let runtimeScope: Scope.Closeable | undefined;
203− const callbackContext = new NodeAsyncHooks.AsyncLocalStorage<PluginCallbackContext>();
204−
205− const getRuntimeScope = (): Effect.Effect<Scope.Closeable> =>
206− Effect.gen(function* () {
207− if (runtimeScope === undefined) runtimeScope = yield* Scope.make("sequential");
208− return runtimeScope;
209− });
210−
211− const reportLifecycle = (
212− phase: "activate" | "deactivate",
213− pluginId: string,
214− ): Effect.Effect<void> =>
215− Effect.sync(() => {
216− try {
217− options.onLifecycle?.({ phase, pluginId });
218− } catch (error) {
219− try {
220− options.onLifecycleError?.({ phase, pluginId, error });
221− } catch {
222− // Observer error reporting must never interrupt a commit or cleanup.
223− }
224− }
225− });
226−
227− const reportCleanupErrors = (
228− phase: "retire" | "rollback",
229− failures: ReadonlyArray<CleanupFailure>,
230− ): Effect.Effect<void> =>
231− Effect.sync(() => {
232− for (const { error } of failures) {
214+export const make = (options: PluginRuntimeOptions = {}) =>
215+ Effect.gen(function* () {
216+ const parentScope = yield* Effect.scope;
217+ const transitionSemaphore = yield* Semaphore.make(1);
218+ let current: LiveComposition = { plugins: [], snapshot: emptySnapshot() };
219+ let disposed = false;
220+ const callbackContext = new NodeAsyncHooks.AsyncLocalStorage<PluginCallbackContext>();
221+
222+ const reportLifecycle = (
223+ phase: "activate" | "deactivate",
224+ pluginId: string,
225+ ): Effect.Effect<void> =>
226+ Effect.sync(() => {
233227 try {
234− options.onCleanupError?.({ phase, error });
235− } catch {
236− // Cleanup reporting must not replace activation errors or undo a committed snapshot.
237− }
238− }
239− });
240−
241− const closePlugins = (
242− plugins: ReadonlyArray<LivePlugin>,
243− notifyDeactivation: boolean,
244− ): Effect.Effect<ReadonlyArray<CleanupFailure>> =>
245− Effect.gen(function* () {
246− const failures: Array<CleanupFailure> = [];
247− for (const plugin of plugins.toReversed()) {
248− const closeExit = yield* Effect.exit(Scope.close(plugin.scope, Exit.void));
249− if (Exit.isFailure(closeExit)) {
250− failures.push({
251− error: Cause.squash(closeExit.cause),
252− pluginId: plugin.definition.id,
253− });
254− }
255− for (const error of plugin.cleanupErrors.splice(0)) {
256− failures.push({ error, pluginId: plugin.definition.id });
257− }
258− if (notifyDeactivation) {
259− yield* reportLifecycle("deactivate", plugin.definition.id);
228+ options.onLifecycle?.({ phase, pluginId });
229+ } catch (error) {
230+ try {
231+ options.onLifecycleError?.({ phase, pluginId, error });
232+ } catch {
233+ // Observer error reporting must never interrupt a commit or cleanup.
234+ }
260235 }
261− }
262− return failures;
263− });
236+ });
264237
265− const invokePluginCallback = <Result>(
266− callback: PluginCallback,
267− pluginId: string,
268− invoke: () => Result | PromiseLike<Result>,
269− onSettled?: () => void,
270− ): Effect.Effect<Result, PluginCallbackError> =>
271− Effect.tryPromise({
272− try: async () => {
273− const callbackState: PluginCallbackContext = { active: true, callback, pluginId };
274− let settled = false;
275− const settle = () => {
276− if (settled) return;
277− settled = true;
278− callbackState.active = false;
279− onSettled?.();
280− };
281− try {
282− const result = callbackContext.run(callbackState, invoke);
283− if (typeof result === "object" && result !== null && "then" in result) {
284− return await Promise.resolve(result).finally(settle);
238+ const reportCleanupErrors = (
239+ phase: "retire" | "rollback",
240+ failures: ReadonlyArray<CleanupFailure>,
241+ ): Effect.Effect<void> =>
242+ Effect.sync(() => {
243+ for (const { error } of failures) {
244+ try {
245+ options.onCleanupError?.({ phase, error });
246+ } catch {
247+ // Cleanup reporting must not replace activation errors or undo a committed snapshot.
285248 }
286− settle();
287− return result;
288− } catch (error) {
289− settle();
290− throw error;
291249 }
292− },
293− catch: (cause) => new PluginCallbackError({ callback, cause, pluginId }),
294− });
250+ });
295251
296− const activatePlugin = (
297− definition: PluginDefinition,
298− capabilities: ReadonlyMap<string, unknown>,
299− ): Effect.Effect<LivePlugin, PluginCallbackError> =>
300− Effect.gen(function* () {
301− const parentScope = yield* getRuntimeScope();
302− const scope = yield* Scope.fork(parentScope, "sequential");
303− const contributions = new Map<string, Array<Contribution>>();
304− const cleanupErrors: Array<unknown> = [];
305− const finalizers: Array<() => void | Promise<void>> = [];
306− const plugin: LivePlugin = { definition, scope, contributions, cleanupErrors };
307− let activating = true;
308− const assertActivating = (method: "resolve" | "register" | "onDispose") => {
309− if (!activating) {
310− throw new PluginActivationContextExpiredError({ method, pluginId: definition.id });
252+ const closePlugins = (
253+ plugins: ReadonlyArray<LivePlugin>,
254+ notifyDeactivation: boolean,
255+ ): Effect.Effect<ReadonlyArray<CleanupFailure>> =>
256+ Effect.gen(function* () {
257+ const failures: Array<CleanupFailure> = [];
258+ for (const plugin of plugins.toReversed()) {
259+ const closeExit = yield* Effect.exit(Scope.close(plugin.scope, Exit.void));
260+ if (Exit.isFailure(closeExit)) {
261+ failures.push({
262+ error: Cause.squash(closeExit.cause),
263+ pluginId: plugin.definition.id,
264+ });
265+ }
266+ for (const error of plugin.cleanupErrors.splice(0)) {
267+ failures.push({ error, pluginId: plugin.definition.id });
268+ }
269+ if (notifyDeactivation) {
270+ yield* reportLifecycle("deactivate", plugin.definition.id);
271+ }
311272 }
312− };
273+ return failures;
274+ });
313275
314− const context: PluginActivationContext = {
315− resolve: <Service>(capability: string): Service => {
316− assertActivating("resolve");
317− if (!(definition.requires ?? []).includes(capability)) {
318− throw new PluginUndeclaredCapabilityError({ capability, pluginId: definition.id });
319− }
320− if (!capabilities.has(capability)) {
321− throw new PluginResolutionError({ capability, pluginId: definition.id });
276+ const invokePluginCallback = <Result>(
277+ callback: PluginCallback,
278+ pluginId: string,
279+ invoke: () => Result | PromiseLike<Result>,
280+ onSettled?: () => void,
281+ ): Effect.Effect<Result, PluginCallbackError> =>
282+ Effect.tryPromise({
283+ try: async () => {
284+ const callbackState: PluginCallbackContext = { active: true, callback, pluginId };
285+ let settled = false;
286+ const settle = () => {
287+ if (settled) return;
288+ settled = true;
289+ callbackState.active = false;
290+ onSettled?.();
291+ };
292+ try {
293+ const result = callbackContext.run(callbackState, invoke);
294+ if (typeof result === "object" && result !== null && "then" in result) {
295+ return await Promise.resolve(result).finally(settle);
296+ }
297+ settle();
298+ return result;
299+ } catch (error) {
300+ settle();
301+ throw error;
322302 }
323− return capabilities.get(capability) as Service;
324− },
325− register: (slot, contribution) => {
326− assertActivating("register");
327− const values = contributions.get(slot) ?? [];
328− values.push(Object.freeze({ id: contribution.id, label: contribution.label }));
329− contributions.set(slot, values);
330− },
331− onDispose: (finalizer) => {
332− assertActivating("onDispose");
333− finalizers.push(finalizer);
334303 },
335− };
336−
337− const activationExit = yield* Effect.exit(
338− invokePluginCallback(
339− "activate",
340− definition.id,
341− () => definition.activate(context),
342− () => {
343− activating = false;
304+ catch: (cause) => new PluginCallbackError({ callback, cause, pluginId }),
305+ });
306+
307+ const activatePlugin = (
308+ definition: PluginDefinition,
309+ capabilities: ReadonlyMap<string, unknown>,
310+ ): Effect.Effect<LivePlugin, PluginCallbackError> =>
311+ Effect.gen(function* () {
312+ const scope = yield* Scope.fork(parentScope, "sequential");
313+ const contributions = new Map<string, Array<Contribution>>();
314+ const cleanupErrors: Array<unknown> = [];
315+ const finalizers: Array<() => void | Promise<void>> = [];
316+ const plugin: LivePlugin = { definition, scope, contributions, cleanupErrors };
317+ let activating = true;
318+ const assertActivating = (method: "resolve" | "register" | "onDispose") => {
319+ if (!activating) {
320+ throw new PluginActivationContextExpiredError({ method, pluginId: definition.id });
321+ }
322+ };
323+
324+ const context: PluginActivationContext = {
325+ resolve: <Service>(capability: string): Service => {
326+ assertActivating("resolve");
327+ if (!(definition.requires ?? []).includes(capability)) {
328+ throw new PluginUndeclaredCapabilityError({ capability, pluginId: definition.id });
329+ }
330+ if (!capabilities.has(capability)) {
331+ throw new PluginResolutionError({ capability, pluginId: definition.id });
332+ }
333+ return capabilities.get(capability) as Service;
344334 },
345− ),
346− );
347− for (const finalizer of finalizers) {
348− const finalizerEffect = invokePluginCallback("finalizer", definition.id, finalizer).pipe(
349− Effect.catch((error) =>
350− Effect.sync(() => {
351− cleanupErrors.push(error);
352− }),
335+ register: (slot, contribution) => {
336+ assertActivating("register");
337+ const values = contributions.get(slot) ?? [];
338+ values.push(Object.freeze({ id: contribution.id, label: contribution.label }));
339+ contributions.set(slot, values);
340+ },
341+ onDispose: (finalizer) => {
342+ assertActivating("onDispose");
343+ finalizers.push(finalizer);
344+ },
345+ };
346+
347+ const activationExit = yield* Effect.exit(
348+ invokePluginCallback(
349+ "activate",
350+ definition.id,
351+ () => definition.activate(context),
352+ () => {
353+ activating = false;
354+ },
353355 ),
354356 );
355− yield* Scope.addFinalizer(scope, finalizerEffect);
356− }
357−
358− if (Exit.isFailure(activationExit)) {
359− const failures = yield* closePlugins([plugin], false);
360− yield* reportCleanupErrors("rollback", failures);
361− return yield* Effect.failCause(activationExit.cause);
362− }
363− return plugin;
364− });
357+ for (const finalizer of finalizers) {
358+ const finalizerEffect = invokePluginCallback("finalizer", definition.id, finalizer).pipe(
359+ Effect.catch((error) =>
360+ Effect.sync(() => {
361+ cleanupErrors.push(error);
362+ }),
363+ ),
364+ );
365+ yield* Scope.addFinalizer(scope, finalizerEffect);
366+ }
365367
366− const reconcileEffect = (
367− definitions: ReadonlyArray<PluginDefinition>,
368− ): Effect.Effect<PluginRuntimeSnapshot, PluginReconcileError> =>
369− Effect.gen(function* () {
370− if (disposed) {
371− return yield* new PluginRuntimeDisposedError({ operation: "reconcile" });
372− }
373−
374− const plan = yield* Effect.try({
375− try: () => planComposition(definitions),
376− catch: (error) => {
377− if (!isPluginPlanningError(error)) throw error;
378− return error;
379− },
368+ if (Exit.isFailure(activationExit)) {
369+ const failures = yield* closePlugins([plugin], false);
370+ yield* reportCleanupErrors("rollback", failures);
371+ return yield* Effect.failCause(activationExit.cause);
372+ }
373+ return plugin;
380374 });
381− const affected = affectedPluginIds(
382− current.plugins.map((plugin) => plugin.definition),
383− plan.definitions,
384− );
385− const currentById = new Map(current.plugins.map((plugin) => [plugin.definition.id, plugin]));
386− const capabilities = new Map<string, unknown>();
387− for (const plugin of current.plugins) {
388− if (affected.has(plugin.definition.id)) continue;
389− for (const [capability, service] of Object.entries(plugin.definition.provides ?? {})) {
390− capabilities.set(capability, service);
375+
376+ const reconcileEffect = (
377+ definitions: ReadonlyArray<PluginDefinition>,
378+ ): Effect.Effect<PluginRuntimeSnapshot, PluginRuntimeReconcileError> =>
379+ Effect.gen(function* () {
380+ if (disposed) {
381+ return yield* new PluginRuntimeDisposedError({ operation: "reconcile" });
391382 }
392− }
393−
394− const staged = new Map<string, LivePlugin>();
395− const candidateExit = yield* Effect.exit(
396− Effect.gen(function* () {
397− for (const definition of plan.definitions) {
398− if (!affected.has(definition.id)) continue;
399− const plugin = yield* activatePlugin(definition, capabilities);
400− staged.set(definition.id, plugin);
401− for (const [capability, service] of Object.entries(definition.provides ?? {})) {
402− capabilities.set(capability, service);
403− }
383+
384+ const plan = yield* Effect.try({
385+ try: () => planComposition(definitions),
386+ catch: (error) => {
387+ if (!isPluginPlanningError(error)) throw error;
388+ return error;
389+ },
390+ });
391+ const affected = affectedPluginIds(
392+ current.plugins.map((plugin) => plugin.definition),
393+ plan.definitions,
394+ );
395+ const currentById = new Map(
396+ current.plugins.map((plugin) => [plugin.definition.id, plugin]),
397+ );
398+ const capabilities = new Map<string, unknown>();
399+ for (const plugin of current.plugins) {
400+ if (affected.has(plugin.definition.id)) continue;
401+ for (const [capability, service] of Object.entries(plugin.definition.provides ?? {})) {
402+ capabilities.set(capability, service);
404403 }
404+ }
405405
406− const nextPlugins: Array<LivePlugin> = [];
407− for (const definition of plan.definitions) {
408− const plugin = staged.get(definition.id) ?? currentById.get(definition.id);
409− if (plugin === undefined) {
410− return yield* new PluginStagingError({ pluginId: definition.id });
406+ const staged = new Map<string, LivePlugin>();
407+ const candidateExit = yield* Effect.exit(
408+ Effect.gen(function* () {
409+ for (const definition of plan.definitions) {
410+ if (!affected.has(definition.id)) continue;
411+ const plugin = yield* activatePlugin(definition, capabilities);
412+ staged.set(definition.id, plugin);
413+ for (const [capability, service] of Object.entries(definition.provides ?? {})) {
414+ capabilities.set(capability, service);
415+ }
411416 }
412− nextPlugins.push(plugin);
413− }
414− return {
415− plugins: nextPlugins,
416− snapshot: snapshotOf(nextPlugins, plan.blocked),
417− };
418− }),
419− );
420− if (Exit.isFailure(candidateExit)) {
421− const failures = yield* closePlugins([...staged.values()], false);
422− yield* reportCleanupErrors("rollback", failures);
423− return yield* Effect.failCause(candidateExit.cause);
424− }
425−
426− const previous = current.plugins.filter((plugin) => affected.has(plugin.definition.id));
427− current = candidateExit.value;
428− for (const plugin of staged.values()) {
429− yield* reportLifecycle("activate", plugin.definition.id);
430− }
431− const failures = yield* closePlugins(previous, true);
432− yield* reportCleanupErrors("retire", failures);
433− return current.snapshot;
434− });
435417
436− const disposeEffect = (): Effect.Effect<void, PluginRuntimeCleanupError> =>
437− Effect.gen(function* () {
438− if (disposed) return;
439− disposed = true;
440− const previous = current.plugins;
441− current = { plugins: [], snapshot: emptySnapshot() };
442− const failures = [...(yield* closePlugins(previous, true))];
443− const parentScope = runtimeScope;
444− runtimeScope = undefined;
445− if (parentScope !== undefined) {
446− const closeExit = yield* Effect.exit(Scope.close(parentScope, Exit.void));
447− if (Exit.isFailure(closeExit)) {
448− failures.push({ error: Cause.squash(closeExit.cause), pluginId: "plugin-runtime" });
418+ const nextPlugins: Array<LivePlugin> = [];
419+ for (const definition of plan.definitions) {
420+ const plugin = staged.get(definition.id) ?? currentById.get(definition.id);
421+ if (plugin === undefined) {
422+ return yield* new PluginStagingError({ pluginId: definition.id });
423+ }
424+ nextPlugins.push(plugin);
425+ }
426+ return {
427+ plugins: nextPlugins,
428+ snapshot: snapshotOf(nextPlugins, plan.blocked),
429+ };
430+ }),
431+ );
432+ if (Exit.isFailure(candidateExit)) {
433+ const failures = yield* closePlugins([...staged.values()], false);
434+ yield* reportCleanupErrors("rollback", failures);
435+ return yield* Effect.failCause(candidateExit.cause);
449436 }
450− }
451− if (failures.length > 0) {
452− return yield* new PluginRuntimeCleanupError({
453− failures: failures.map(({ error, pluginId }) => ({ cause: error, pluginId })),
454− });
455− }
456− });
457437
458− const runPromiseAdapter = <Result, Failure>(
459− operation: RuntimeOperation,
460− effect: () => Effect.Effect<Result, Failure>,
461− ): Promise<Result> => {
462− const callback = callbackContext.getStore();
463− if (callback?.active === true) {
464− return Promise.reject(
465− new PluginRuntimeReentrancyError({
466− callback: callback.callback,
467− operation,
468− pluginId: callback.pluginId,
469− }),
470− );
471− }
438+ const previous = current.plugins.filter((plugin) => affected.has(plugin.definition.id));
439+ current = candidateExit.value;
440+ for (const plugin of staged.values()) {
441+ yield* reportLifecycle("activate", plugin.definition.id);
442+ }
443+ const failures = yield* closePlugins(previous, true);
444+ yield* reportCleanupErrors("retire", failures);
445+ return current.snapshot;
446+ });
447+
448+ const disposeEffect = (): Effect.Effect<void, PluginRuntimeCleanupError> =>
449+ Effect.gen(function* () {
450+ if (disposed) return;
451+ disposed = true;
452+ const previous = current.plugins;
453+ current = { plugins: [], snapshot: emptySnapshot() };
454+ const failures = [...(yield* closePlugins(previous, true))];
455+ if (failures.length > 0) {
456+ return yield* new PluginRuntimeCleanupError({
457+ failures: failures.map(({ error, pluginId }) => ({ cause: error, pluginId })),
458+ });
459+ }
460+ });
461+
462+ const runTransition = <Result, Failure>(
463+ operation: RuntimeOperation,
464+ effect: () => Effect.Effect<Result, Failure>,
465+ ): Effect.Effect<Result, Failure | PluginRuntimeReentrancyError> =>
466+ Effect.suspend<Result, Failure | PluginRuntimeReentrancyError, never>(() => {
467+ const callback = callbackContext.getStore();
468+ if (callback?.active === true) {
469+ return Effect.fail(
470+ new PluginRuntimeReentrancyError({
471+ callback: callback.callback,
472+ operation,
473+ pluginId: callback.pluginId,
474+ }),
475+ );
476+ }
477+ return transitionSemaphore.withPermits(1)(effect());
478+ });
472479
473− const result = transition.then(() => Effect.runPromise(effect()));
474− transition = result.then(
475− () => undefined,
476− () => undefined,
480+ yield* Effect.addFinalizer(() =>
481+ disposeEffect().pipe(
482+ Effect.catch((error) =>
483+ reportCleanupErrors(
484+ "retire",
485+ error.failures.map(({ cause, pluginId }) => ({ error: cause, pluginId })),
486+ ),
487+ ),
488+ ),
477489 );
478− return result;
479− };
480−
481− return {
482− reconcile: (definitions) => {
483− let desired: ReadonlyArray<PluginDefinition>;
484− try {
485− desired = snapshotDefinitions(definitions);
486− } catch (error) {
487− return Promise.reject(error);
488− }
489− return runPromiseAdapter("reconcile", () => reconcileEffect(desired));
490− },
491− snapshot: () => current.snapshot,
492− dispose: () => runPromiseAdapter("dispose", disposeEffect),
493− };
494−};
490+
491+ return {
492+ reconcile: (definitions) => {
493+ let desired: ReadonlyArray<PluginDefinition>;
494+ try {
495+ desired = snapshotDefinitions(definitions);
496+ } catch (error) {
497+ return Effect.die(error);
498+ }
499+ return runTransition("reconcile", () => reconcileEffect(desired));
500+ },
501+ snapshot: Effect.sync(() => current.snapshot),
502+ dispose: runTransition("dispose", disposeEffect),
503+ } satisfies PluginRuntime["Service"];
504+ });
505+
506+export const layer = (options: PluginRuntimeOptions = {}) =>
507+ Layer.effect(PluginRuntime, make(options));
packages/plugin-runtime/test/runtime.test.ts+80−33
@@ -1,45 +1,92 @@
1−import { describe, expect, it } from "vite-plus/test";
1+import { it } from "@effect/vitest";
2+import { describe, expect } from "vite-plus/test";
3+import * as Effect from "effect/Effect";
24
3−import type { PluginDefinition } from "../src/contract.ts";
4−import { createPluginRuntime } from "../src/runtime.ts";
5+import type { PluginDefinition, PluginRuntimeOptions } from "../src/contract.ts";
6+import { layer, make, PluginRuntime } from "../src/runtime.ts";
57 import { defineRuntimeContract } from "./runtimeContract.ts";
68
7−defineRuntimeContract("plugin runtime", createPluginRuntime);
9+const makeTestRuntime = (options: PluginRuntimeOptions = {}) => make(options);
10+
11+defineRuntimeContract("plugin runtime", makeTestRuntime);
812
913 describe("plugin runtime errors", () => {
10− it("returns schema-tagged planning errors", async () => {
11− const runtime = createPluginRuntime();
12− const duplicate = {
13− id: "acme.duplicate",
14− version: "1.0.0",
15− activate() {},
16− };
17−
18− await expect(runtime.reconcile([duplicate, duplicate])).rejects.toMatchObject({
19− _tag: "DuplicatePluginIdError",
20− pluginId: "acme.duplicate",
21− });
22− });
14+ it.effect("returns schema-tagged planning errors", () =>
15+ Effect.scoped(
16+ Effect.gen(function* () {
17+ const runtime = yield* makeTestRuntime();
18+ const duplicate = {
19+ id: "acme.duplicate",
20+ version: "1.0.0",
21+ activate() {},
22+ };
23+
24+ const error = yield* Effect.flip(runtime.reconcile([duplicate, duplicate]));
25+
26+ expect(error).toMatchObject({
27+ _tag: "DuplicatePluginIdError",
28+ pluginId: "acme.duplicate",
29+ });
30+ yield* runtime.dispose;
31+ }),
32+ ),
33+ );
2334 });
2435
2536 describe("plugin runtime planner", () => {
26− it("plans a deep acyclic dependency chain without using the call stack", async () => {
27− const runtime = createPluginRuntime();
28− const pluginCount = 20_000;
29− const definitions: Array<PluginDefinition> = Array.from(
30− { length: pluginCount },
31− (_, index) => ({
32− id: `plugin-${index}`,
33− version: "1.0.0",
34− ...(index === 0 ? {} : { requires: [`capability-${index - 1}`] }),
35− provides: { [`capability-${index}`]: index },
36− activate() {},
37+ it.effect("plans a deep acyclic dependency chain without using the call stack", () =>
38+ Effect.scoped(
39+ Effect.gen(function* () {
40+ const runtime = yield* makeTestRuntime();
41+ const pluginCount = 20_000;
42+ const definitions: Array<PluginDefinition> = Array.from(
43+ { length: pluginCount },
44+ (_, index) => ({
45+ id: `plugin-${index}`,
46+ version: "1.0.0",
47+ ...(index === 0 ? {} : { requires: [`capability-${index - 1}`] }),
48+ provides: { [`capability-${index}`]: index },
49+ activate() {},
50+ }),
51+ );
52+
53+ const snapshot = yield* runtime.reconcile(definitions.toReversed());
54+
55+ expect(snapshot.active).toHaveLength(pluginCount);
56+ yield* runtime.dispose;
3757 }),
38− );
58+ ),
59+ );
60+});
61+
62+describe("plugin runtime layer", () => {
63+ it.effect("closes active plugin scopes when the layer is released", () =>
64+ Effect.gen(function* () {
65+ let disposed = false;
66+ const lifecycle: Array<string> = [];
3967
40− const snapshot = await runtime.reconcile(definitions.toReversed());
68+ yield* PluginRuntime.use((runtime) =>
69+ runtime.reconcile([
70+ {
71+ id: "acme.layer-owned",
72+ version: "1.0.0",
73+ activate(context) {
74+ context.onDispose(() => {
75+ disposed = true;
76+ });
77+ },
78+ },
79+ ]),
80+ ).pipe(
81+ Effect.provide(
82+ layer({
83+ onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`),
84+ }),
85+ ),
86+ );
4187
42− expect(snapshot.active).toHaveLength(pluginCount);
43− await runtime.dispose();
44− });
88+ expect(disposed).toBe(true);
89+ expect(lifecycle).toEqual(["activate:acme.layer-owned", "deactivate:acme.layer-owned"]);
90+ }),
91+ );
4592 });
packages/plugin-runtime/test/runtimeContract.ts+1003−814
@@ -1,31 +1,52 @@
1−import * as NodeTimersPromises from "node:timers/promises";
1+import { it } from "@effect/vitest";
2+import { describe, expect } from "vite-plus/test";
3+import * as Cause from "effect/Cause";
4+import * as Effect from "effect/Effect";
5+import * as Exit from "effect/Exit";
6+import * as Fiber from "effect/Fiber";
7+import * as Scope from "effect/Scope";
28
3−import { describe, expect, it } from "vite-plus/test";
9+import type { PluginRuntime, PluginRuntimeReconcileError } from "../src/runtime.ts";
410
511 import type {
612 PluginDefinition,
7− PluginRuntimeFactory,
13+ PluginRuntimeOptions,
814 PluginRuntimeSnapshot,
915 } from "../src/contract.ts";
1016
11−const contributionLabels = (snapshot: PluginRuntimeSnapshot, slot: string) =>
12− snapshot.contributions[slot]?.map((item) => item.label) ?? [];
17+type TestPluginRuntimeFactory = (
18+ options?: PluginRuntimeOptions,
19+) => Effect.Effect<PluginRuntime["Service"], never, Scope.Scope>;
20+
21+const makeEffectCallback = <A, E, R = never>() => {
22+ let unsafeResume: (effect: Effect.Effect<A, E, R>) => void = () => {
23+ throw new Error("effect callback has not started");
24+ };
25+ return {
26+ await: Effect.callback<A, E, R>((resume) => {
27+ unsafeResume = resume;
28+ }),
29+ resume: (effect: Effect.Effect<A, E, R>) => unsafeResume(effect),
30+ } as const;
31+};
1332
14−const withOperationTimeout = async <Result>(operation: Promise<Result>): Promise<Result> => {
15− const controller = new AbortController();
16− const timeout = NodeTimersPromises.setTimeout(100, undefined, {
17− ref: false,
18− signal: controller.signal,
19− }).then(() => {
20− throw new Error("operation timed out");
21− });
22− try {
23− return await Promise.race([operation, timeout]);
24− } finally {
25− controller.abort();
26− }
33+const failureOf = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
34+ Effect.exit(effect).pipe(
35+ Effect.map((exit) => (Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined)),
36+ );
37+
38+const settlePromise = (
39+ exit: Exit.Exit<unknown, unknown>,
40+ resolve: () => void,
41+ reject: (error: unknown) => void,
42+) => {
43+ if (Exit.isFailure(exit)) reject(Cause.squash(exit.cause));
44+ else resolve();
2745 };
2846
47+const contributionLabels = (snapshot: PluginRuntimeSnapshot, slot: string) =>
48+ snapshot.contributions[slot]?.map((item) => item.label) ?? [];
49+
2950 const failureCause = (error: unknown): unknown =>
3051 typeof error === "object" && error !== null && "cause" in error
3152 ? (error as { readonly cause: unknown }).cause
@@ -55,814 +76,982 @@ const consumer = (): PluginDefinition => ({ · 23 unmodified lines
5576 },
5677 });
5778
58−export function defineRuntimeContract(name: string, createRuntime: PluginRuntimeFactory) {
79+export function defineRuntimeContract(name: string, createRuntime: TestPluginRuntimeFactory) {
5980 describe(name, () => {
60− it("activates providers before consumers regardless of manifest order", async () => {
61− const runtime = createRuntime();
62−
63− const snapshot = await runtime.reconcile([consumer(), provider()]);
64−
65− expect(snapshot.active).toEqual(["acme.database", "acme.issues"]);
66− expect(contributionLabels(snapshot, "commands")).toEqual(["database-1.0.0"]);
67− await runtime.dispose();
68− });
69−
70− it("blocks missing dependencies without blocking independent plugins", async () => {
71− const runtime = createRuntime();
72− const independent: PluginDefinition = {
73− id: "acme.clock",
74− version: "1.0.0",
75− activate(context) {
76− context.register("status", { id: "clock", label: "clock" });
77− },
78− };
79−
80− const snapshot = await runtime.reconcile([consumer(), independent]);
81−
82− expect(snapshot.active).toEqual(["acme.clock"]);
83− expect(snapshot.blocked["acme.issues"]).toContain("acme.database@1");
84− expect(contributionLabels(snapshot, "status")).toEqual(["clock"]);
85− await runtime.dispose();
86− });
87−
88− it("deactivates dependents before providers", async () => {
89− const lifecycle: Array<string> = [];
90− const runtime = createRuntime({
91− onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`),
92− });
93− await runtime.reconcile([provider(), consumer()]);
94− lifecycle.length = 0;
95−
96− await runtime.reconcile([]);
97−
98− expect(lifecycle).toEqual(["deactivate:acme.issues", "deactivate:acme.database"]);
99− await runtime.dispose();
100− });
101−
102− it("runs plugin finalizers in reverse registration order", async () => {
103− const disposed: Array<string> = [];
104− const runtime = createRuntime();
105− await runtime.reconcile([
106− {
107− id: "acme.finalizers",
108− version: "1.0.0",
109− activate(context) {
110− context.onDispose(() => {
111− disposed.push("first");
112− });
113− context.onDispose(() => {
114− disposed.push("second");
115− });
116− },
117− },
118− ]);
119−
120− await runtime.reconcile([]);
121−
122− expect(disposed).toEqual(["second", "first"]);
123− await runtime.dispose();
124− });
125−
126− it("keeps unchanged plugin scopes alive across reconciliation", async () => {
127− let activations = 0;
128− let disposals = 0;
129− const stable: PluginDefinition = {
130− id: "acme.stable",
131− version: "1.0.0",
132− activate(context) {
133− activations += 1;
134− context.onDispose(() => {
135− disposals += 1;
136− });
137− },
138− };
139− const runtime = createRuntime();
140−
141− await runtime.reconcile([stable]);
142− await runtime.reconcile([stable]);
143−
144− expect(activations).toBe(1);
145− expect(disposals).toBe(0);
146− await runtime.dispose();
147− expect(disposals).toBe(1);
148− });
149−
150− it("treats reordered requirements and providers as the same definition", async () => {
151− let activations = 0;
152− const firstService = {};
153− const secondService = {};
154− const activate: PluginDefinition["activate"] = () => {
155− activations += 1;
156− };
157− const dependencies: ReadonlyArray<PluginDefinition> = [
158− {
159− id: "acme.first-dependency",
160− version: "1.0.0",
161− provides: { "acme.first@1": true },
162− activate() {},
163− },
164− {
165− id: "acme.second-dependency",
166− version: "1.0.0",
167− provides: { "acme.second@1": true },
168− activate() {},
169− },
170− ];
171− const runtime = createRuntime();
172− await runtime.reconcile([
173− ...dependencies,
174− {
175− id: "acme.stable-order",
176− version: "1.0.0",
177− requires: ["acme.first@1", "acme.second@1"],
178− provides: { "acme.output-one@1": firstService, "acme.output-two@1": secondService },
179− activate,
180− },
181− ]);
182−
183− await runtime.reconcile([
184− ...dependencies,
185− {
186− id: "acme.stable-order",
187− version: "1.0.0",
188− requires: ["acme.second@1", "acme.first@1"],
189− provides: { "acme.output-two@1": secondService, "acme.output-one@1": firstService },
190− activate,
191− },
192− ]);
193−
194− expect(activations).toBe(1);
195− await runtime.dispose();
196− });
197−
198− it("restarts a plugin when its activation implementation changes", async () => {
199− const runtime = createRuntime();
200− const first: PluginDefinition = {
201− id: "acme.implementation",
202− version: "1.0.0",
203− activate(context) {
204− context.register("commands", { id: "implementation", label: "first" });
205− },
206− };
207− const second: PluginDefinition = {
208− id: "acme.implementation",
209− version: "1.0.0",
210− activate(context) {
211− context.register("commands", { id: "implementation", label: "second" });
212− },
213− };
214−
215− await runtime.reconcile([first]);
216− const snapshot = await runtime.reconcile([second]);
217−
218− expect(contributionLabels(snapshot, "commands")).toEqual(["second"]);
219− await runtime.dispose();
220− });
221−
222− it("restarts a changed provider and its dependents without touching independent plugins", async () => {
223− let independentActivations = 0;
224− const lifecycle: Array<string> = [];
225− const independent: PluginDefinition = {
226− id: "acme.independent",
227− version: "1.0.0",
228− activate() {
229− independentActivations += 1;
230− },
231− };
232− const runtime = createRuntime({
233− onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`),
234− });
235− await runtime.reconcile([consumer(), independent, provider()]);
236− lifecycle.length = 0;
237−
238− const snapshot = await runtime.reconcile([consumer(), independent, provider("2.0.0")]);
239−
240− expect(independentActivations).toBe(1);
241− expect(lifecycle).not.toContain("activate:acme.independent");
242− expect(lifecycle).not.toContain("deactivate:acme.independent");
243− expect(lifecycle.filter((event) => event.startsWith("activate:"))).toEqual([
244− "activate:acme.database",
245− "activate:acme.issues",
246− ]);
247− expect(lifecycle.filter((event) => event.startsWith("deactivate:"))).toEqual([
248− "deactivate:acme.issues",
249− "deactivate:acme.database",
250− ]);
251− expect(contributionLabels(snapshot, "commands")).toEqual(["database-2.0.0"]);
252− await runtime.dispose();
253− });
254−
255− it("returns deeply frozen snapshots", async () => {
256− const runtime = createRuntime();
257−
258− const snapshot = await runtime.reconcile([provider()]);
259− const status = snapshot.contributions.status;
260−
261− expect(Object.isFrozen(snapshot)).toBe(true);
262− expect(Object.isFrozen(snapshot.active)).toBe(true);
263− expect(Object.isFrozen(snapshot.blocked)).toBe(true);
264− expect(Object.isFrozen(snapshot.contributions)).toBe(true);
265− expect(Object.isFrozen(status)).toBe(true);
266− expect(Object.isFrozen(status?.[0])).toBe(true);
267− await runtime.dispose();
268− });
269−
270− it("detaches registered contributions from later plugin mutation", async () => {
271− const contribution = { id: "mutable", label: "original" };
272− const plugin: PluginDefinition = {
273− id: "acme.mutable-contribution",
274− version: "1.0.0",
275− activate(context) {
276− context.register("commands", contribution);
277− },
278− };
279− const unrelated: PluginDefinition = {
280− id: "acme.unrelated-contribution",
281− version: "1.0.0",
282− activate() {},
283− };
284− const runtime = createRuntime();
285− await runtime.reconcile([plugin]);
286−
287− contribution.label = "changed";
288− const snapshot = await runtime.reconcile([plugin, unrelated]);
289−
290− expect(contributionLabels(snapshot, "commands")).toEqual(["original"]);
291− await runtime.dispose();
292− });
293−
294− it("treats every plugin id and contribution slot as data", async () => {
295− const runtime = createRuntime();
296− const unusualProvider: PluginDefinition = {
297− id: "toString",
298− version: "1.0.0",
299− provides: { "acme.unusual@1": "available" },
300− activate(context) {
301− context.register("__proto__", { id: "provider", label: "provider" });
302− },
303− };
304− const unusualConsumer: PluginDefinition = {
305− id: "constructor",
306− version: "1.0.0",
307− requires: ["acme.unusual@1"],
308− activate(context) {
309− context.resolve("acme.unusual@1");
310− context.register("toString", { id: "consumer", label: "consumer" });
311− },
312− };
313− const unusualBlocked: PluginDefinition = {
314− id: "__proto__",
315− version: "1.0.0",
316− requires: ["acme.missing@1"],
317− activate() {
318− throw new Error("blocked plugin activated");
319− },
320− };
321−
322− const snapshot = await runtime.reconcile([unusualConsumer, unusualProvider, unusualBlocked]);
323−
324− expect(snapshot.active).toEqual(["toString", "constructor"]);
325− expect(Object.hasOwn(snapshot.blocked, "__proto__")).toBe(true);
326− expect(snapshot.blocked.__proto__).toContain("acme.missing@1");
327− expect(Object.hasOwn(snapshot.contributions, "__proto__")).toBe(true);
328− expect(Object.hasOwn(snapshot.contributions, "toString")).toBe(true);
329− expect(contributionLabels(snapshot, "__proto__")).toEqual(["provider"]);
330− expect(contributionLabels(snapshot, "toString")).toEqual(["consumer"]);
331− await runtime.dispose();
332− });
333−
334− it("restarts dependents when a provided value changes", async () => {
335− const lifecycle: Array<string> = [];
336− const runtime = createRuntime({
337− onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`),
338− });
339− const serviceProvider = (name: string): PluginDefinition => ({
340− id: "acme.service",
341− version: "1.0.0",
342− provides: { "acme.service@1": { name } },
343− activate() {},
344− });
345− const serviceConsumer: PluginDefinition = {
346− id: "acme.service-consumer",
347− version: "1.0.0",
348− requires: ["acme.service@1"],
349− activate(context) {
350− const service = context.resolve<{ readonly name: string }>("acme.service@1");
351− context.register("commands", { id: "service", label: service.name });
352− },
353− };
354− await runtime.reconcile([serviceProvider("first"), serviceConsumer]);
355− lifecycle.length = 0;
356−
357− const snapshot = await runtime.reconcile([serviceProvider("second"), serviceConsumer]);
358−
359− expect(contributionLabels(snapshot, "commands")).toEqual(["second"]);
360− expect(lifecycle.filter((event) => event.startsWith("activate:"))).toEqual([
361− "activate:acme.service",
362− "activate:acme.service-consumer",
363− ]);
364− await runtime.dispose();
365− });
366−
367− it("does not report a rolled-back candidate as activated", async () => {
368− const lifecycle: Array<string> = [];
369− const runtime = createRuntime({
370− onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`),
371− });
372− const staged: PluginDefinition = {
373− id: "acme.staged",
374− version: "1.0.0",
375− activate() {},
376− };
377− const broken: PluginDefinition = {
378− id: "acme.broken",
379− version: "1.0.0",
380− activate() {
381− throw new Error("activation failed");
382− },
383− };
384−
385− let stagingFailure: unknown;
386− try {
387− await runtime.reconcile([staged, broken]);
388− } catch (error) {
389− stagingFailure = error;
390− }
391− expect(failureMessage(stagingFailure)).toContain("activation failed");
392−
393− expect(lifecycle).toEqual([]);
394− await runtime.dispose();
395− });
396−
397− it("preserves activation failures when rollback cleanup also fails", async () => {
398− const activationError = new Error("activation failed");
399− const cleanupError = new Error("rollback cleanup failed");
400− const cleanupEvents: Array<{ readonly phase: string; readonly error: unknown }> = [];
401− const runtime = createRuntime({
402− onCleanupError: (event) => {
403− cleanupEvents.push(event);
404− throw new Error("cleanup observer failed");
405− },
406− });
407− const broken: PluginDefinition = {
408− id: "acme.rollback-error",
409− version: "1.0.0",
410− activate(context) {
411− context.onDispose(() => {
412− throw cleanupError;
81+ it.effect("activates providers before consumers regardless of manifest order", () =>
82+ Effect.scoped(
83+ Effect.gen(function* () {
84+ const runtime = yield* createRuntime();
85+
86+ const snapshot = yield* runtime.reconcile([consumer(), provider()]);
87+
88+ expect(snapshot.active).toEqual(["acme.database", "acme.issues"]);
89+ expect(contributionLabels(snapshot, "commands")).toEqual(["database-1.0.0"]);
90+ yield* runtime.dispose;
91+ }),
92+ ),
93+ );
94+
95+ it.effect("blocks missing dependencies without blocking independent plugins", () =>
96+ Effect.scoped(
97+ Effect.gen(function* () {
98+ const runtime = yield* createRuntime();
99+ const independent: PluginDefinition = {
100+ id: "acme.clock",
101+ version: "1.0.0",
102+ activate(context) {
103+ context.register("status", { id: "clock", label: "clock" });
104+ },
105+ };
106+
107+ const snapshot = yield* runtime.reconcile([consumer(), independent]);
108+
109+ expect(snapshot.active).toEqual(["acme.clock"]);
110+ expect(snapshot.blocked["acme.issues"]).toContain("acme.database@1");
111+ expect(contributionLabels(snapshot, "status")).toEqual(["clock"]);
112+ yield* runtime.dispose;
113+ }),
114+ ),
115+ );
116+
117+ it.effect("deactivates dependents before providers", () =>
118+ Effect.scoped(
119+ Effect.gen(function* () {
120+ const lifecycle: Array<string> = [];
121+ const runtime = yield* createRuntime({
122+ onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`),
413123 });
414− throw activationError;
415− },
416− };
417−
418− let activationFailure: unknown;
419− try {
420− await runtime.reconcile([broken]);
421− } catch (error) {
422− activationFailure = error;
423− }
424− expect(failureCause(activationFailure)).toBe(activationError);
425− expect(cleanupEvents).toHaveLength(1);
426− expect(cleanupEvents[0]?.phase).toBe("rollback");
427− expect(failureCause(cleanupEvents[0]?.error)).toBe(cleanupError);
428− await runtime.dispose();
429− });
430−
431− it("returns the committed snapshot when retiring an old plugin reports cleanup errors", async () => {
432− const cleanupError = new Error("retirement cleanup failed");
433− const cleanupEvents: Array<{ readonly phase: string; readonly error: unknown }> = [];
434− const runtime = createRuntime({
435− onCleanupError: (event) => {
436− cleanupEvents.push(event);
437− throw new Error("cleanup observer failed");
438− },
439− });
440− await runtime.reconcile([
441− {
442− id: "acme.retirement-error",
443− version: "1.0.0",
444− activate(context) {
445− context.register("commands", { id: "retirement", label: "old" });
446− context.onDispose(() => {
447− throw cleanupError;
448− });
449− },
450− },
451− ]);
452−
453− const snapshot = await runtime.reconcile([
454− {
455− id: "acme.retirement-error",
456− version: "2.0.0",
457− activate(context) {
458− context.register("commands", { id: "retirement", label: "new" });
459− },
460− },
461− ]);
462−
463− expect(contributionLabels(snapshot, "commands")).toEqual(["new"]);
464− expect(runtime.snapshot()).toBe(snapshot);
465− expect(cleanupEvents).toHaveLength(1);
466− expect(cleanupEvents[0]?.phase).toBe("retire");
467− expect(failureCause(cleanupEvents[0]?.error)).toBe(cleanupError);
468− await runtime.dispose();
469− });
470−
471− it("does not let lifecycle observers interrupt a committed transition", async () => {
472− let oldDisposed = false;
473− const observerErrors: Array<string> = [];
474− const runtime = createRuntime({
475− onLifecycle: ({ phase, pluginId }) => {
476− throw new Error(`${phase}:${pluginId}`);
477− },
478− onLifecycleError: ({ phase, pluginId }) => {
479− observerErrors.push(`${phase}:${pluginId}`);
480− },
481− });
482− await runtime.reconcile([
483− {
484− id: "acme.lifecycle-observer",
485− version: "1.0.0",
486− activate(context) {
487− context.onDispose(() => {
488− oldDisposed = true;
489− });
490− },
491− },
492− ]);
493−
494− const snapshot = await runtime.reconcile([
495− {
496− id: "acme.lifecycle-observer",
497− version: "2.0.0",
498− activate(context) {
499− context.register("commands", { id: "observer", label: "committed" });
500− },
501− },
502− ]);
503−
504− expect(oldDisposed).toBe(true);
505− expect(contributionLabels(snapshot, "commands")).toEqual(["committed"]);
506− expect(observerErrors).toEqual([
507− "activate:acme.lifecycle-observer",
508− "activate:acme.lifecycle-observer",
509− "deactivate:acme.lifecycle-observer",
510− ]);
511− await runtime.dispose();
512− });
513−
514− it("rejects runtime operations reentered from plugin activation", async () => {
515− const runtime = createRuntime();
516− const reentrant: PluginDefinition = {
517− id: "acme.reentrant",
518− version: "1.0.0",
519− async activate() {
520− await runtime.reconcile([]);
521− },
522− };
523− await expect(withOperationTimeout(runtime.reconcile([reentrant]))).rejects.toThrow(
524− "reentrant",
525− );
526− });
527−
528− it("rejects runtime operations reentered from plugin finalizers", async () => {
529− let nestedFailure: unknown;
530− const runtime = createRuntime();
531− await runtime.reconcile([
532− {
533− id: "acme.reentrant-finalizer",
534− version: "1.0.0",
535− activate(context) {
536− context.onDispose(async () => {
537− try {
538− await runtime.dispose();
539− } catch (error) {
540− nestedFailure = error;
541− }
542− });
543− },
544− },
545− ]);
546− await expect(withOperationTimeout(runtime.reconcile([]))).resolves.toBeDefined();
547− expect(nestedFailure).toBeInstanceOf(Error);
548− expect((nestedFailure as Error).message).toContain("reentrant");
549− await runtime.dispose();
550− });
551−
552− it("allows descendant tasks to use the runtime after their plugin callback settles", async () => {
553− let releaseBackground!: () => void;
554− const gate = new Promise<void>((resolve) => {
555− releaseBackground = resolve;
556− });
557− let backgroundResult: Promise<PluginRuntimeSnapshot> | undefined;
558− const runtime = createRuntime();
559−
560− await runtime.reconcile([
561− {
562− id: "acme.background-task",
563− version: "1.0.0",
564− activate() {
565− backgroundResult = gate.then(() => runtime.reconcile([]));
566− },
567− },
568− ]);
569−
570− releaseBackground();
571− await expect(withOperationTimeout(backgroundResult!)).resolves.toBeDefined();
572− await runtime.dispose();
573− });
574−
575− it("allows microtasks spawned by synchronous plugin callbacks to use the runtime", async () => {
576− let backgroundResult: Promise<PluginRuntimeSnapshot> | undefined;
577− const runtime = createRuntime();
578−
579− await runtime.reconcile([
580− {
581− id: "acme.microtask",
582− version: "1.0.0",
583− activate() {
584− queueMicrotask(() => {
585− backgroundResult = runtime.reconcile([]);
586− });
587− },
588− },
589− ]);
590−
591− expect(backgroundResult).toBeDefined();
592− await expect(withOperationTimeout(backgroundResult!)).resolves.toBeDefined();
593− await runtime.dispose();
594− });
595−
596− it("allows microtasks spawned by synchronous callbacks that return plain functions", async () => {
597− let backgroundResult: Promise<PluginRuntimeSnapshot> | undefined;
598− const runtime = createRuntime();
599− const activate = (() => {
600− queueMicrotask(() => {
601− backgroundResult = runtime.reconcile([]);
602− });
603− return () => undefined;
604− }) as unknown as PluginDefinition["activate"];
605−
606− await runtime.reconcile([
607− {
608− id: "acme.function-return",
609− version: "1.0.0",
610− activate,
611− },
612− ]);
613−
614− expect(backgroundResult).toBeDefined();
615− await expect(withOperationTimeout(backgroundResult!)).resolves.toBeDefined();
616− await runtime.dispose();
617− });
618−
619− it("rejects activation-context calls from microtasks queued by synchronous callbacks", async () => {
620− let lateFailure: unknown;
621− let disposed = false;
622− const runtime = createRuntime();
623−
624− await runtime.reconcile([
625− {
626− id: "acme.microtask-context",
627− version: "1.0.0",
628− activate(context) {
629− queueMicrotask(() => {
630− try {
124+ yield* runtime.reconcile([provider(), consumer()]);
125+ lifecycle.length = 0;
126+
127+ yield* runtime.reconcile([]);
128+
129+ expect(lifecycle).toEqual(["deactivate:acme.issues", "deactivate:acme.database"]);
130+ yield* runtime.dispose;
131+ }),
132+ ),
133+ );
134+
135+ it.effect("runs plugin finalizers in reverse registration order", () =>
136+ Effect.scoped(
137+ Effect.gen(function* () {
138+ const disposed: Array<string> = [];
139+ const runtime = yield* createRuntime();
140+ yield* runtime.reconcile([
141+ {
142+ id: "acme.finalizers",
143+ version: "1.0.0",
144+ activate(context) {
631145 context.onDispose(() => {
632− disposed = true;
146+ disposed.push("first");
633147 });
634− } catch (error) {
635− lateFailure = error;
636− }
637− });
638− },
639− },
640− ]);
641−
642− expect(lateFailure).toBeInstanceOf(Error);
643− expect((lateFailure as Error).message).toContain("no longer active");
644− await runtime.dispose();
645− expect(disposed).toBe(false);
646− });
647−
648− it("rejects finalizers registered after activation settles", async () => {
649− let registerLateFinalizer!: () => void;
650− let disposed = false;
651− const runtime = createRuntime();
652− await runtime.reconcile([
653− {
654− id: "acme.late-finalizer",
655− version: "1.0.0",
656− activate(context) {
657− registerLateFinalizer = () =>
148+ context.onDispose(() => {
149+ disposed.push("second");
150+ });
151+ },
152+ },
153+ ]);
154+
155+ yield* runtime.reconcile([]);
156+
157+ expect(disposed).toEqual(["second", "first"]);
158+ yield* runtime.dispose;
159+ }),
160+ ),
161+ );
162+
163+ it.effect("keeps unchanged plugin scopes alive across reconciliation", () =>
164+ Effect.scoped(
165+ Effect.gen(function* () {
166+ let activations = 0;
167+ let disposals = 0;
168+ const stable: PluginDefinition = {
169+ id: "acme.stable",
170+ version: "1.0.0",
171+ activate(context) {
172+ activations += 1;
658173 context.onDispose(() => {
659− disposed = true;
174+ disposals += 1;
660175 });
661− },
662− },
663− ]);
664−
665− expect(registerLateFinalizer).toThrow("no longer active");
666− await runtime.dispose();
667− expect(disposed).toBe(false);
668− });
669−
670− it("snapshots the requested definitions before waiting for an earlier transition", async () => {
671− let releaseActivation!: () => void;
672− const activationGate = new Promise<void>((resolve) => {
673− releaseActivation = resolve;
674− });
675− const runtime = createRuntime();
676− const first = runtime.reconcile([
677− {
678− id: "acme.blocking",
679− version: "1.0.0",
680− async activate() {
681− await activationGate;
682− },
683− },
684− ]);
685− const requested: Array<PluginDefinition> = [provider()];
686− const second = runtime.reconcile(requested);
687− requested.splice(0);
688−
689− releaseActivation();
690− await first;
691− const snapshot = await second;
692−
693− expect(snapshot.active).toEqual(["acme.database"]);
694− await runtime.dispose();
695− });
696−
697− it("snapshots each requested definition and its declarations before queueing", async () => {
698− let releaseActivation!: () => void;
699− const activationGate = new Promise<void>((resolve) => {
700− releaseActivation = resolve;
701− });
702− const runtime = createRuntime();
703− const first = runtime.reconcile([
704− {
705− id: "acme.blocking",
706− version: "1.0.0",
707− async activate() {
708− await activationGate;
709− },
710− },
711− ]);
712− const requires = ["acme.database@1"];
713− const provides: Record<string, unknown> = { "acme.consumer@1": true };
714− const requestedDefinition: PluginDefinition = {
715− id: "acme.consumer",
716− version: "1.0.0",
717− requires,
718− provides,
719− activate() {},
720− };
721− const second = runtime.reconcile([provider(), requestedDefinition]);
722−
723− (requestedDefinition as { id: string }).id = "acme.changed";
724− requires[0] = "acme.missing@1";
725− delete provides["acme.consumer@1"];
726−
727− releaseActivation();
728− await first;
729− const snapshot = await second;
730−
731− expect(snapshot.active).toEqual(["acme.database", "acme.consumer"]);
732− await runtime.dispose();
733− });
734−
735− it("rejects capabilities that the plugin did not declare", async () => {
736− const runtime = createRuntime();
737− const undeclaredConsumer: PluginDefinition = {
738− id: "acme.undeclared-consumer",
739− version: "1.0.0",
740− activate(context) {
741− context.resolve("acme.database@1");
742− },
743− };
744−
745− let undeclaredFailure: unknown;
746− try {
747− await runtime.reconcile([provider(), undeclaredConsumer]);
748− } catch (error) {
749− undeclaredFailure = error;
750− }
751− expect(failureMessage(undeclaredFailure)).toContain("did not declare");
752− expect(runtime.snapshot().active).toEqual([]);
753− await runtime.dispose();
754− });
755−
756− it("rolls back a plugin scope when contribution snapshotting throws", async () => {
757− let disposed = false;
758− const runtime = createRuntime();
759− const badContribution = {
760− get id(): string {
761− throw new Error("bad contribution getter");
762− },
763− label: "bad",
764− };
765−
766− let snapshotFailure: unknown;
767− try {
768− await runtime.reconcile([
769− {
770− id: "acme.snapshot-defect",
176+ },
177+ };
178+ const runtime = yield* createRuntime();
179+
180+ yield* runtime.reconcile([stable]);
181+ yield* runtime.reconcile([stable]);
182+
183+ expect(activations).toBe(1);
184+ expect(disposals).toBe(0);
185+ yield* runtime.dispose;
186+ expect(disposals).toBe(1);
187+ }),
188+ ),
189+ );
190+
191+ it.effect("treats reordered requirements and providers as the same definition", () =>
192+ Effect.scoped(
193+ Effect.gen(function* () {
194+ let activations = 0;
195+ const firstService = {};
196+ const secondService = {};
197+ const activate: PluginDefinition["activate"] = () => {
198+ activations += 1;
199+ };
200+ const dependencies: ReadonlyArray<PluginDefinition> = [
201+ {
202+ id: "acme.first-dependency",
203+ version: "1.0.0",
204+ provides: { "acme.first@1": true },
205+ activate() {},
206+ },
207+ {
208+ id: "acme.second-dependency",
209+ version: "1.0.0",
210+ provides: { "acme.second@1": true },
211+ activate() {},
212+ },
213+ ];
214+ const runtime = yield* createRuntime();
215+ yield* runtime.reconcile([
216+ ...dependencies,
217+ {
218+ id: "acme.stable-order",
219+ version: "1.0.0",
220+ requires: ["acme.first@1", "acme.second@1"],
221+ provides: { "acme.output-one@1": firstService, "acme.output-two@1": secondService },
222+ activate,
223+ },
224+ ]);
225+
226+ yield* runtime.reconcile([
227+ ...dependencies,
228+ {
229+ id: "acme.stable-order",
230+ version: "1.0.0",
231+ requires: ["acme.second@1", "acme.first@1"],
232+ provides: { "acme.output-two@1": secondService, "acme.output-one@1": firstService },
233+ activate,
234+ },
235+ ]);
236+
237+ expect(activations).toBe(1);
238+ yield* runtime.dispose;
239+ }),
240+ ),
241+ );
242+
243+ it.effect("restarts a plugin when its activation implementation changes", () =>
244+ Effect.scoped(
245+ Effect.gen(function* () {
246+ const runtime = yield* createRuntime();
247+ const first: PluginDefinition = {
248+ id: "acme.implementation",
249+ version: "1.0.0",
250+ activate(context) {
251+ context.register("commands", { id: "implementation", label: "first" });
252+ },
253+ };
254+ const second: PluginDefinition = {
255+ id: "acme.implementation",
256+ version: "1.0.0",
257+ activate(context) {
258+ context.register("commands", { id: "implementation", label: "second" });
259+ },
260+ };
261+
262+ yield* runtime.reconcile([first]);
263+ const snapshot = yield* runtime.reconcile([second]);
264+
265+ expect(contributionLabels(snapshot, "commands")).toEqual(["second"]);
266+ yield* runtime.dispose;
267+ }),
268+ ),
269+ );
270+
271+ it.effect(
272+ "restarts a changed provider and its dependents without touching independent plugins",
273+ () =>
274+ Effect.scoped(
275+ Effect.gen(function* () {
276+ let independentActivations = 0;
277+ const lifecycle: Array<string> = [];
278+ const independent: PluginDefinition = {
279+ id: "acme.independent",
280+ version: "1.0.0",
281+ activate() {
282+ independentActivations += 1;
283+ },
284+ };
285+ const runtime = yield* createRuntime({
286+ onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`),
287+ });
288+ yield* runtime.reconcile([consumer(), independent, provider()]);
289+ lifecycle.length = 0;
290+
291+ const snapshot = yield* runtime.reconcile([consumer(), independent, provider("2.0.0")]);
292+
293+ expect(independentActivations).toBe(1);
294+ expect(lifecycle).not.toContain("activate:acme.independent");
295+ expect(lifecycle).not.toContain("deactivate:acme.independent");
296+ expect(lifecycle.filter((event) => event.startsWith("activate:"))).toEqual([
297+ "activate:acme.database",
298+ "activate:acme.issues",
299+ ]);
300+ expect(lifecycle.filter((event) => event.startsWith("deactivate:"))).toEqual([
301+ "deactivate:acme.issues",
302+ "deactivate:acme.database",
303+ ]);
304+ expect(contributionLabels(snapshot, "commands")).toEqual(["database-2.0.0"]);
305+ yield* runtime.dispose;
306+ }),
307+ ),
308+ );
309+
310+ it.effect("returns deeply frozen snapshots", () =>
311+ Effect.scoped(
312+ Effect.gen(function* () {
313+ const runtime = yield* createRuntime();
314+
315+ const snapshot = yield* runtime.reconcile([provider()]);
316+ const status = snapshot.contributions.status;
317+
318+ expect(Object.isFrozen(snapshot)).toBe(true);
319+ expect(Object.isFrozen(snapshot.active)).toBe(true);
320+ expect(Object.isFrozen(snapshot.blocked)).toBe(true);
321+ expect(Object.isFrozen(snapshot.contributions)).toBe(true);
322+ expect(Object.isFrozen(status)).toBe(true);
323+ expect(Object.isFrozen(status?.[0])).toBe(true);
324+ yield* runtime.dispose;
325+ }),
326+ ),
327+ );
328+
329+ it.effect("detaches registered contributions from later plugin mutation", () =>
330+ Effect.scoped(
331+ Effect.gen(function* () {
332+ const contribution = { id: "mutable", label: "original" };
333+ const plugin: PluginDefinition = {
334+ id: "acme.mutable-contribution",
335+ version: "1.0.0",
336+ activate(context) {
337+ context.register("commands", contribution);
338+ },
339+ };
340+ const unrelated: PluginDefinition = {
341+ id: "acme.unrelated-contribution",
342+ version: "1.0.0",
343+ activate() {},
344+ };
345+ const runtime = yield* createRuntime();
346+ yield* runtime.reconcile([plugin]);
347+
348+ contribution.label = "changed";
349+ const snapshot = yield* runtime.reconcile([plugin, unrelated]);
350+
351+ expect(contributionLabels(snapshot, "commands")).toEqual(["original"]);
352+ yield* runtime.dispose;
353+ }),
354+ ),
355+ );
356+
357+ it.effect("treats every plugin id and contribution slot as data", () =>
358+ Effect.scoped(
359+ Effect.gen(function* () {
360+ const runtime = yield* createRuntime();
361+ const unusualProvider: PluginDefinition = {
362+ id: "toString",
363+ version: "1.0.0",
364+ provides: { "acme.unusual@1": "available" },
365+ activate(context) {
366+ context.register("__proto__", { id: "provider", label: "provider" });
367+ },
368+ };
369+ const unusualConsumer: PluginDefinition = {
370+ id: "constructor",
371+ version: "1.0.0",
372+ requires: ["acme.unusual@1"],
373+ activate(context) {
374+ context.resolve("acme.unusual@1");
375+ context.register("toString", { id: "consumer", label: "consumer" });
376+ },
377+ };
378+ const unusualBlocked: PluginDefinition = {
379+ id: "__proto__",
380+ version: "1.0.0",
381+ requires: ["acme.missing@1"],
382+ activate() {
383+ throw new Error("blocked plugin activated");
384+ },
385+ };
386+
387+ const snapshot = yield* runtime.reconcile([
388+ unusualConsumer,
389+ unusualProvider,
390+ unusualBlocked,
391+ ]);
392+
393+ expect(snapshot.active).toEqual(["toString", "constructor"]);
394+ expect(Object.hasOwn(snapshot.blocked, "__proto__")).toBe(true);
395+ expect(snapshot.blocked.__proto__).toContain("acme.missing@1");
396+ expect(Object.hasOwn(snapshot.contributions, "__proto__")).toBe(true);
397+ expect(Object.hasOwn(snapshot.contributions, "toString")).toBe(true);
398+ expect(contributionLabels(snapshot, "__proto__")).toEqual(["provider"]);
399+ expect(contributionLabels(snapshot, "toString")).toEqual(["consumer"]);
400+ yield* runtime.dispose;
401+ }),
402+ ),
403+ );
404+
405+ it.effect("restarts dependents when a provided value changes", () =>
406+ Effect.scoped(
407+ Effect.gen(function* () {
408+ const lifecycle: Array<string> = [];
409+ const runtime = yield* createRuntime({
410+ onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`),
411+ });
412+ const serviceProvider = (name: string): PluginDefinition => ({
413+ id: "acme.service",
414+ version: "1.0.0",
415+ provides: { "acme.service@1": { name } },
416+ activate() {},
417+ });
418+ const serviceConsumer: PluginDefinition = {
419+ id: "acme.service-consumer",
420+ version: "1.0.0",
421+ requires: ["acme.service@1"],
422+ activate(context) {
423+ const service = context.resolve<{ readonly name: string }>("acme.service@1");
424+ context.register("commands", { id: "service", label: service.name });
425+ },
426+ };
427+ yield* runtime.reconcile([serviceProvider("first"), serviceConsumer]);
428+ lifecycle.length = 0;
429+
430+ const snapshot = yield* runtime.reconcile([serviceProvider("second"), serviceConsumer]);
431+
432+ expect(contributionLabels(snapshot, "commands")).toEqual(["second"]);
433+ expect(lifecycle.filter((event) => event.startsWith("activate:"))).toEqual([
434+ "activate:acme.service",
435+ "activate:acme.service-consumer",
436+ ]);
437+ yield* runtime.dispose;
438+ }),
439+ ),
440+ );
441+
442+ it.effect("does not report a rolled-back candidate as activated", () =>
443+ Effect.scoped(
444+ Effect.gen(function* () {
445+ const lifecycle: Array<string> = [];
446+ const runtime = yield* createRuntime({
447+ onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`),
448+ });
449+ const staged: PluginDefinition = {
450+ id: "acme.staged",
451+ version: "1.0.0",
452+ activate() {},
453+ };
454+ const broken: PluginDefinition = {
455+ id: "acme.broken",
456+ version: "1.0.0",
457+ activate() {
458+ throw new Error("activation failed");
459+ },
460+ };
461+
462+ const stagingFailure = yield* failureOf(runtime.reconcile([staged, broken]));
463+ expect(failureMessage(stagingFailure)).toContain("activation failed");
464+
465+ expect(lifecycle).toEqual([]);
466+ yield* runtime.dispose;
467+ }),
468+ ),
469+ );
470+
471+ it.effect("preserves activation failures when rollback cleanup also fails", () =>
472+ Effect.scoped(
473+ Effect.gen(function* () {
474+ const activationError = new Error("activation failed");
475+ const cleanupError = new Error("rollback cleanup failed");
476+ const cleanupEvents: Array<{ readonly phase: string; readonly error: unknown }> = [];
477+ const runtime = yield* createRuntime({
478+ onCleanupError: (event) => {
479+ cleanupEvents.push(event);
480+ throw new Error("cleanup observer failed");
481+ },
482+ });
483+ const broken: PluginDefinition = {
484+ id: "acme.rollback-error",
771485 version: "1.0.0",
772486 activate(context) {
773487 context.onDispose(() => {
774− disposed = true;
488+ throw cleanupError;
775489 });
776− context.register("commands", badContribution);
490+ throw activationError;
777491 },
778− },
779− ]);
780− } catch (error) {
781− snapshotFailure = error;
782− }
783−
784− expect(failureMessage(snapshotFailure)).toContain("bad contribution getter");
785− expect(disposed).toBe(true);
786− expect(runtime.snapshot().active).toEqual([]);
787− await runtime.dispose();
788− });
789−
790− it("attempts every plugin cleanup when one finalizer fails", async () => {
791− const disposed: Array<string> = [];
792− const runtime = createRuntime();
793− await runtime.reconcile([
794− {
795− id: "acme.first-cleanup",
796− version: "1.0.0",
797− activate(context) {
798− context.onDispose(() => {
799− disposed.push("first");
492+ };
493+
494+ const activationFailure = yield* failureOf(runtime.reconcile([broken]));
495+ expect(failureCause(activationFailure)).toBe(activationError);
496+ expect(cleanupEvents).toHaveLength(1);
497+ expect(cleanupEvents[0]?.phase).toBe("rollback");
498+ expect(failureCause(cleanupEvents[0]?.error)).toBe(cleanupError);
499+ yield* runtime.dispose;
500+ }),
501+ ),
502+ );
503+
504+ it.effect(
505+ "returns the committed snapshot when retiring an old plugin reports cleanup errors",
506+ () =>
507+ Effect.scoped(
508+ Effect.gen(function* () {
509+ const cleanupError = new Error("retirement cleanup failed");
510+ const cleanupEvents: Array<{ readonly phase: string; readonly error: unknown }> = [];
511+ const runtime = yield* createRuntime({
512+ onCleanupError: (event) => {
513+ cleanupEvents.push(event);
514+ throw new Error("cleanup observer failed");
515+ },
800516 });
801− },
802− },
803− {
804− id: "acme.second-cleanup",
805− version: "1.0.0",
806− activate(context) {
807− context.onDispose(() => {
808− disposed.push("second");
809− throw new Error("cleanup failed");
517+ yield* runtime.reconcile([
518+ {
519+ id: "acme.retirement-error",
520+ version: "1.0.0",
521+ activate(context) {
522+ context.register("commands", { id: "retirement", label: "old" });
523+ context.onDispose(() => {
524+ throw cleanupError;
525+ });
526+ },
527+ },
528+ ]);
529+
530+ const snapshot = yield* runtime.reconcile([
531+ {
532+ id: "acme.retirement-error",
533+ version: "2.0.0",
534+ activate(context) {
535+ context.register("commands", { id: "retirement", label: "new" });
536+ },
537+ },
538+ ]);
539+
540+ expect(contributionLabels(snapshot, "commands")).toEqual(["new"]);
541+ expect(yield* runtime.snapshot).toBe(snapshot);
542+ expect(cleanupEvents).toHaveLength(1);
543+ expect(cleanupEvents[0]?.phase).toBe("retire");
544+ expect(failureCause(cleanupEvents[0]?.error)).toBe(cleanupError);
545+ yield* runtime.dispose;
546+ }),
547+ ),
548+ );
549+
550+ it.effect("does not let lifecycle observers interrupt a committed transition", () =>
551+ Effect.scoped(
552+ Effect.gen(function* () {
553+ let oldDisposed = false;
554+ const observerErrors: Array<string> = [];
555+ const runtime = yield* createRuntime({
556+ onLifecycle: ({ phase, pluginId }) => {
557+ throw new Error(`${phase}:${pluginId}`);
558+ },
559+ onLifecycleError: ({ phase, pluginId }) => {
560+ observerErrors.push(`${phase}:${pluginId}`);
561+ },
562+ });
563+ yield* runtime.reconcile([
564+ {
565+ id: "acme.lifecycle-observer",
566+ version: "1.0.0",
567+ activate(context) {
568+ context.onDispose(() => {
569+ oldDisposed = true;
570+ });
571+ },
572+ },
573+ ]);
574+
575+ const snapshot = yield* runtime.reconcile([
576+ {
577+ id: "acme.lifecycle-observer",
578+ version: "2.0.0",
579+ activate(context) {
580+ context.register("commands", { id: "observer", label: "committed" });
581+ },
582+ },
583+ ]);
584+
585+ expect(oldDisposed).toBe(true);
586+ expect(contributionLabels(snapshot, "commands")).toEqual(["committed"]);
587+ expect(observerErrors).toEqual([
588+ "activate:acme.lifecycle-observer",
589+ "activate:acme.lifecycle-observer",
590+ "deactivate:acme.lifecycle-observer",
591+ ]);
592+ yield* runtime.dispose;
593+ }),
594+ ),
595+ );
596+
597+ it.effect("rejects runtime operations reentered from plugin activation", () =>
598+ Effect.scoped(
599+ Effect.gen(function* () {
600+ const runtime = yield* createRuntime();
601+ const nested = makeEffectCallback<Exit.Exit<PluginRuntimeSnapshot, unknown>, never>();
602+ const nestedFiber = yield* Effect.forkChild(nested.await);
603+ yield* Effect.yieldNow;
604+ let settleActivation!: (exit: Exit.Exit<unknown, unknown>) => void;
605+ const activationGate = new Promise<void>((resolve, reject) => {
606+ settleActivation = (exit) => settlePromise(exit, resolve, reject);
607+ });
608+ const reentrant: PluginDefinition = {
609+ id: "acme.reentrant",
610+ version: "1.0.0",
611+ activate() {
612+ nested.resume(
613+ Effect.exit(runtime.reconcile([])).pipe(
614+ Effect.tap((exit) => Effect.sync(() => settleActivation(exit))),
615+ ),
616+ );
617+ return activationGate;
618+ },
619+ };
620+ const activationFailure = yield* failureOf(runtime.reconcile([reentrant]));
621+ yield* Fiber.join(nestedFiber);
622+
623+ expect(failureMessage(activationFailure)).toContain("reentrant");
624+ }),
625+ ),
626+ );
627+
628+ it.effect("rejects runtime operations reentered from plugin finalizers", () =>
629+ Effect.scoped(
630+ Effect.gen(function* () {
631+ const runtime = yield* createRuntime();
632+ const nested = makeEffectCallback<Exit.Exit<void, unknown>, never>();
633+ const nestedFiber = yield* Effect.forkChild(nested.await);
634+ yield* Effect.yieldNow;
635+ let finishFinalizer!: (exit: Exit.Exit<void, unknown>) => void;
636+ const finalizerGate = new Promise<void>((resolve) => {
637+ finishFinalizer = () => resolve();
638+ });
639+ yield* runtime.reconcile([
640+ {
641+ id: "acme.reentrant-finalizer",
642+ version: "1.0.0",
643+ activate(context) {
644+ context.onDispose(() => {
645+ nested.resume(
646+ Effect.exit(runtime.dispose).pipe(
647+ Effect.tap((exit) => Effect.sync(() => finishFinalizer(exit))),
648+ ),
649+ );
650+ return finalizerGate;
651+ });
652+ },
653+ },
654+ ]);
655+ const snapshot = yield* runtime.reconcile([]);
656+ const nestedExit = yield* Fiber.join(nestedFiber);
657+ const nestedFailure = Exit.isFailure(nestedExit)
658+ ? Cause.squash(nestedExit.cause)
659+ : undefined;
660+
661+ expect(snapshot).toBeDefined();
662+ expect(nestedFailure).toBeInstanceOf(Error);
663+ expect((nestedFailure as Error).message).toContain("reentrant");
664+ yield* runtime.dispose;
665+ }),
666+ ),
667+ );
668+
669+ it.effect(
670+ "allows descendant tasks to use the runtime after their plugin callback settles",
671+ () =>
672+ Effect.scoped(
673+ Effect.gen(function* () {
674+ let releaseBackground!: () => void;
675+ const gate = new Promise<void>((resolve) => {
676+ releaseBackground = resolve;
810677 });
811− },
812− },
813− ]);
814−
815− await expect(runtime.dispose()).rejects.toThrow();
816−
817− expect(disposed).toEqual(["second", "first"]);
818− });
819−
820− it("keeps the old plugin active when a replacement fails", async () => {
821− const runtime = createRuntime();
822− await runtime.reconcile([provider()]);
823− const broken: PluginDefinition = {
824− ...provider("2.0.0"),
825− async activate(context) {
826− context.register("status", { id: "database", label: "database 2.0.0" });
827− throw new Error("candidate failed");
828− },
829− };
830−
831− let candidateFailure: unknown;
832− try {
833− await runtime.reconcile([broken]);
834− } catch (error) {
835− candidateFailure = error;
836− }
837− expect(failureMessage(candidateFailure)).toContain("candidate failed");
838−
839− expect(runtime.snapshot().active).toEqual(["acme.database"]);
840− expect(contributionLabels(runtime.snapshot(), "status")).toEqual(["database 1.0.0"]);
841− await runtime.dispose();
842− });
843−
844− it("rejects dependency cycles without disturbing the current composition", async () => {
845− const runtime = createRuntime();
846− await runtime.reconcile([provider()]);
847− const alpha: PluginDefinition = {
848− id: "acme.alpha",
849− version: "1.0.0",
850− requires: ["acme.beta@1"],
851− provides: { "acme.alpha@1": true },
852− activate() {},
853− };
854− const beta: PluginDefinition = {
855− id: "acme.beta",
856− version: "1.0.0",
857− requires: ["acme.alpha@1"],
858− provides: { "acme.beta@1": true },
859− activate() {},
860− };
861−
862− await expect(runtime.reconcile([alpha, beta])).rejects.toThrow("cycle");
863−
864− expect(runtime.snapshot().active).toEqual(["acme.database"]);
865− await runtime.dispose();
866− });
678+ const runtime = yield* createRuntime();
679+ const background = makeEffectCallback<
680+ PluginRuntimeSnapshot,
681+ PluginRuntimeReconcileError
682+ >();
683+ const backgroundFiber = yield* Effect.forkChild(background.await);
684+ yield* Effect.yieldNow;
685+
686+ yield* runtime.reconcile([
687+ {
688+ id: "acme.background-task",
689+ version: "1.0.0",
690+ activate() {
691+ void gate.then(() => background.resume(runtime.reconcile([])));
692+ },
693+ },
694+ ]);
695+
696+ releaseBackground();
697+ expect(yield* Fiber.join(backgroundFiber)).toBeDefined();
698+ yield* runtime.dispose;
699+ }),
700+ ),
701+ );
702+
703+ it.effect("allows microtasks spawned by synchronous plugin callbacks to use the runtime", () =>
704+ Effect.scoped(
705+ Effect.gen(function* () {
706+ let backgroundScheduled = false;
707+ const runtime = yield* createRuntime();
708+ const background = makeEffectCallback<
709+ PluginRuntimeSnapshot,
710+ PluginRuntimeReconcileError
711+ >();
712+ const backgroundFiber = yield* Effect.forkChild(background.await);
713+ yield* Effect.yieldNow;
714+
715+ yield* runtime.reconcile([
716+ {
717+ id: "acme.microtask",
718+ version: "1.0.0",
719+ activate() {
720+ queueMicrotask(() => {
721+ backgroundScheduled = true;
722+ background.resume(runtime.reconcile([]));
723+ });
724+ },
725+ },
726+ ]);
727+
728+ expect(backgroundScheduled).toBe(true);
729+ expect(yield* Fiber.join(backgroundFiber)).toBeDefined();
730+ yield* runtime.dispose;
731+ }),
732+ ),
733+ );
734+
735+ it.effect(
736+ "allows microtasks spawned by synchronous callbacks that return plain functions",
737+ () =>
738+ Effect.scoped(
739+ Effect.gen(function* () {
740+ let backgroundScheduled = false;
741+ const runtime = yield* createRuntime();
742+ const background = makeEffectCallback<
743+ PluginRuntimeSnapshot,
744+ PluginRuntimeReconcileError
745+ >();
746+ const backgroundFiber = yield* Effect.forkChild(background.await);
747+ yield* Effect.yieldNow;
748+ const activate = (() => {
749+ queueMicrotask(() => {
750+ backgroundScheduled = true;
751+ background.resume(runtime.reconcile([]));
752+ });
753+ return () => undefined;
754+ }) as unknown as PluginDefinition["activate"];
755+
756+ yield* runtime.reconcile([
757+ {
758+ id: "acme.function-return",
759+ version: "1.0.0",
760+ activate,
761+ },
762+ ]);
763+
764+ expect(backgroundScheduled).toBe(true);
765+ expect(yield* Fiber.join(backgroundFiber)).toBeDefined();
766+ yield* runtime.dispose;
767+ }),
768+ ),
769+ );
770+
771+ it.effect(
772+ "rejects activation-context calls from microtasks queued by synchronous callbacks",
773+ () =>
774+ Effect.scoped(
775+ Effect.gen(function* () {
776+ let lateFailure: unknown;
777+ let disposed = false;
778+ const runtime = yield* createRuntime();
779+
780+ yield* runtime.reconcile([
781+ {
782+ id: "acme.microtask-context",
783+ version: "1.0.0",
784+ activate(context) {
785+ queueMicrotask(() => {
786+ try {
787+ context.onDispose(() => {
788+ disposed = true;
789+ });
790+ } catch (error) {
791+ lateFailure = error;
792+ }
793+ });
794+ },
795+ },
796+ ]);
797+
798+ expect(lateFailure).toBeInstanceOf(Error);
799+ expect((lateFailure as Error).message).toContain("no longer active");
800+ yield* runtime.dispose;
801+ expect(disposed).toBe(false);
802+ }),
803+ ),
804+ );
805+
806+ it.effect("rejects finalizers registered after activation settles", () =>
807+ Effect.scoped(
808+ Effect.gen(function* () {
809+ let registerLateFinalizer!: () => void;
810+ let disposed = false;
811+ const runtime = yield* createRuntime();
812+ yield* runtime.reconcile([
813+ {
814+ id: "acme.late-finalizer",
815+ version: "1.0.0",
816+ activate(context) {
817+ registerLateFinalizer = () =>
818+ context.onDispose(() => {
819+ disposed = true;
820+ });
821+ },
822+ },
823+ ]);
824+
825+ expect(registerLateFinalizer).toThrow("no longer active");
826+ yield* runtime.dispose;
827+ expect(disposed).toBe(false);
828+ }),
829+ ),
830+ );
831+
832+ it.effect("snapshots the requested definitions before waiting for an earlier transition", () =>
833+ Effect.scoped(
834+ Effect.gen(function* () {
835+ let releaseActivation!: () => void;
836+ const activationGate = new Promise<void>((resolve) => {
837+ releaseActivation = resolve;
838+ });
839+ const runtime = yield* createRuntime();
840+ const first = yield* Effect.forkChild(
841+ runtime.reconcile([
842+ {
843+ id: "acme.blocking",
844+ version: "1.0.0",
845+ async activate() {
846+ await activationGate;
847+ },
848+ },
849+ ]),
850+ );
851+ yield* Effect.yieldNow;
852+ const requested: Array<PluginDefinition> = [provider()];
853+ const second = yield* Effect.forkChild(runtime.reconcile(requested));
854+ requested.splice(0);
855+
856+ releaseActivation();
857+ yield* Fiber.join(first);
858+ const snapshot = yield* Fiber.join(second);
859+
860+ expect(snapshot.active).toEqual(["acme.database"]);
861+ yield* runtime.dispose;
862+ }),
863+ ),
864+ );
865+
866+ it.effect("snapshots each requested definition and its declarations before queueing", () =>
867+ Effect.scoped(
868+ Effect.gen(function* () {
869+ let releaseActivation!: () => void;
870+ const activationGate = new Promise<void>((resolve) => {
871+ releaseActivation = resolve;
872+ });
873+ const runtime = yield* createRuntime();
874+ const first = yield* Effect.forkChild(
875+ runtime.reconcile([
876+ {
877+ id: "acme.blocking",
878+ version: "1.0.0",
879+ async activate() {
880+ await activationGate;
881+ },
882+ },
883+ ]),
884+ );
885+ yield* Effect.yieldNow;
886+ const requires = ["acme.database@1"];
887+ const provides: Record<string, unknown> = { "acme.consumer@1": true };
888+ const requestedDefinition: PluginDefinition = {
889+ id: "acme.consumer",
890+ version: "1.0.0",
891+ requires,
892+ provides,
893+ activate() {},
894+ };
895+ const second = yield* Effect.forkChild(
896+ runtime.reconcile([provider(), requestedDefinition]),
897+ );
898+
899+ (requestedDefinition as { id: string }).id = "acme.changed";
900+ requires[0] = "acme.missing@1";
901+ delete provides["acme.consumer@1"];
902+
903+ releaseActivation();
904+ yield* Fiber.join(first);
905+ const snapshot = yield* Fiber.join(second);
906+
907+ expect(snapshot.active).toEqual(["acme.database", "acme.consumer"]);
908+ yield* runtime.dispose;
909+ }),
910+ ),
911+ );
912+
913+ it.effect("rejects capabilities that the plugin did not declare", () =>
914+ Effect.scoped(
915+ Effect.gen(function* () {
916+ const runtime = yield* createRuntime();
917+ const undeclaredConsumer: PluginDefinition = {
918+ id: "acme.undeclared-consumer",
919+ version: "1.0.0",
920+ activate(context) {
921+ context.resolve("acme.database@1");
922+ },
923+ };
924+
925+ const undeclaredFailure = yield* failureOf(
926+ runtime.reconcile([provider(), undeclaredConsumer]),
927+ );
928+ expect(failureMessage(undeclaredFailure)).toContain("did not declare");
929+ expect((yield* runtime.snapshot).active).toEqual([]);
930+ yield* runtime.dispose;
931+ }),
932+ ),
933+ );
934+
935+ it.effect("rolls back a plugin scope when contribution snapshotting throws", () =>
936+ Effect.scoped(
937+ Effect.gen(function* () {
938+ let disposed = false;
939+ const runtime = yield* createRuntime();
940+ const badContribution = {
941+ get id(): string {
942+ throw new Error("bad contribution getter");
943+ },
944+ label: "bad",
945+ };
946+
947+ const snapshotFailure = yield* failureOf(
948+ runtime.reconcile([
949+ {
950+ id: "acme.snapshot-defect",
951+ version: "1.0.0",
952+ activate(context) {
953+ context.onDispose(() => {
954+ disposed = true;
955+ });
956+ context.register("commands", badContribution);
957+ },
958+ },
959+ ]),
960+ );
961+
962+ expect(failureMessage(snapshotFailure)).toContain("bad contribution getter");
963+ expect(disposed).toBe(true);
964+ expect((yield* runtime.snapshot).active).toEqual([]);
965+ yield* runtime.dispose;
966+ }),
967+ ),
968+ );
969+
970+ it.effect("attempts every plugin cleanup when one finalizer fails", () =>
971+ Effect.scoped(
972+ Effect.gen(function* () {
973+ const disposed: Array<string> = [];
974+ const runtime = yield* createRuntime();
975+ yield* runtime.reconcile([
976+ {
977+ id: "acme.first-cleanup",
978+ version: "1.0.0",
979+ activate(context) {
980+ context.onDispose(() => {
981+ disposed.push("first");
982+ });
983+ },
984+ },
985+ {
986+ id: "acme.second-cleanup",
987+ version: "1.0.0",
988+ activate(context) {
989+ context.onDispose(() => {
990+ disposed.push("second");
991+ throw new Error("cleanup failed");
992+ });
993+ },
994+ },
995+ ]);
996+
997+ expect(yield* failureOf(runtime.dispose)).toBeInstanceOf(Error);
998+
999+ expect(disposed).toEqual(["second", "first"]);
1000+ }),
1001+ ),
1002+ );
1003+
1004+ it.effect("keeps the old plugin active when a replacement fails", () =>
1005+ Effect.scoped(
1006+ Effect.gen(function* () {
1007+ const runtime = yield* createRuntime();
1008+ yield* runtime.reconcile([provider()]);
1009+ const broken: PluginDefinition = {
1010+ ...provider("2.0.0"),
1011+ async activate(context) {
1012+ context.register("status", { id: "database", label: "database 2.0.0" });
1013+ throw new Error("candidate failed");
1014+ },
1015+ };
1016+
1017+ const candidateFailure = yield* failureOf(runtime.reconcile([broken]));
1018+ expect(failureMessage(candidateFailure)).toContain("candidate failed");
1019+
1020+ expect((yield* runtime.snapshot).active).toEqual(["acme.database"]);
1021+ expect(contributionLabels(yield* runtime.snapshot, "status")).toEqual(["database 1.0.0"]);
1022+ yield* runtime.dispose;
1023+ }),
1024+ ),
1025+ );
1026+
1027+ it.effect("rejects dependency cycles without disturbing the current composition", () =>
1028+ Effect.scoped(
1029+ Effect.gen(function* () {
1030+ const runtime = yield* createRuntime();
1031+ yield* runtime.reconcile([provider()]);
1032+ const alpha: PluginDefinition = {
1033+ id: "acme.alpha",
1034+ version: "1.0.0",
1035+ requires: ["acme.beta@1"],
1036+ provides: { "acme.alpha@1": true },
1037+ activate() {},
1038+ };
1039+ const beta: PluginDefinition = {
1040+ id: "acme.beta",
1041+ version: "1.0.0",
1042+ requires: ["acme.alpha@1"],
1043+ provides: { "acme.beta@1": true },
1044+ activate() {},
1045+ };
1046+
1047+ expect(failureMessage(yield* failureOf(runtime.reconcile([alpha, beta])))).toContain(
1048+ "cycle",
1049+ );
1050+
1051+ expect((yield* runtime.snapshot).active).toEqual(["acme.database"]);
1052+ yield* runtime.dispose;
1053+ }),
1054+ ),
1055+ );
8671056 });
8681057 }
pnpm-lock.yaml+3−0
@@ -836,6 +836,9 @@ importers: · 835 unmodified lines
836836 specifier: 4.0.0-beta.103
837837 version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)
838838 devDependencies:
839+ '@effect/vitest':
840+ specifier: 4.0.0-beta.103
841+ version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))
839842 '@types/node':
840843 specifier: 24.12.4
841844 version: 24.12.4