History

fix: close plugin activation context promptly

t3-code[bot] committed 2 months ago

Commit fd92f381 parent a390b303 files · +79 −14Browse files

Unified · Split

3 files
  1. packages/plugin-runtime-poc/README.md +1 −1
  2. packages/plugin-runtime-poc/src/effectScopeRuntime.ts +49 −13
  3. packages/plugin-runtime-poc/src/runtimeContract.ts +29 −0
packages/plugin-runtime-poc/README.md+1−1
@@ -14,7 +14,7 @@ this is the recommended base because t3 already uses effect. it keeps cordis's o · 13 unmodified lines
14 14
15## proved15## proved
16 16
17- 31 tests cover dependency order, blocking, rollback, cycles, unchanged instance reuse, order-insensitive declarations, activation implementation replacement, schema-tagged errors, active callback reentrancy, synchronous callback microtasks, plain-function callback returns, descendant async tasks, late activation-context calls, queued input snapshots, observer isolation, cleanup reporting, unusual ids and slots, and immutable snapshots17- 32 tests cover dependency order, blocking, rollback, cycles, unchanged instance reuse, order-insensitive declarations, activation implementation replacement, schema-tagged errors, active callback reentrancy, synchronous callback microtasks, plain-function callback returns, prompt activation-context expiry, descendant async tasks, late activation-context calls, queued input snapshots, observer isolation, cleanup reporting, unusual ids and slots, and immutable snapshots
18- manifest validation enforces semver, prevents entrypoint path escape, and keeps mobile declarative18- manifest validation enforces semver, prevents entrypoint path escape, and keeps mobile declarative
19- the demo exercises activation, replacement, teardown, and a 250-plugin chain19- the demo exercises activation, replacement, teardown, and a 250-plugin chain
20 20
packages/plugin-runtime-poc/src/effectScopeRuntime.ts+49−13
@@ -86,6 +86,18 @@ class PluginResolutionError extends Schema.TaggedErrorClass<PluginResolutionErro · 85 unmodified lines
86 }86 }
87}87}
88 88
89class PluginActivationContextExpiredError extends Schema.TaggedErrorClass<PluginActivationContextExpiredError>()(
90 "PluginActivationContextExpiredError",
91 {
92 method: Schema.Literals(["resolve", "register", "onDispose"]),
93 pluginId: Schema.String,
94 },
95) {
96 override get message(): string {
97 return `activation context for ${this.pluginId} is no longer active (${this.method})`;
98 }
99}
100
89class PluginCallbackError extends Schema.TaggedErrorClass<PluginCallbackError>()(101class PluginCallbackError extends Schema.TaggedErrorClass<PluginCallbackError>()(
90 "PluginCallbackError",102 "PluginCallbackError",
91 {103 {
@@ -148,6 +160,15 @@ class PluginRuntimeCleanupError extends Schema.TaggedErrorClass<PluginRuntimeCle · 56 unmodified lines
148 160
149type PluginPlanningError = DuplicatePluginIdError | DuplicateCapabilityError | DependencyCycleError;161type PluginPlanningError = DuplicatePluginIdError | DuplicateCapabilityError | DependencyCycleError;
150 162
163const isDuplicatePluginIdError = Schema.is(DuplicatePluginIdError);
164const isDuplicateCapabilityError = Schema.is(DuplicateCapabilityError);
165const isDependencyCycleError = Schema.is(DependencyCycleError);
166
167const isPluginPlanningError = (error: unknown): error is PluginPlanningError =>
168 isDuplicatePluginIdError(error) ||
169 isDuplicateCapabilityError(error) ||
170 isDependencyCycleError(error);
171
151type PluginReconcileError =172type PluginReconcileError =
152 | PluginPlanningError173 | PluginPlanningError
153 | PluginCallbackError174 | PluginCallbackError
@@ -446,21 +467,27 @@ export const createEffectScopeRuntime: PluginRuntimeFactory = (options = {}): Pl · 292 unmodified lines
446 callback: PluginCallback,467 callback: PluginCallback,
447 pluginId: string,468 pluginId: string,
448 invoke: () => Result | PromiseLike<Result>,469 invoke: () => Result | PromiseLike<Result>,
470 onSettled?: () => void,
449 ): Effect.Effect<Result, PluginCallbackError> =>471 ): Effect.Effect<Result, PluginCallbackError> =>
450 Effect.tryPromise({472 Effect.tryPromise({
451 try: async () => {473 try: async () => {
452 const callbackState: PluginCallbackContext = { active: true, callback, pluginId };474 const callbackState: PluginCallbackContext = { active: true, callback, pluginId };
475 let settled = false;
476 const settle = () => {
477 if (settled) return;
478 settled = true;
479 callbackState.active = false;
480 onSettled?.();
481 };
453 try {482 try {
454 const result = callbackContext.run(callbackState, invoke);483 const result = callbackContext.run(callbackState, invoke);
455 if (typeof result === "object" && result !== null && "then" in result) {484 if (typeof result === "object" && result !== null && "then" in result) {
456 return await Promise.resolve(result).finally(() => {485 return await Promise.resolve(result).finally(settle);
457 callbackState.active = false;
458 });
459 }486 }
460 callbackState.active = false;487 settle();
461 return result;488 return result;
462 } catch (error) {489 } catch (error) {
463 callbackState.active = false;490 settle();
464 throw error;491 throw error;
465 }492 }
466 },493 },
@@ -479,36 +506,42 @@ export const createEffectScopeRuntime: PluginRuntimeFactory = (options = {}): Pl · 12 unmodified lines
479 const finalizers: Array<() => void | Promise<void>> = [];506 const finalizers: Array<() => void | Promise<void>> = [];
480 const plugin: LivePlugin = { definition, scope, contributions, cleanupErrors };507 const plugin: LivePlugin = { definition, scope, contributions, cleanupErrors };
481 let activating = true;508 let activating = true;
482 const assertActivating = () => {509 const assertActivating = (method: "resolve" | "register" | "onDispose") => {
483 if (!activating) {510 if (!activating) {
484 throw new Error(`activation context for ${definition.id} is no longer active`);511 throw new PluginActivationContextExpiredError({ method, pluginId: definition.id });
485 }512 }
486 };513 };
487 514
488 const context: PluginActivationContext = {515 const context: PluginActivationContext = {
489 resolve: <Service>(capability: string): Service => {516 resolve: <Service>(capability: string): Service => {
490 assertActivating();517 assertActivating("resolve");
491 if (!capabilities.has(capability)) {518 if (!capabilities.has(capability)) {
492 throw new PluginResolutionError({ capability, pluginId: definition.id });519 throw new PluginResolutionError({ capability, pluginId: definition.id });
493 }520 }
494 return capabilities.get(capability) as Service;521 return capabilities.get(capability) as Service;
495 },522 },
496 register: (slot, contribution) => {523 register: (slot, contribution) => {
497 assertActivating();524 assertActivating("register");
498 const values = contributions.get(slot) ?? [];525 const values = contributions.get(slot) ?? [];
499 values.push(contribution);526 values.push(contribution);
500 contributions.set(slot, values);527 contributions.set(slot, values);
501 },528 },
502 onDispose: (finalizer) => {529 onDispose: (finalizer) => {
503 assertActivating();530 assertActivating("onDispose");
504 finalizers.push(finalizer);531 finalizers.push(finalizer);
505 },532 },
506 };533 };
507 534
508 const activationExit = yield* Effect.exit(535 const activationExit = yield* Effect.exit(
509 invokePluginCallback("activate", definition.id, () => definition.activate(context)),536 invokePluginCallback(
537 "activate",
538 definition.id,
539 () => definition.activate(context),
540 () => {
541 activating = false;
542 },
543 ),
510 );544 );
511 activating = false;
512 for (const finalizer of finalizers) {545 for (const finalizer of finalizers) {
513 const finalizerEffect = invokePluginCallback("finalizer", definition.id, finalizer).pipe(546 const finalizerEffect = invokePluginCallback("finalizer", definition.id, finalizer).pipe(
514 Effect.catch((error) =>547 Effect.catch((error) =>
@@ -538,7 +571,10 @@ export const createEffectScopeRuntime: PluginRuntimeFactory = (options = {}): Pl · 23 unmodified lines
538 571
539 const plan = yield* Effect.try({572 const plan = yield* Effect.try({
540 try: () => planComposition(definitions),573 try: () => planComposition(definitions),
541 catch: (error) => error as PluginPlanningError,574 catch: (error) => {
575 if (!isPluginPlanningError(error)) throw error;
576 return error;
577 },
542 });578 });
543 const affected = affectedPluginIds(current.plugins, plan.definitions);579 const affected = affectedPluginIds(current.plugins, plan.definitions);
544 const currentById = new Map(current.plugins.map((plugin) => [plugin.definition.id, plugin]));580 const currentById = new Map(current.plugins.map((plugin) => [plugin.definition.id, plugin]));
packages/plugin-runtime-poc/src/runtimeContract.ts+29−0
@@ -588,6 +588,35 @@ export function defineRuntimeContract(name: string, createRuntime: PluginRuntime · 587 unmodified lines
588 await runtime.dispose();588 await runtime.dispose();
589 });589 });
590 590
591 it("rejects activation-context calls from microtasks queued by synchronous callbacks", async () => {
592 let lateFailure: unknown;
593 let disposed = false;
594 const runtime = createRuntime();
595
596 await runtime.reconcile([
597 {
598 id: "acme.microtask-context",
599 version: "1.0.0",
600 activate(context) {
601 queueMicrotask(() => {
602 try {
603 context.onDispose(() => {
604 disposed = true;
605 });
606 } catch (error) {
607 lateFailure = error;
608 }
609 });
610 },
611 },
612 ]);
613
614 expect(lateFailure).toBeInstanceOf(Error);
615 expect((lateFailure as Error).message).toContain("no longer active");
616 await runtime.dispose();
617 expect(disposed).toBe(false);
618 });
619
591 it("rejects finalizers registered after activation settles", async () => {620 it("rejects finalizers registered after activation settles", async () => {
592 let registerLateFinalizer!: () => void;621 let registerLateFinalizer!: () => void;
593 let disposed = false;622 let disposed = false;

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t