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 @@
1# plugin runtime1# plugin runtime
2 2
3internal runtime for t3 product plugins.3internal effect service for t3 product plugins.
4 4
5it 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.5it 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.
6 6
7cordis is not a dependency. a pure-only executor was rejected because plugin lifetimes and async cleanup should be owned by effect scopes.7cordis 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
20 "effect": "catalog:"20 "effect": "catalog:"
21 },21 },
22 "devDependencies": {22 "devDependencies": {
23 "@effect/vitest": "catalog:",
23 "@types/node": "catalog:",24 "@types/node": "catalog:",
24 "vite-plus": "catalog:"25 "vite-plus": "catalog:"
25 }26 }
packages/plugin-runtime/src/contract.ts+0−10
@@ -23,14 +23,6 @@ export interface PluginRuntimeSnapshot { · 22 unmodified lines
23 readonly contributions: Readonly<Partial<Record<string, ReadonlyArray<Contribution>>>>;23 readonly contributions: Readonly<Partial<Record<string, ReadonlyArray<Contribution>>>>;
24}24}
25 25
26export interface PluginRuntime {
27 readonly reconcile: (
28 definitions: ReadonlyArray<PluginDefinition>,
29 ) => Promise<PluginRuntimeSnapshot>;
30 readonly snapshot: () => PluginRuntimeSnapshot;
31 readonly dispose: () => Promise<void>;
32}
33
34export interface PluginRuntimeOptions {26export interface PluginRuntimeOptions {
35 readonly onLifecycle?: (event: {27 readonly onLifecycle?: (event: {
36 readonly phase: "activate" | "deactivate";28 readonly phase: "activate" | "deactivate";
@@ -46,5 +38,3 @@ export interface PluginRuntimeOptions { · 9 unmodified lines
46 readonly error: unknown;38 readonly error: unknown;
47 }) => void;39 }) => void;
48}40}
49
50export type PluginRuntimeFactory = (options?: PluginRuntimeOptions) => PluginRuntime;
packages/plugin-runtime/src/index.ts+7−3
@@ -2,9 +2,13 @@ export type { · 1 unmodified line
2 Contribution,2 Contribution,
3 PluginActivationContext,3 PluginActivationContext,
4 PluginDefinition,4 PluginDefinition,
5 PluginRuntime,
6 PluginRuntimeFactory,
7 PluginRuntimeOptions,5 PluginRuntimeOptions,
8 PluginRuntimeSnapshot,6 PluginRuntimeSnapshot,
9} from "./contract.ts";7} from "./contract.ts";
10export { createPluginRuntime } from "./runtime.ts";8export {
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 @@
1import * as NodeAsyncHooks from "node:async_hooks";1import * as NodeAsyncHooks from "node:async_hooks";
2 2
3import * as Cause from "effect/Cause";3import * as Cause from "effect/Cause";
4import * as Context from "effect/Context";
4import * as Effect from "effect/Effect";5import * as Effect from "effect/Effect";
5import * as Exit from "effect/Exit";6import * as Exit from "effect/Exit";
7import * as Layer from "effect/Layer";
6import * as Schema from "effect/Schema";8import * as Schema from "effect/Schema";
9import * as Semaphore from "effect/Semaphore";
7import * as Scope from "effect/Scope";10import * as Scope from "effect/Scope";
8 11
9import type {12import type {
10 Contribution,13 Contribution,
11 PluginActivationContext,14 PluginActivationContext,
12 PluginDefinition,15 PluginDefinition,
13 PluginRuntime,16 PluginRuntimeOptions,
14 PluginRuntimeFactory,
15 PluginRuntimeSnapshot,17 PluginRuntimeSnapshot,
16} from "./contract.ts";18} from "./contract.ts";
17import {19import {
@@ -137,12 +139,26 @@ class PluginRuntimeCleanupError extends Schema.TaggedErrorClass<PluginRuntimeCle · 119 unmodified lines
137 }139 }
138}140}
139 141
140type PluginReconcileError =142export type PluginRuntimeReconcileError =
141 | PluginPlanningError143 | PluginPlanningError
142 | PluginCallbackError144 | PluginCallbackError
143 | PluginRuntimeDisposedError145 | PluginRuntimeDisposedError
146 | PluginRuntimeReentrancyError
144 | PluginStagingError;147 | PluginStagingError;
145 148
149export type PluginRuntimeDisposeError = PluginRuntimeCleanupError | PluginRuntimeReentrancyError;
150
151export 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
146const createNullPrototypeRecord = <Value>(): Record<string, Value> =>162const createNullPrototypeRecord = <Value>(): Record<string, Value> =>
147 Object.create(null) as Record<string, Value>;163 Object.create(null) as Record<string, Value>;
148 164
@@ -195,300 +211,297 @@ const snapshotOf = ( · 46 unmodified lines
195 });211 });
196};212};
197 213
198export const createPluginRuntime: PluginRuntimeFactory = (options = {}): PluginRuntime => {214export const make = (options: PluginRuntimeOptions = {}) =>
199 let current: LiveComposition = { plugins: [], snapshot: emptySnapshot() };215 Effect.gen(function* () {
200 let disposed = false;216 const parentScope = yield* Effect.scope;
201 let transition: Promise<void> = Promise.resolve();217 const transitionSemaphore = yield* Semaphore.make(1);
202 let runtimeScope: Scope.Closeable | undefined;218 let current: LiveComposition = { plugins: [], snapshot: emptySnapshot() };
203 const callbackContext = new NodeAsyncHooks.AsyncLocalStorage<PluginCallbackContext>();219 let disposed = false;
204 220 const callbackContext = new NodeAsyncHooks.AsyncLocalStorage<PluginCallbackContext>();
205 const getRuntimeScope = (): Effect.Effect<Scope.Closeable> =>221
206 Effect.gen(function* () {222 const reportLifecycle = (
207 if (runtimeScope === undefined) runtimeScope = yield* Scope.make("sequential");223 phase: "activate" | "deactivate",
208 return runtimeScope;224 pluginId: string,
209 });225 ): Effect.Effect<void> =>
210 226 Effect.sync(() => {
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) {
233 try {227 try {
234 options.onCleanupError?.({ phase, error });228 options.onLifecycle?.({ phase, pluginId });
235 } catch {229 } catch (error) {
236 // Cleanup reporting must not replace activation errors or undo a committed snapshot.230 try {
237 }231 options.onLifecycleError?.({ phase, pluginId, error });
238 }232 } catch {
239 });233 // Observer error reporting must never interrupt a commit or cleanup.
240 234 }
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);
260 }235 }
261 }236 });
262 return failures;
263 });
264 237
265 const invokePluginCallback = <Result>(238 const reportCleanupErrors = (
266 callback: PluginCallback,239 phase: "retire" | "rollback",
267 pluginId: string,240 failures: ReadonlyArray<CleanupFailure>,
268 invoke: () => Result | PromiseLike<Result>,241 ): Effect.Effect<void> =>
269 onSettled?: () => void,242 Effect.sync(() => {
270 ): Effect.Effect<Result, PluginCallbackError> =>243 for (const { error } of failures) {
271 Effect.tryPromise({244 try {
272 try: async () => {245 options.onCleanupError?.({ phase, error });
273 const callbackState: PluginCallbackContext = { active: true, callback, pluginId };246 } catch {
274 let settled = false;247 // Cleanup reporting must not replace activation errors or undo a committed snapshot.
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);
285 }248 }
286 settle();
287 return result;
288 } catch (error) {
289 settle();
290 throw error;
291 }249 }
292 },250 });
293 catch: (cause) => new PluginCallbackError({ callback, cause, pluginId }),
294 });
295 251
296 const activatePlugin = (252 const closePlugins = (
297 definition: PluginDefinition,253 plugins: ReadonlyArray<LivePlugin>,
298 capabilities: ReadonlyMap<string, unknown>,254 notifyDeactivation: boolean,
299 ): Effect.Effect<LivePlugin, PluginCallbackError> =>255 ): Effect.Effect<ReadonlyArray<CleanupFailure>> =>
300 Effect.gen(function* () {256 Effect.gen(function* () {
301 const parentScope = yield* getRuntimeScope();257 const failures: Array<CleanupFailure> = [];
302 const scope = yield* Scope.fork(parentScope, "sequential");258 for (const plugin of plugins.toReversed()) {
303 const contributions = new Map<string, Array<Contribution>>();259 const closeExit = yield* Effect.exit(Scope.close(plugin.scope, Exit.void));
304 const cleanupErrors: Array<unknown> = [];260 if (Exit.isFailure(closeExit)) {
305 const finalizers: Array<() => void | Promise<void>> = [];261 failures.push({
306 const plugin: LivePlugin = { definition, scope, contributions, cleanupErrors };262 error: Cause.squash(closeExit.cause),
307 let activating = true;263 pluginId: plugin.definition.id,
308 const assertActivating = (method: "resolve" | "register" | "onDispose") => {264 });
309 if (!activating) {265 }
310 throw new PluginActivationContextExpiredError({ method, pluginId: definition.id });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 }
311 }272 }
312 };273 return failures;
274 });
313 275
314 const context: PluginActivationContext = {276 const invokePluginCallback = <Result>(
315 resolve: <Service>(capability: string): Service => {277 callback: PluginCallback,
316 assertActivating("resolve");278 pluginId: string,
317 if (!(definition.requires ?? []).includes(capability)) {279 invoke: () => Result | PromiseLike<Result>,
318 throw new PluginUndeclaredCapabilityError({ capability, pluginId: definition.id });280 onSettled?: () => void,
319 }281 ): Effect.Effect<Result, PluginCallbackError> =>
320 if (!capabilities.has(capability)) {282 Effect.tryPromise({
321 throw new PluginResolutionError({ capability, pluginId: definition.id });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;
322 }302 }
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);
334 },303 },
335 };304 catch: (cause) => new PluginCallbackError({ callback, cause, pluginId }),
336 305 });
337 const activationExit = yield* Effect.exit(306
338 invokePluginCallback(307 const activatePlugin = (
339 "activate",308 definition: PluginDefinition,
340 definition.id,309 capabilities: ReadonlyMap<string, unknown>,
341 () => definition.activate(context),310 ): Effect.Effect<LivePlugin, PluginCallbackError> =>
342 () => {311 Effect.gen(function* () {
343 activating = false;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;
344 },334 },
345 ),335 register: (slot, contribution) => {
346 );336 assertActivating("register");
347 for (const finalizer of finalizers) {337 const values = contributions.get(slot) ?? [];
348 const finalizerEffect = invokePluginCallback("finalizer", definition.id, finalizer).pipe(338 values.push(Object.freeze({ id: contribution.id, label: contribution.label }));
349 Effect.catch((error) =>339 contributions.set(slot, values);
350 Effect.sync(() => {340 },
351 cleanupErrors.push(error);341 onDispose: (finalizer) => {
352 }),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 },
353 ),355 ),
354 );356 );
355 yield* Scope.addFinalizer(scope, finalizerEffect);357 for (const finalizer of finalizers) {
356 }358 const finalizerEffect = invokePluginCallback("finalizer", definition.id, finalizer).pipe(
357 359 Effect.catch((error) =>
358 if (Exit.isFailure(activationExit)) {360 Effect.sync(() => {
359 const failures = yield* closePlugins([plugin], false);361 cleanupErrors.push(error);
360 yield* reportCleanupErrors("rollback", failures);362 }),
361 return yield* Effect.failCause(activationExit.cause);363 ),
362 }364 );
363 return plugin;365 yield* Scope.addFinalizer(scope, finalizerEffect);
364 });366 }
365 367
366 const reconcileEffect = (368 if (Exit.isFailure(activationExit)) {
367 definitions: ReadonlyArray<PluginDefinition>,369 const failures = yield* closePlugins([plugin], false);
368 ): Effect.Effect<PluginRuntimeSnapshot, PluginReconcileError> =>370 yield* reportCleanupErrors("rollback", failures);
369 Effect.gen(function* () {371 return yield* Effect.failCause(activationExit.cause);
370 if (disposed) {372 }
371 return yield* new PluginRuntimeDisposedError({ operation: "reconcile" });373 return plugin;
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 },
380 });374 });
381 const affected = affectedPluginIds(375
382 current.plugins.map((plugin) => plugin.definition),376 const reconcileEffect = (
383 plan.definitions,377 definitions: ReadonlyArray<PluginDefinition>,
384 );378 ): Effect.Effect<PluginRuntimeSnapshot, PluginRuntimeReconcileError> =>
385 const currentById = new Map(current.plugins.map((plugin) => [plugin.definition.id, plugin]));379 Effect.gen(function* () {
386 const capabilities = new Map<string, unknown>();380 if (disposed) {
387 for (const plugin of current.plugins) {381 return yield* new PluginRuntimeDisposedError({ operation: "reconcile" });
388 if (affected.has(plugin.definition.id)) continue;
389 for (const [capability, service] of Object.entries(plugin.definition.provides ?? {})) {
390 capabilities.set(capability, service);
391 }382 }
392 }383
393 384 const plan = yield* Effect.try({
394 const staged = new Map<string, LivePlugin>();385 try: () => planComposition(definitions),
395 const candidateExit = yield* Effect.exit(386 catch: (error) => {
396 Effect.gen(function* () {387 if (!isPluginPlanningError(error)) throw error;
397 for (const definition of plan.definitions) {388 return error;
398 if (!affected.has(definition.id)) continue;389 },
399 const plugin = yield* activatePlugin(definition, capabilities);390 });
400 staged.set(definition.id, plugin);391 const affected = affectedPluginIds(
401 for (const [capability, service] of Object.entries(definition.provides ?? {})) {392 current.plugins.map((plugin) => plugin.definition),
402 capabilities.set(capability, service);393 plan.definitions,
403 }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);
404 }403 }
404 }
405 405
406 const nextPlugins: Array<LivePlugin> = [];406 const staged = new Map<string, LivePlugin>();
407 for (const definition of plan.definitions) {407 const candidateExit = yield* Effect.exit(
408 const plugin = staged.get(definition.id) ?? currentById.get(definition.id);408 Effect.gen(function* () {
409 if (plugin === undefined) {409 for (const definition of plan.definitions) {
410 return yield* new PluginStagingError({ pluginId: definition.id });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 }
411 }416 }
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 });
435 417
436 const disposeEffect = (): Effect.Effect<void, PluginRuntimeCleanupError> =>418 const nextPlugins: Array<LivePlugin> = [];
437 Effect.gen(function* () {419 for (const definition of plan.definitions) {
438 if (disposed) return;420 const plugin = staged.get(definition.id) ?? currentById.get(definition.id);
439 disposed = true;421 if (plugin === undefined) {
440 const previous = current.plugins;422 return yield* new PluginStagingError({ pluginId: definition.id });
441 current = { plugins: [], snapshot: emptySnapshot() };423 }
442 const failures = [...(yield* closePlugins(previous, true))];424 nextPlugins.push(plugin);
443 const parentScope = runtimeScope;425 }
444 runtimeScope = undefined;426 return {
445 if (parentScope !== undefined) {427 plugins: nextPlugins,
446 const closeExit = yield* Effect.exit(Scope.close(parentScope, Exit.void));428 snapshot: snapshotOf(nextPlugins, plan.blocked),
447 if (Exit.isFailure(closeExit)) {429 };
448 failures.push({ error: Cause.squash(closeExit.cause), pluginId: "plugin-runtime" });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);
449 }436 }
450 }
451 if (failures.length > 0) {
452 return yield* new PluginRuntimeCleanupError({
453 failures: failures.map(({ error, pluginId }) => ({ cause: error, pluginId })),
454 });
455 }
456 });
457 437
458 const runPromiseAdapter = <Result, Failure>(438 const previous = current.plugins.filter((plugin) => affected.has(plugin.definition.id));
459 operation: RuntimeOperation,439 current = candidateExit.value;
460 effect: () => Effect.Effect<Result, Failure>,440 for (const plugin of staged.values()) {
461 ): Promise<Result> => {441 yield* reportLifecycle("activate", plugin.definition.id);
462 const callback = callbackContext.getStore();442 }
463 if (callback?.active === true) {443 const failures = yield* closePlugins(previous, true);
464 return Promise.reject(444 yield* reportCleanupErrors("retire", failures);
465 new PluginRuntimeReentrancyError({445 return current.snapshot;
466 callback: callback.callback,446 });
467 operation,447
468 pluginId: callback.pluginId,448 const disposeEffect = (): Effect.Effect<void, PluginRuntimeCleanupError> =>
469 }),449 Effect.gen(function* () {
470 );450 if (disposed) return;
471 }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 });
472 479
473 const result = transition.then(() => Effect.runPromise(effect()));480 yield* Effect.addFinalizer(() =>
474 transition = result.then(481 disposeEffect().pipe(
475 () => undefined,482 Effect.catch((error) =>
476 () => undefined,483 reportCleanupErrors(
484 "retire",
485 error.failures.map(({ cause, pluginId }) => ({ error: cause, pluginId })),
486 ),
487 ),
488 ),
477 );489 );
478 return result;490
479 };491 return {
480 492 reconcile: (definitions) => {
481 return {493 let desired: ReadonlyArray<PluginDefinition>;
482 reconcile: (definitions) => {494 try {
483 let desired: ReadonlyArray<PluginDefinition>;495 desired = snapshotDefinitions(definitions);
484 try {496 } catch (error) {
485 desired = snapshotDefinitions(definitions);497 return Effect.die(error);
486 } catch (error) {498 }
487 return Promise.reject(error);499 return runTransition("reconcile", () => reconcileEffect(desired));
488 }500 },
489 return runPromiseAdapter("reconcile", () => reconcileEffect(desired));501 snapshot: Effect.sync(() => current.snapshot),
490 },502 dispose: runTransition("dispose", disposeEffect),
491 snapshot: () => current.snapshot,503 } satisfies PluginRuntime["Service"];
492 dispose: () => runPromiseAdapter("dispose", disposeEffect),504 });
493 };505
494};506export const layer = (options: PluginRuntimeOptions = {}) =>
507 Layer.effect(PluginRuntime, make(options));
packages/plugin-runtime/test/runtime.test.ts+80−33
@@ -1,45 +1,92 @@
1import { describe, expect, it } from "vite-plus/test";1import { it } from "@effect/vitest";
2import { describe, expect } from "vite-plus/test";
3import * as Effect from "effect/Effect";
2 4
3import type { PluginDefinition } from "../src/contract.ts";5import type { PluginDefinition, PluginRuntimeOptions } from "../src/contract.ts";
4import { createPluginRuntime } from "../src/runtime.ts";6import { layer, make, PluginRuntime } from "../src/runtime.ts";
5import { defineRuntimeContract } from "./runtimeContract.ts";7import { defineRuntimeContract } from "./runtimeContract.ts";
6 8
7defineRuntimeContract("plugin runtime", createPluginRuntime);9const makeTestRuntime = (options: PluginRuntimeOptions = {}) => make(options);
10
11defineRuntimeContract("plugin runtime", makeTestRuntime);
8 12
9describe("plugin runtime errors", () => {13describe("plugin runtime errors", () => {
10 it("returns schema-tagged planning errors", async () => {14 it.effect("returns schema-tagged planning errors", () =>
11 const runtime = createPluginRuntime();15 Effect.scoped(
12 const duplicate = {16 Effect.gen(function* () {
13 id: "acme.duplicate",17 const runtime = yield* makeTestRuntime();
14 version: "1.0.0",18 const duplicate = {
15 activate() {},19 id: "acme.duplicate",
16 };20 version: "1.0.0",
17 21 activate() {},
18 await expect(runtime.reconcile([duplicate, duplicate])).rejects.toMatchObject({22 };
19 _tag: "DuplicatePluginIdError",23
20 pluginId: "acme.duplicate",24 const error = yield* Effect.flip(runtime.reconcile([duplicate, duplicate]));
21 });25
22 });26 expect(error).toMatchObject({
27 _tag: "DuplicatePluginIdError",
28 pluginId: "acme.duplicate",
29 });
30 yield* runtime.dispose;
31 }),
32 ),
33 );
23});34});
24 35
25describe("plugin runtime planner", () => {36describe("plugin runtime planner", () => {
26 it("plans a deep acyclic dependency chain without using the call stack", async () => {37 it.effect("plans a deep acyclic dependency chain without using the call stack", () =>
27 const runtime = createPluginRuntime();38 Effect.scoped(
28 const pluginCount = 20_000;39 Effect.gen(function* () {
29 const definitions: Array<PluginDefinition> = Array.from(40 const runtime = yield* makeTestRuntime();
30 { length: pluginCount },41 const pluginCount = 20_000;
31 (_, index) => ({42 const definitions: Array<PluginDefinition> = Array.from(
32 id: `plugin-${index}`,43 { length: pluginCount },
33 version: "1.0.0",44 (_, index) => ({
34 ...(index === 0 ? {} : { requires: [`capability-${index - 1}`] }),45 id: `plugin-${index}`,
35 provides: { [`capability-${index}`]: index },46 version: "1.0.0",
36 activate() {},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;
37 }),57 }),
38 );58 ),
59 );
60});
61
62describe("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> = [];
39 67
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 );
41 87
42 expect(snapshot.active).toHaveLength(pluginCount);88 expect(disposed).toBe(true);
43 await runtime.dispose();89 expect(lifecycle).toEqual(["activate:acme.layer-owned", "deactivate:acme.layer-owned"]);
44 });90 }),
91 );
45});92});
packages/plugin-runtime/test/runtimeContract.ts+1003−814
@@ -1,31 +1,52 @@
1import * as NodeTimersPromises from "node:timers/promises";1import { it } from "@effect/vitest";
2import { describe, expect } from "vite-plus/test";
3import * as Cause from "effect/Cause";
4import * as Effect from "effect/Effect";
5import * as Exit from "effect/Exit";
6import * as Fiber from "effect/Fiber";
7import * as Scope from "effect/Scope";
2 8
3import { describe, expect, it } from "vite-plus/test";9import type { PluginRuntime, PluginRuntimeReconcileError } from "../src/runtime.ts";
4 10
5import type {11import type {
6 PluginDefinition,12 PluginDefinition,
7 PluginRuntimeFactory,13 PluginRuntimeOptions,
8 PluginRuntimeSnapshot,14 PluginRuntimeSnapshot,
9} from "../src/contract.ts";15} from "../src/contract.ts";
10 16
11const contributionLabels = (snapshot: PluginRuntimeSnapshot, slot: string) =>17type TestPluginRuntimeFactory = (
12 snapshot.contributions[slot]?.map((item) => item.label) ?? [];18 options?: PluginRuntimeOptions,
19) => Effect.Effect<PluginRuntime["Service"], never, Scope.Scope>;
20
21const 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};
13 32
14const withOperationTimeout = async <Result>(operation: Promise<Result>): Promise<Result> => {33const failureOf = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
15 const controller = new AbortController();34 Effect.exit(effect).pipe(
16 const timeout = NodeTimersPromises.setTimeout(100, undefined, {35 Effect.map((exit) => (Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined)),
17 ref: false,36 );
18 signal: controller.signal,37
19 }).then(() => {38const settlePromise = (
20 throw new Error("operation timed out");39 exit: Exit.Exit<unknown, unknown>,
21 });40 resolve: () => void,
22 try {41 reject: (error: unknown) => void,
23 return await Promise.race([operation, timeout]);42) => {
24 } finally {43 if (Exit.isFailure(exit)) reject(Cause.squash(exit.cause));
25 controller.abort();44 else resolve();
26 }
27};45};
28 46
47const contributionLabels = (snapshot: PluginRuntimeSnapshot, slot: string) =>
48 snapshot.contributions[slot]?.map((item) => item.label) ?? [];
49
29const failureCause = (error: unknown): unknown =>50const failureCause = (error: unknown): unknown =>
30 typeof error === "object" && error !== null && "cause" in error51 typeof error === "object" && error !== null && "cause" in error
31 ? (error as { readonly cause: unknown }).cause52 ? (error as { readonly cause: unknown }).cause
@@ -55,814 +76,982 @@ const consumer = (): PluginDefinition => ({ · 23 unmodified lines
55 },76 },
56});77});
57 78
58export function defineRuntimeContract(name: string, createRuntime: PluginRuntimeFactory) {79export function defineRuntimeContract(name: string, createRuntime: TestPluginRuntimeFactory) {
59 describe(name, () => {80 describe(name, () => {
60 it("activates providers before consumers regardless of manifest order", async () => {81 it.effect("activates providers before consumers regardless of manifest order", () =>
61 const runtime = createRuntime();82 Effect.scoped(
62 83 Effect.gen(function* () {
63 const snapshot = await runtime.reconcile([consumer(), provider()]);84 const runtime = yield* createRuntime();
64 85
65 expect(snapshot.active).toEqual(["acme.database", "acme.issues"]);86 const snapshot = yield* runtime.reconcile([consumer(), provider()]);
66 expect(contributionLabels(snapshot, "commands")).toEqual(["database-1.0.0"]);87
67 await runtime.dispose();88 expect(snapshot.active).toEqual(["acme.database", "acme.issues"]);
68 });89 expect(contributionLabels(snapshot, "commands")).toEqual(["database-1.0.0"]);
69 90 yield* runtime.dispose;
70 it("blocks missing dependencies without blocking independent plugins", async () => {91 }),
71 const runtime = createRuntime();92 ),
72 const independent: PluginDefinition = {93 );
73 id: "acme.clock",94
74 version: "1.0.0",95 it.effect("blocks missing dependencies without blocking independent plugins", () =>
75 activate(context) {96 Effect.scoped(
76 context.register("status", { id: "clock", label: "clock" });97 Effect.gen(function* () {
77 },98 const runtime = yield* createRuntime();
78 };99 const independent: PluginDefinition = {
79 100 id: "acme.clock",
80 const snapshot = await runtime.reconcile([consumer(), independent]);101 version: "1.0.0",
81 102 activate(context) {
82 expect(snapshot.active).toEqual(["acme.clock"]);103 context.register("status", { id: "clock", label: "clock" });
83 expect(snapshot.blocked["acme.issues"]).toContain("acme.database@1");104 },
84 expect(contributionLabels(snapshot, "status")).toEqual(["clock"]);105 };
85 await runtime.dispose();106
86 });107 const snapshot = yield* runtime.reconcile([consumer(), independent]);
87 108
88 it("deactivates dependents before providers", async () => {109 expect(snapshot.active).toEqual(["acme.clock"]);
89 const lifecycle: Array<string> = [];110 expect(snapshot.blocked["acme.issues"]).toContain("acme.database@1");
90 const runtime = createRuntime({111 expect(contributionLabels(snapshot, "status")).toEqual(["clock"]);
91 onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`),112 yield* runtime.dispose;
92 });113 }),
93 await runtime.reconcile([provider(), consumer()]);114 ),
94 lifecycle.length = 0;115 );
95 116
96 await runtime.reconcile([]);117 it.effect("deactivates dependents before providers", () =>
97 118 Effect.scoped(
98 expect(lifecycle).toEqual(["deactivate:acme.issues", "deactivate:acme.database"]);119 Effect.gen(function* () {
99 await runtime.dispose();120 const lifecycle: Array<string> = [];
100 });121 const runtime = yield* createRuntime({
101 122 onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`),
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;
413 });123 });
414 throw activationError;124 yield* runtime.reconcile([provider(), consumer()]);
415 },125 lifecycle.length = 0;
416 };126
417 127 yield* runtime.reconcile([]);
418 let activationFailure: unknown;128
419 try {129 expect(lifecycle).toEqual(["deactivate:acme.issues", "deactivate:acme.database"]);
420 await runtime.reconcile([broken]);130 yield* runtime.dispose;
421 } catch (error) {131 }),
422 activationFailure = error;132 ),
423 }133 );
424 expect(failureCause(activationFailure)).toBe(activationError);134
425 expect(cleanupEvents).toHaveLength(1);135 it.effect("runs plugin finalizers in reverse registration order", () =>
426 expect(cleanupEvents[0]?.phase).toBe("rollback");136 Effect.scoped(
427 expect(failureCause(cleanupEvents[0]?.error)).toBe(cleanupError);137 Effect.gen(function* () {
428 await runtime.dispose();138 const disposed: Array<string> = [];
429 });139 const runtime = yield* createRuntime();
430 140 yield* runtime.reconcile([
431 it("returns the committed snapshot when retiring an old plugin reports cleanup errors", async () => {141 {
432 const cleanupError = new Error("retirement cleanup failed");142 id: "acme.finalizers",
433 const cleanupEvents: Array<{ readonly phase: string; readonly error: unknown }> = [];143 version: "1.0.0",
434 const runtime = createRuntime({144 activate(context) {
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 {
631 context.onDispose(() => {145 context.onDispose(() => {
632 disposed = true;146 disposed.push("first");
633 });147 });
634 } catch (error) {148 context.onDispose(() => {
635 lateFailure = error;149 disposed.push("second");
636 }150 });
637 });151 },
638 },152 },
639 },153 ]);
640 ]);154
641 155 yield* runtime.reconcile([]);
642 expect(lateFailure).toBeInstanceOf(Error);156
643 expect((lateFailure as Error).message).toContain("no longer active");157 expect(disposed).toEqual(["second", "first"]);
644 await runtime.dispose();158 yield* runtime.dispose;
645 expect(disposed).toBe(false);159 }),
646 });160 ),
647 161 );
648 it("rejects finalizers registered after activation settles", async () => {162
649 let registerLateFinalizer!: () => void;163 it.effect("keeps unchanged plugin scopes alive across reconciliation", () =>
650 let disposed = false;164 Effect.scoped(
651 const runtime = createRuntime();165 Effect.gen(function* () {
652 await runtime.reconcile([166 let activations = 0;
653 {167 let disposals = 0;
654 id: "acme.late-finalizer",168 const stable: PluginDefinition = {
655 version: "1.0.0",169 id: "acme.stable",
656 activate(context) {170 version: "1.0.0",
657 registerLateFinalizer = () =>171 activate(context) {
172 activations += 1;
658 context.onDispose(() => {173 context.onDispose(() => {
659 disposed = true;174 disposals += 1;
660 });175 });
661 },176 },
662 },177 };
663 ]);178 const runtime = yield* createRuntime();
664 179
665 expect(registerLateFinalizer).toThrow("no longer active");180 yield* runtime.reconcile([stable]);
666 await runtime.dispose();181 yield* runtime.reconcile([stable]);
667 expect(disposed).toBe(false);182
668 });183 expect(activations).toBe(1);
669 184 expect(disposals).toBe(0);
670 it("snapshots the requested definitions before waiting for an earlier transition", async () => {185 yield* runtime.dispose;
671 let releaseActivation!: () => void;186 expect(disposals).toBe(1);
672 const activationGate = new Promise<void>((resolve) => {187 }),
673 releaseActivation = resolve;188 ),
674 });189 );
675 const runtime = createRuntime();190
676 const first = runtime.reconcile([191 it.effect("treats reordered requirements and providers as the same definition", () =>
677 {192 Effect.scoped(
678 id: "acme.blocking",193 Effect.gen(function* () {
679 version: "1.0.0",194 let activations = 0;
680 async activate() {195 const firstService = {};
681 await activationGate;196 const secondService = {};
682 },197 const activate: PluginDefinition["activate"] = () => {
683 },198 activations += 1;
684 ]);199 };
685 const requested: Array<PluginDefinition> = [provider()];200 const dependencies: ReadonlyArray<PluginDefinition> = [
686 const second = runtime.reconcile(requested);201 {
687 requested.splice(0);202 id: "acme.first-dependency",
688 203 version: "1.0.0",
689 releaseActivation();204 provides: { "acme.first@1": true },
690 await first;205 activate() {},
691 const snapshot = await second;206 },
692 207 {
693 expect(snapshot.active).toEqual(["acme.database"]);208 id: "acme.second-dependency",
694 await runtime.dispose();209 version: "1.0.0",
695 });210 provides: { "acme.second@1": true },
696 211 activate() {},
697 it("snapshots each requested definition and its declarations before queueing", async () => {212 },
698 let releaseActivation!: () => void;213 ];
699 const activationGate = new Promise<void>((resolve) => {214 const runtime = yield* createRuntime();
700 releaseActivation = resolve;215 yield* runtime.reconcile([
701 });216 ...dependencies,
702 const runtime = createRuntime();217 {
703 const first = runtime.reconcile([218 id: "acme.stable-order",
704 {219 version: "1.0.0",
705 id: "acme.blocking",220 requires: ["acme.first@1", "acme.second@1"],
706 version: "1.0.0",221 provides: { "acme.output-one@1": firstService, "acme.output-two@1": secondService },
707 async activate() {222 activate,
708 await activationGate;223 },
709 },224 ]);
710 },225
711 ]);226 yield* runtime.reconcile([
712 const requires = ["acme.database@1"];227 ...dependencies,
713 const provides: Record<string, unknown> = { "acme.consumer@1": true };228 {
714 const requestedDefinition: PluginDefinition = {229 id: "acme.stable-order",
715 id: "acme.consumer",230 version: "1.0.0",
716 version: "1.0.0",231 requires: ["acme.second@1", "acme.first@1"],
717 requires,232 provides: { "acme.output-two@1": secondService, "acme.output-one@1": firstService },
718 provides,233 activate,
719 activate() {},234 },
720 };235 ]);
721 const second = runtime.reconcile([provider(), requestedDefinition]);236
722 237 expect(activations).toBe(1);
723 (requestedDefinition as { id: string }).id = "acme.changed";238 yield* runtime.dispose;
724 requires[0] = "acme.missing@1";239 }),
725 delete provides["acme.consumer@1"];240 ),
726 241 );
727 releaseActivation();242
728 await first;243 it.effect("restarts a plugin when its activation implementation changes", () =>
729 const snapshot = await second;244 Effect.scoped(
730 245 Effect.gen(function* () {
731 expect(snapshot.active).toEqual(["acme.database", "acme.consumer"]);246 const runtime = yield* createRuntime();
732 await runtime.dispose();247 const first: PluginDefinition = {
733 });248 id: "acme.implementation",
734 249 version: "1.0.0",
735 it("rejects capabilities that the plugin did not declare", async () => {250 activate(context) {
736 const runtime = createRuntime();251 context.register("commands", { id: "implementation", label: "first" });
737 const undeclaredConsumer: PluginDefinition = {252 },
738 id: "acme.undeclared-consumer",253 };
739 version: "1.0.0",254 const second: PluginDefinition = {
740 activate(context) {255 id: "acme.implementation",
741 context.resolve("acme.database@1");256 version: "1.0.0",
742 },257 activate(context) {
743 };258 context.register("commands", { id: "implementation", label: "second" });
744 259 },
745 let undeclaredFailure: unknown;260 };
746 try {261
747 await runtime.reconcile([provider(), undeclaredConsumer]);262 yield* runtime.reconcile([first]);
748 } catch (error) {263 const snapshot = yield* runtime.reconcile([second]);
749 undeclaredFailure = error;264
750 }265 expect(contributionLabels(snapshot, "commands")).toEqual(["second"]);
751 expect(failureMessage(undeclaredFailure)).toContain("did not declare");266 yield* runtime.dispose;
752 expect(runtime.snapshot().active).toEqual([]);267 }),
753 await runtime.dispose();268 ),
754 });269 );
755 270
756 it("rolls back a plugin scope when contribution snapshotting throws", async () => {271 it.effect(
757 let disposed = false;272 "restarts a changed provider and its dependents without touching independent plugins",
758 const runtime = createRuntime();273 () =>
759 const badContribution = {274 Effect.scoped(
760 get id(): string {275 Effect.gen(function* () {
761 throw new Error("bad contribution getter");276 let independentActivations = 0;
762 },277 const lifecycle: Array<string> = [];
763 label: "bad",278 const independent: PluginDefinition = {
764 };279 id: "acme.independent",
765 280 version: "1.0.0",
766 let snapshotFailure: unknown;281 activate() {
767 try {282 independentActivations += 1;
768 await runtime.reconcile([283 },
769 {284 };
770 id: "acme.snapshot-defect",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",
771 version: "1.0.0",485 version: "1.0.0",
772 activate(context) {486 activate(context) {
773 context.onDispose(() => {487 context.onDispose(() => {
774 disposed = true;488 throw cleanupError;
775 });489 });
776 context.register("commands", badContribution);490 throw activationError;
777 },491 },
778 },492 };
779 ]);493
780 } catch (error) {494 const activationFailure = yield* failureOf(runtime.reconcile([broken]));
781 snapshotFailure = error;495 expect(failureCause(activationFailure)).toBe(activationError);
782 }496 expect(cleanupEvents).toHaveLength(1);
783 497 expect(cleanupEvents[0]?.phase).toBe("rollback");
784 expect(failureMessage(snapshotFailure)).toContain("bad contribution getter");498 expect(failureCause(cleanupEvents[0]?.error)).toBe(cleanupError);
785 expect(disposed).toBe(true);499 yield* runtime.dispose;
786 expect(runtime.snapshot().active).toEqual([]);500 }),
787 await runtime.dispose();501 ),
788 });502 );
789 503
790 it("attempts every plugin cleanup when one finalizer fails", async () => {504 it.effect(
791 const disposed: Array<string> = [];505 "returns the committed snapshot when retiring an old plugin reports cleanup errors",
792 const runtime = createRuntime();506 () =>
793 await runtime.reconcile([507 Effect.scoped(
794 {508 Effect.gen(function* () {
795 id: "acme.first-cleanup",509 const cleanupError = new Error("retirement cleanup failed");
796 version: "1.0.0",510 const cleanupEvents: Array<{ readonly phase: string; readonly error: unknown }> = [];
797 activate(context) {511 const runtime = yield* createRuntime({
798 context.onDispose(() => {512 onCleanupError: (event) => {
799 disposed.push("first");513 cleanupEvents.push(event);
514 throw new Error("cleanup observer failed");
515 },
800 });516 });
801 },517 yield* runtime.reconcile([
802 },518 {
803 {519 id: "acme.retirement-error",
804 id: "acme.second-cleanup",520 version: "1.0.0",
805 version: "1.0.0",521 activate(context) {
806 activate(context) {522 context.register("commands", { id: "retirement", label: "old" });
807 context.onDispose(() => {523 context.onDispose(() => {
808 disposed.push("second");524 throw cleanupError;
809 throw new Error("cleanup failed");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;
810 });677 });
811 },678 const runtime = yield* createRuntime();
812 },679 const background = makeEffectCallback<
813 ]);680 PluginRuntimeSnapshot,
814 681 PluginRuntimeReconcileError
815 await expect(runtime.dispose()).rejects.toThrow();682 >();
816 683 const backgroundFiber = yield* Effect.forkChild(background.await);
817 expect(disposed).toEqual(["second", "first"]);684 yield* Effect.yieldNow;
818 });685
819 686 yield* runtime.reconcile([
820 it("keeps the old plugin active when a replacement fails", async () => {687 {
821 const runtime = createRuntime();688 id: "acme.background-task",
822 await runtime.reconcile([provider()]);689 version: "1.0.0",
823 const broken: PluginDefinition = {690 activate() {
824 ...provider("2.0.0"),691 void gate.then(() => background.resume(runtime.reconcile([])));
825 async activate(context) {692 },
826 context.register("status", { id: "database", label: "database 2.0.0" });693 },
827 throw new Error("candidate failed");694 ]);
828 },695
829 };696 releaseBackground();
830 697 expect(yield* Fiber.join(backgroundFiber)).toBeDefined();
831 let candidateFailure: unknown;698 yield* runtime.dispose;
832 try {699 }),
833 await runtime.reconcile([broken]);700 ),
834 } catch (error) {701 );
835 candidateFailure = error;702
836 }703 it.effect("allows microtasks spawned by synchronous plugin callbacks to use the runtime", () =>
837 expect(failureMessage(candidateFailure)).toContain("candidate failed");704 Effect.scoped(
838 705 Effect.gen(function* () {
839 expect(runtime.snapshot().active).toEqual(["acme.database"]);706 let backgroundScheduled = false;
840 expect(contributionLabels(runtime.snapshot(), "status")).toEqual(["database 1.0.0"]);707 const runtime = yield* createRuntime();
841 await runtime.dispose();708 const background = makeEffectCallback<
842 });709 PluginRuntimeSnapshot,
843 710 PluginRuntimeReconcileError
844 it("rejects dependency cycles without disturbing the current composition", async () => {711 >();
845 const runtime = createRuntime();712 const backgroundFiber = yield* Effect.forkChild(background.await);
846 await runtime.reconcile([provider()]);713 yield* Effect.yieldNow;
847 const alpha: PluginDefinition = {714
848 id: "acme.alpha",715 yield* runtime.reconcile([
849 version: "1.0.0",716 {
850 requires: ["acme.beta@1"],717 id: "acme.microtask",
851 provides: { "acme.alpha@1": true },718 version: "1.0.0",
852 activate() {},719 activate() {
853 };720 queueMicrotask(() => {
854 const beta: PluginDefinition = {721 backgroundScheduled = true;
855 id: "acme.beta",722 background.resume(runtime.reconcile([]));
856 version: "1.0.0",723 });
857 requires: ["acme.alpha@1"],724 },
858 provides: { "acme.beta@1": true },725 },
859 activate() {},726 ]);
860 };727
861 728 expect(backgroundScheduled).toBe(true);
862 await expect(runtime.reconcile([alpha, beta])).rejects.toThrow("cycle");729 expect(yield* Fiber.join(backgroundFiber)).toBeDefined();
863 730 yield* runtime.dispose;
864 expect(runtime.snapshot().active).toEqual(["acme.database"]);731 }),
865 await runtime.dispose();732 ),
866 });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 );
867 });1056 });
868}1057}
pnpm-lock.yaml+3−0
@@ -836,6 +836,9 @@ importers: · 835 unmodified lines
836 specifier: 4.0.0-beta.103836 specifier: 4.0.0-beta.103
837 version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)837 version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)
838 devDependencies:838 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))
839 '@types/node':842 '@types/node':
840 specifier: 24.12.4843 specifier: 24.12.4
841 version: 24.12.4844 version: 24.12.4