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
1414
1515 ## proved
1616
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 snapshots
17+- 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
1818 - manifest validation enforces semver, prevents entrypoint path escape, and keeps mobile declarative
1919 - the demo exercises activation, replacement, teardown, and a 250-plugin chain
2020
packages/plugin-runtime-poc/src/effectScopeRuntime.ts+49−13
@@ -86,6 +86,18 @@ class PluginResolutionError extends Schema.TaggedErrorClass<PluginResolutionErro · 85 unmodified lines
8686 }
8787 }
8888
89+class 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+
89101 class PluginCallbackError extends Schema.TaggedErrorClass<PluginCallbackError>()(
90102 "PluginCallbackError",
91103 {
@@ -148,6 +160,15 @@ class PluginRuntimeCleanupError extends Schema.TaggedErrorClass<PluginRuntimeCle · 56 unmodified lines
148160
149161 type PluginPlanningError = DuplicatePluginIdError | DuplicateCapabilityError | DependencyCycleError;
150162
163+const isDuplicatePluginIdError = Schema.is(DuplicatePluginIdError);
164+const isDuplicateCapabilityError = Schema.is(DuplicateCapabilityError);
165+const isDependencyCycleError = Schema.is(DependencyCycleError);
166+
167+const isPluginPlanningError = (error: unknown): error is PluginPlanningError =>
168+ isDuplicatePluginIdError(error) ||
169+ isDuplicateCapabilityError(error) ||
170+ isDependencyCycleError(error);
171+
151172 type PluginReconcileError =
152173 | PluginPlanningError
153174 | PluginCallbackError
@@ -446,21 +467,27 @@ export const createEffectScopeRuntime: PluginRuntimeFactory = (options = {}): Pl · 292 unmodified lines
446467 callback: PluginCallback,
447468 pluginId: string,
448469 invoke: () => Result | PromiseLike<Result>,
470+ onSettled?: () => void,
449471 ): Effect.Effect<Result, PluginCallbackError> =>
450472 Effect.tryPromise({
451473 try: async () => {
452474 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+ };
453482 try {
454483 const result = callbackContext.run(callbackState, invoke);
455484 if (typeof result === "object" && result !== null && "then" in result) {
456− return await Promise.resolve(result).finally(() => {
457− callbackState.active = false;
458− });
485+ return await Promise.resolve(result).finally(settle);
459486 }
460− callbackState.active = false;
487+ settle();
461488 return result;
462489 } catch (error) {
463− callbackState.active = false;
490+ settle();
464491 throw error;
465492 }
466493 },
@@ -479,36 +506,42 @@ export const createEffectScopeRuntime: PluginRuntimeFactory = (options = {}): Pl · 12 unmodified lines
479506 const finalizers: Array<() => void | Promise<void>> = [];
480507 const plugin: LivePlugin = { definition, scope, contributions, cleanupErrors };
481508 let activating = true;
482− const assertActivating = () => {
509+ const assertActivating = (method: "resolve" | "register" | "onDispose") => {
483510 if (!activating) {
484− throw new Error(`activation context for ${definition.id} is no longer active`);
511+ throw new PluginActivationContextExpiredError({ method, pluginId: definition.id });
485512 }
486513 };
487514
488515 const context: PluginActivationContext = {
489516 resolve: <Service>(capability: string): Service => {
490− assertActivating();
517+ assertActivating("resolve");
491518 if (!capabilities.has(capability)) {
492519 throw new PluginResolutionError({ capability, pluginId: definition.id });
493520 }
494521 return capabilities.get(capability) as Service;
495522 },
496523 register: (slot, contribution) => {
497− assertActivating();
524+ assertActivating("register");
498525 const values = contributions.get(slot) ?? [];
499526 values.push(contribution);
500527 contributions.set(slot, values);
501528 },
502529 onDispose: (finalizer) => {
503− assertActivating();
530+ assertActivating("onDispose");
504531 finalizers.push(finalizer);
505532 },
506533 };
507534
508535 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+ ),
510544 );
511− activating = false;
512545 for (const finalizer of finalizers) {
513546 const finalizerEffect = invokePluginCallback("finalizer", definition.id, finalizer).pipe(
514547 Effect.catch((error) =>
@@ -538,7 +571,10 @@ export const createEffectScopeRuntime: PluginRuntimeFactory = (options = {}): Pl · 23 unmodified lines
538571
539572 const plan = yield* Effect.try({
540573 try: () => planComposition(definitions),
541− catch: (error) => error as PluginPlanningError,
574+ catch: (error) => {
575+ if (!isPluginPlanningError(error)) throw error;
576+ return error;
577+ },
542578 });
543579 const affected = affectedPluginIds(current.plugins, plan.definitions);
544580 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
588588 await runtime.dispose();
589589 });
590590
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+
591620 it("rejects finalizers registered after activation settles", async () => {
592621 let registerLateFinalizer!: () => void;
593622 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