History

fix: tighten plugin callback boundaries

t3-code[bot] committed 2 months ago

Commit e3039b21 parent 7221a723 files · +139 −35Browse files

Unified · Split

3 files
  1. packages/plugin-runtime-poc/README.md +1 −1
  2. packages/plugin-runtime-poc/src/cordisRuntime.ts +53 −25
  3. packages/plugin-runtime-poc/src/runtimeContract.ts +85 −9
packages/plugin-runtime-poc/README.md+1−1
@@ -14,7 +14,7 @@ cordis satisfies the contract, but the adapter still needs a t3-specific planner · 13 unmodified lines
14 14
15## proved15## proved
16 16
17- 26 tests cover dependency order, blocking, rollback, cycles, unchanged fiber reuse, order-insensitive declarations, activation implementation replacement, active callback reentrancy, descendant async tasks, observer isolation, cleanup reporting, unusual ids and slots, and immutable snapshots17- 29 tests cover dependency order, blocking, rollback, cycles, unchanged fiber reuse, order-insensitive declarations, activation implementation replacement, active callback reentrancy, synchronous callback microtasks, 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/cordisRuntime.ts+53−25
@@ -332,9 +332,20 @@ export const createCordisRuntime: PluginRuntimeFactory = (options = {}): PluginR · 331 unmodified lines
332 ): Promise<Result> => {332 ): Promise<Result> => {
333 const callbackState = { active: true };333 const callbackState = { active: true };
334 try {334 try {
335 return await callbackContext.run(callbackState, () => Promise.resolve().then(invoke));335 const result = callbackContext.run(callbackState, invoke);
336 } finally {336 if (
337 (typeof result === "object" && result !== null && "then" in result) ||
338 typeof result === "function"
339 ) {
340 return await Promise.resolve(result).finally(() => {
341 callbackState.active = false;
342 });
343 }
344 callbackState.active = false;
345 return result;
346 } catch (error) {
337 callbackState.active = false;347 callbackState.active = false;
348 throw error;
338 }349 }
339 };350 };
340 351
@@ -372,13 +383,14 @@ export const createCordisRuntime: PluginRuntimeFactory = (options = {}): PluginR · 31 unmodified lines
372 }383 }
373 };384 };
374 385
375 const reconcile = (definitions: ReadonlyArray<PluginDefinition>) =>386 const reconcile = (definitions: ReadonlyArray<PluginDefinition>) => {
376 runExclusive(async () => {387 const desired = [...definitions];
388 return runExclusive(async () => {
377 if (disposed) throw new Error("plugin runtime is disposed");389 if (disposed) throw new Error("plugin runtime is disposed");
378 390
379 // Validation and cycle detection happen before any candidate fibers are created,391 // Validation and cycle detection happen before any candidate fibers are created,
380 // so an invalid graph cannot touch the currently active composition.392 // so an invalid graph cannot touch the currently active composition.
381 const { blocked, ordered, providerByCapability } = analyzeDefinitions(definitions);393 const { blocked, ordered, providerByCapability } = analyzeDefinitions(desired);
382 const affected = affectedPluginIds(composition.plugins, ordered);394 const affected = affectedPluginIds(composition.plugins, ordered);
383 const currentById = new Map(395 const currentById = new Map(
384 composition.plugins.map((plugin) => [plugin.definition.id, plugin]),396 composition.plugins.map((plugin) => [plugin.definition.id, plugin]),
@@ -419,6 +431,12 @@ export const createCordisRuntime: PluginRuntimeFactory = (options = {}): PluginR · 34 unmodified lines
419 431
420 const cordisPlugin: CordisPlugin = async (fiberContext) => {432 const cordisPlugin: CordisPlugin = async (fiberContext) => {
421 const finalizers: Array<() => void | Promise<void>> = [];433 const finalizers: Array<() => void | Promise<void>> = [];
434 let activating = true;
435 const assertActivating = () => {
436 if (!activating) {
437 throw new Error(`activation context for ${definition.id} is no longer active`);
438 }
439 };
422 fiberContext.effect(440 fiberContext.effect(
423 () => async () => {441 () => async () => {
424 for (const finalizer of finalizers.toReversed()) {442 for (const finalizer of finalizers.toReversed()) {
@@ -436,26 +454,35 @@ export const createCordisRuntime: PluginRuntimeFactory = (options = {}): PluginR · 11 unmodified lines
436 fiberContext.provide(capability, service);454 fiberContext.provide(capability, service);
437 }455 }
438 456
439 await invokePluginCallback(() =>457 try {
440 definition.activate({458 await invokePluginCallback(() =>
441 resolve: <Service>(capability: string) => fiberContext.get(capability) as Service,459 definition.activate({
442 register(slot, contribution) {460 resolve: <Service>(capability: string) => {
443 fiberContext.effect(() => {461 assertActivating();
444 const items = contributions.get(slot) ?? [];462 return fiberContext.get(capability) as Service;
445 items.push(contribution);463 },
446 contributions.set(slot, items);464 register(slot, contribution) {
447 return () => {465 assertActivating();
448 const index = items.indexOf(contribution);466 fiberContext.effect(() => {
449 if (index >= 0) items.splice(index, 1);467 const items = contributions.get(slot) ?? [];
450 if (!items.length) contributions.delete(slot);468 items.push(contribution);
451 };469 contributions.set(slot, items);
452 }, `${definition.id}:contribution:${slot}`);470 return () => {
453 },471 const index = items.indexOf(contribution);
454 onDispose(finalizer) {472 if (index >= 0) items.splice(index, 1);
455 finalizers.push(finalizer);473 if (!items.length) contributions.delete(slot);
456 },474 };
457 }),475 }, `${definition.id}:contribution:${slot}`);
458 );476 },
477 onDispose(finalizer) {
478 assertActivating();
479 finalizers.push(finalizer);
480 },
481 }),
482 );
483 } finally {
484 activating = false;
485 }
459 };486 };
460 cordisPlugin.inject = [...(definition.requires ?? [])];487 cordisPlugin.inject = [...(definition.requires ?? [])];
461 cordisPlugin.provide = Object.keys(definition.provides ?? {});488 cordisPlugin.provide = Object.keys(definition.provides ?? {});
@@ -502,6 +529,7 @@ export const createCordisRuntime: PluginRuntimeFactory = (options = {}): PluginR · 40 unmodified lines
502 reportCleanupErrors("retire", cleanupErrors);529 reportCleanupErrors("retire", cleanupErrors);
503 return candidate.snapshot;530 return candidate.snapshot;
504 });531 });
532 };
505 533
506 return {534 return {
507 reconcile,535 reconcile,
packages/plugin-runtime-poc/src/runtimeContract.ts+85−9
@@ -7,10 +7,20 @@ import type { PluginDefinition, PluginRuntimeFactory, PluginRuntimeSnapshot } fr · 6 unmodified lines
7const contributionLabels = (snapshot: PluginRuntimeSnapshot, slot: string) =>7const contributionLabels = (snapshot: PluginRuntimeSnapshot, slot: string) =>
8 snapshot.contributions[slot]?.map((item) => item.label) ?? [];8 snapshot.contributions[slot]?.map((item) => item.label) ?? [];
9 9
10const operationTimeout = () =>10const withOperationTimeout = async <Result>(operation: Promise<Result>): Promise<Result> => {
11 NodeTimersPromises.setTimeout(100).then(() => {11 const controller = new AbortController();
12 const timeout = NodeTimersPromises.setTimeout(100, undefined, {
13 ref: false,
14 signal: controller.signal,
15 }).then(() => {
12 throw new Error("operation timed out");16 throw new Error("operation timed out");
13 });17 });
18 try {
19 return await Promise.race([operation, timeout]);
20 } finally {
21 controller.abort();
22 }
23};
14 24
15const failureCause = (error: unknown): unknown =>25const failureCause = (error: unknown): unknown =>
16 typeof error === "object" && error !== null && "cause" in error26 typeof error === "object" && error !== null && "cause" in error
@@ -482,9 +492,7 @@ export function defineRuntimeContract(name: string, createRuntime: PluginRuntime · 465 unmodified lines
482 await runtime.reconcile([]);492 await runtime.reconcile([]);
483 },493 },
484 };494 };
485 const timeout = operationTimeout();495 await expect(withOperationTimeout(runtime.reconcile([reentrant]))).rejects.toThrow(
486
487 await expect(Promise.race([runtime.reconcile([reentrant]), timeout])).rejects.toThrow(
488 "reentrant",496 "reentrant",
489 );497 );
490 });498 });
@@ -507,9 +515,7 @@ export function defineRuntimeContract(name: string, createRuntime: PluginRuntime · 16 unmodified lines
507 },515 },
508 },516 },
509 ]);517 ]);
510 const timeout = operationTimeout();518 await expect(withOperationTimeout(runtime.reconcile([]))).resolves.toBeDefined();
511
512 await expect(Promise.race([runtime.reconcile([]), timeout])).resolves.toBeDefined();
513 expect(nestedFailure).toBeInstanceOf(Error);519 expect(nestedFailure).toBeInstanceOf(Error);
514 expect((nestedFailure as Error).message).toContain("reentrant");520 expect((nestedFailure as Error).message).toContain("reentrant");
515 await runtime.dispose();521 await runtime.dispose();
@@ -534,7 +540,77 @@ export function defineRuntimeContract(name: string, createRuntime: PluginRuntime · 18 unmodified lines
534 ]);540 ]);
535 541
536 releaseBackground();542 releaseBackground();
537 await expect(Promise.race([backgroundResult!, operationTimeout()])).resolves.toBeDefined();543 await expect(withOperationTimeout(backgroundResult!)).resolves.toBeDefined();
544 await runtime.dispose();
545 });
546
547 it("allows microtasks spawned by synchronous plugin callbacks to use the runtime", async () => {
548 let backgroundResult: Promise<PluginRuntimeSnapshot> | undefined;
549 const runtime = createRuntime();
550
551 await runtime.reconcile([
552 {
553 id: "acme.microtask",
554 version: "1.0.0",
555 activate() {
556 queueMicrotask(() => {
557 backgroundResult = runtime.reconcile([]);
558 });
559 },
560 },
561 ]);
562
563 expect(backgroundResult).toBeDefined();
564 await expect(withOperationTimeout(backgroundResult!)).resolves.toBeDefined();
565 await runtime.dispose();
566 });
567
568 it("rejects finalizers registered after activation settles", async () => {
569 let registerLateFinalizer!: () => void;
570 let disposed = false;
571 const runtime = createRuntime();
572 await runtime.reconcile([
573 {
574 id: "acme.late-finalizer",
575 version: "1.0.0",
576 activate(context) {
577 registerLateFinalizer = () =>
578 context.onDispose(() => {
579 disposed = true;
580 });
581 },
582 },
583 ]);
584
585 expect(registerLateFinalizer).toThrow("no longer active");
586 await runtime.dispose();
587 expect(disposed).toBe(false);
588 });
589
590 it("snapshots the requested definitions before waiting for an earlier transition", async () => {
591 let releaseActivation!: () => void;
592 const activationGate = new Promise<void>((resolve) => {
593 releaseActivation = resolve;
594 });
595 const runtime = createRuntime();
596 const first = runtime.reconcile([
597 {
598 id: "acme.blocking",
599 version: "1.0.0",
600 async activate() {
601 await activationGate;
602 },
603 },
604 ]);
605 const requested: Array<PluginDefinition> = [provider()];
606 const second = runtime.reconcile(requested);
607 requested.splice(0);
608
609 releaseActivation();
610 await first;
611 const snapshot = await second;
612
613 expect(snapshot.active).toEqual(["acme.database"]);
538 await runtime.dispose();614 await runtime.dispose();
539 });615 });
540 616