History

spike: isolate pure plugin reconciler

t3-code[bot] committed 2 months ago

Commit a065b7b1 parent bcfd48511 files · +1027 −0Browse files

Unified · Split

11 files
  1. packages/plugin-runtime-poc/README.md +29 −0
  2. packages/plugin-runtime-poc/package.json +17 −0
  3. packages/plugin-runtime-poc/src/contract.ts +41 −0
  4. packages/plugin-runtime-poc/src/demo.ts +77 −0
  5. packages/plugin-runtime-poc/src/manifest.test.ts +63 −0
  6. packages/plugin-runtime-poc/src/manifest.ts +42 −0
  7. packages/plugin-runtime-poc/src/pureRuntime.test.ts +29 −0
  8. packages/plugin-runtime-poc/src/pureRuntime.ts +378 −0
  9. packages/plugin-runtime-poc/src/runtimeContract.ts +331 −0
  10. packages/plugin-runtime-poc/tsconfig.json +7 −0
  11. pnpm-lock.yaml +13 −0
packages/plugin-runtime-poc/README.md+29−0
@@ -0,0 +1,29 @@
1# pure plugin reconciler poc
2
3this branch isolates the framework-free implementation from the three-way plugin runtime experiment.
4
5## shape
6
7- an iterative capability graph plans activation and targeted restarts
8- plugin instances own explicit lifo finalizer stacks
9- contributions are staged per plugin and published as frozen snapshots
10- failed candidates are cleaned up without replacing the live composition
11- cleanup attempts every dependent-first instance and aggregates failures
12
13this is the smallest and fastest planner in the experiment. for production, its graph algorithm should drive effect scopes rather than keeping manual finalizer ownership.
14
15## proved
16
17- 18 tests cover dependency order, blocking, rollback, cycles, unchanged instance reuse, provider-value replacement, unusual ids and slots, cleanup failures, immutable snapshots, and a 20,000-plugin stack-safe chain
18- manifest validation keeps mobile declarative
19- the demo exercises activation, replacement, teardown, and a 250-plugin chain
20
21## run
22
23```sh
24vp test run packages/plugin-runtime-poc/src
25vp run --filter @t3tools/plugin-runtime-pure-poc typecheck
26vp run --filter @t3tools/plugin-runtime-pure-poc demo
27```
28
29this is an isolated runtime poc. it does not load packages, add a sandbox, expose plugin rpc, or wire product ui.
packages/plugin-runtime-poc/package.json+17−0
@@ -0,0 +1,17 @@
1{
2 "name": "@t3tools/plugin-runtime-pure-poc",
3 "private": true,
4 "type": "module",
5 "scripts": {
6 "test": "vp test run",
7 "typecheck": "tsgo --noEmit",
8 "demo": "node src/demo.ts"
9 },
10 "dependencies": {
11 "effect": "4.0.0-beta.103"
12 },
13 "devDependencies": {
14 "@types/node": "24.12.4",
15 "vite-plus": "catalog:"
16 }
17}
packages/plugin-runtime-poc/src/contract.ts+41−0
@@ -0,0 +1,41 @@
1export interface Contribution {
2 readonly id: string;
3 readonly label: string;
4}
5
6export interface PluginDefinition {
7 readonly id: string;
8 readonly version: string;
9 readonly requires?: ReadonlyArray<string>;
10 readonly provides?: Readonly<Record<string, unknown>>;
11 readonly activate: (context: PluginActivationContext) => void | Promise<void>;
12}
13
14export interface PluginActivationContext {
15 readonly resolve: <Service>(capability: string) => Service;
16 readonly register: (slot: string, contribution: Contribution) => void;
17 readonly onDispose: (finalizer: () => void | Promise<void>) => void;
18}
19
20export interface PluginRuntimeSnapshot {
21 readonly active: ReadonlyArray<string>;
22 readonly blocked: Readonly<Record<string, string>>;
23 readonly contributions: Readonly<Record<string, ReadonlyArray<Contribution>>>;
24}
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 {
35 readonly onLifecycle?: (event: {
36 readonly phase: "activate" | "deactivate";
37 readonly pluginId: string;
38 }) => void;
39}
40
41export type PluginRuntimeFactory = (options?: PluginRuntimeOptions) => PluginRuntime;
packages/plugin-runtime-poc/src/demo.ts+77−0
@@ -0,0 +1,77 @@
1import type { PluginDefinition, PluginRuntimeSnapshot } from "./contract.ts";
2import { createPureRuntime } from "./pureRuntime.ts";
3
4const commandLabels = (snapshot: PluginRuntimeSnapshot) =>
5 snapshot.contributions.commands?.map((command) => command.label) ?? [];
6
7const database = (version: string): PluginDefinition => ({
8 id: "demo.database",
9 version,
10 provides: { "demo.database@1": { version } },
11 activate(context) {
12 context.register("status", { id: "database", label: `database ${version}` });
13 },
14});
15
16const issues: PluginDefinition = {
17 id: "demo.issues",
18 version: "1.0.0",
19 requires: ["demo.database@1"],
20 activate(context) {
21 const service = context.resolve<{ readonly version: string }>("demo.database@1");
22 context.register("commands", {
23 id: "create-issue",
24 label: `create issue with database ${service.version}`,
25 });
26 },
27};
28
29const chain = (length: number): ReadonlyArray<PluginDefinition> =>
30 Array.from({ length }, (_, index) => ({
31 id: `benchmark.plugin.${index}`,
32 version: "1.0.0",
33 ...(index === 0 ? {} : { requires: [`benchmark.capability.${index - 1}`] }),
34 provides: { [`benchmark.capability.${index}`]: index },
35 activate() {},
36 }));
37
38const lifecycle: Array<string> = [];
39const runtime = createPureRuntime({
40 onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`),
41});
42const initial = await runtime.reconcile([issues, database("1.0.0")]);
43const upgraded = await runtime.reconcile([issues, database("2.0.0")]);
44await runtime.reconcile([]);
45
46const benchmark = createPureRuntime();
47const benchmarkDefinitions = chain(250);
48const startedAt = process.hrtime.bigint();
49await benchmark.reconcile(benchmarkDefinitions);
50const activationMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000;
51const noopStartedAt = process.hrtime.bigint();
52await benchmark.reconcile(benchmarkDefinitions);
53const noopMs = Number(process.hrtime.bigint() - noopStartedAt) / 1_000_000;
54const upgradedDefinitions = benchmarkDefinitions.map((definition, index) =>
55 index === 0 ? { ...definition, version: "2.0.0" } : definition,
56);
57const restartStartedAt = process.hrtime.bigint();
58await benchmark.reconcile(upgradedDefinitions);
59const restartMs = Number(process.hrtime.bigint() - restartStartedAt) / 1_000_000;
60await benchmark.dispose();
61
62process.stdout.write(
63 `${JSON.stringify(
64 {
65 variant: "pure-reconciler",
66 initialCommands: commandLabels(initial),
67 upgradedCommands: commandLabels(upgraded),
68 lifecycle,
69 chain250ActivationMs: Math.round(activationMs * 100) / 100,
70 chain250NoopMs: Math.round(noopMs * 100) / 100,
71 chain250RootRestartMs: Math.round(restartMs * 100) / 100,
72 },
73 null,
74 2,
75 )}\n`,
76);
77await runtime.dispose();
packages/plugin-runtime-poc/src/manifest.test.ts+63−0
@@ -0,0 +1,63 @@
1import * as Schema from "effect/Schema";
2import { describe, expect, it } from "vite-plus/test";
3
4import { PluginManifest } from "./manifest.ts";
5
6const decodeManifest = Schema.decodeUnknownSync(PluginManifest);
7
8const validManifest = {
9 id: "com.acme.linear",
10 version: "1.2.0",
11 engines: { t3: "^0.1.0" },
12 entrypoints: {
13 server: "./dist/server.js",
14 web: "./dist/web.js",
15 },
16 requires: ["t3.commands@1", "t3.secrets@1"],
17 provides: ["com.acme.linear@1"],
18 permissions: ["network:https://api.linear.app", "secrets:linear-token"],
19 contributes: {
20 commands: ["linear.create-issue"],
21 settings: ["linear.settings"],
22 views: ["thread.right-panel"],
23 },
24};
25
26describe("PluginManifest", () => {
27 it("decodes a namespaced multi-surface plugin manifest", () => {
28 expect(decodeManifest(validManifest)).toEqual(validManifest);
29 });
30
31 it("rejects unnamespaced plugin and contribution ids", () => {
32 expect(() => decodeManifest({ ...validManifest, id: "linear" })).toThrow();
33 expect(() =>
34 decodeManifest({
35 ...validManifest,
36 contributes: { ...validManifest.contributes, commands: ["create-issue"] },
37 }),
38 ).toThrow();
39 });
40
41 it("rejects malformed versions and capability ids", () => {
42 expect(() => decodeManifest({ ...validManifest, version: "next" })).toThrow();
43 expect(() => decodeManifest({ ...validManifest, requires: ["t3.commands"] })).toThrow();
44 });
45
46 it("keeps mobile declarative by excluding a mobile executable entrypoint", () => {
47 const decoded = decodeManifest({
48 ...validManifest,
49 surfaces: ["web", "desktop", "mobile"],
50 contributes: { ...validManifest.contributes, mobileCards: ["linear.summary"] },
51 });
52
53 expect(decoded.surfaces).toEqual(["web", "desktop", "mobile"]);
54 expect(decoded.contributes.mobileCards).toEqual(["linear.summary"]);
55 expect(decoded.entrypoints).not.toHaveProperty("mobile");
56 expect(() =>
57 decodeManifest({
58 ...validManifest,
59 entrypoints: { ...validManifest.entrypoints, mobile: "./dist/mobile.js" },
60 }),
61 ).toThrow();
62 });
63});
packages/plugin-runtime-poc/src/manifest.ts+42−0
@@ -0,0 +1,42 @@
1import * as Schema from "effect/Schema";
2
3const NamespacedId = Schema.String.check(
4 Schema.isPattern(/^[a-z0-9][a-z0-9-]*(?:\.[a-z0-9][a-z0-9-]*)+$/),
5);
6
7const SemanticVersion = Schema.String.check(
8 Schema.isPattern(/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/),
9);
10
11const CapabilityId = Schema.String.check(Schema.isPattern(/^[a-z0-9][a-z0-9.-]*@[1-9]\d*$/));
12
13const RelativeEntrypoint = Schema.String.check(Schema.isPattern(/^\.\/[A-Za-z0-9_./-]+$/));
14const Permission = Schema.String.check(Schema.isPattern(/^[a-z][a-z-]*:.+$/));
15
16const ContributionCatalog = Schema.Struct({
17 commands: Schema.optional(Schema.Array(NamespacedId)),
18 settings: Schema.optional(Schema.Array(NamespacedId)),
19 views: Schema.optional(Schema.Array(NamespacedId)),
20 mobileCards: Schema.optional(Schema.Array(NamespacedId)),
21});
22
23export const PluginManifest = Schema.Struct({
24 id: NamespacedId,
25 version: SemanticVersion,
26 engines: Schema.Struct({
27 t3: Schema.String.check(Schema.isNonEmpty()),
28 }),
29 surfaces: Schema.optional(Schema.Array(Schema.Literals(["web", "desktop", "mobile"]))),
30 entrypoints: Schema.Struct({
31 server: Schema.optional(RelativeEntrypoint),
32 web: Schema.optional(RelativeEntrypoint),
33 desktop: Schema.optional(RelativeEntrypoint),
34 }).annotate({ parseOptions: { onExcessProperty: "error" } }),
35 requires: Schema.optional(Schema.Array(CapabilityId)),
36 optional: Schema.optional(Schema.Array(CapabilityId)),
37 provides: Schema.optional(Schema.Array(CapabilityId)),
38 permissions: Schema.optional(Schema.Array(Permission)),
39 contributes: ContributionCatalog,
40});
41
42export type PluginManifest = typeof PluginManifest.Type;
packages/plugin-runtime-poc/src/pureRuntime.test.ts+29−0
@@ -0,0 +1,29 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import type { PluginDefinition } from "./contract.ts";
4import { createPureRuntime } from "./pureRuntime.ts";
5import { defineRuntimeContract } from "./runtimeContract.ts";
6
7defineRuntimeContract("pure reconciliation runtime", createPureRuntime);
8
9describe("pure reconciliation planner", () => {
10 it("plans a deep acyclic dependency chain without using the call stack", async () => {
11 const runtime = createPureRuntime();
12 const pluginCount = 20_000;
13 const definitions: Array<PluginDefinition> = Array.from(
14 { length: pluginCount },
15 (_, index) => ({
16 id: `plugin-${index}`,
17 version: "1.0.0",
18 ...(index === 0 ? {} : { requires: [`capability-${index - 1}`] }),
19 provides: { [`capability-${index}`]: index },
20 activate() {},
21 }),
22 );
23
24 const snapshot = await runtime.reconcile(definitions.toReversed());
25
26 expect(snapshot.active).toHaveLength(pluginCount);
27 await runtime.dispose();
28 });
29});
packages/plugin-runtime-poc/src/pureRuntime.ts+378−0
@@ -0,0 +1,378 @@
1import type {
2 Contribution,
3 PluginDefinition,
4 PluginRuntime,
5 PluginRuntimeFactory,
6 PluginRuntimeSnapshot,
7} from "./contract.ts";
8
9type Finalizer = () => void | Promise<void>;
10
11interface PluginInstance {
12 readonly definition: PluginDefinition;
13 readonly contributions: Map<string, Array<Contribution>>;
14 readonly finalizers: Array<Finalizer>;
15 activated: boolean;
16}
17
18interface Composition {
19 readonly instances: ReadonlyArray<PluginInstance>;
20 readonly snapshot: PluginRuntimeSnapshot;
21}
22
23interface CompositionPlan {
24 readonly active: ReadonlyArray<PluginDefinition>;
25 readonly blocked: Readonly<Record<string, string>>;
26 readonly providers: ReadonlyMap<string, PluginDefinition>;
27}
28
29const createSnapshot = (
30 instances: ReadonlyArray<PluginInstance>,
31 blocked: Readonly<Record<string, string>>,
32): PluginRuntimeSnapshot => {
33 const contributions: Record<string, Array<Contribution>> = Object.create(null);
34
35 for (const instance of instances) {
36 for (const [slot, registrations] of instance.contributions) {
37 const items = Object.hasOwn(contributions, slot) ? contributions[slot] : [];
38 if (items === undefined) continue;
39 items.push(...registrations);
40 contributions[slot] = items;
41 }
42 }
43
44 const frozenContributions: Record<string, ReadonlyArray<Contribution>> = Object.create(null);
45 for (const [slot, registrations] of Object.entries(contributions)) {
46 frozenContributions[slot] = Object.freeze([...registrations]);
47 }
48
49 const frozenBlocked: Record<string, string> = Object.assign(Object.create(null), blocked);
50
51 return Object.freeze({
52 active: Object.freeze(instances.map(({ definition }) => definition.id)),
53 blocked: Object.freeze(frozenBlocked),
54 contributions: Object.freeze(frozenContributions),
55 });
56};
57
58const emptyComposition = (): Composition => ({
59 instances: [],
60 snapshot: createSnapshot([], {}),
61});
62
63const planComposition = (definitions: ReadonlyArray<PluginDefinition>): CompositionPlan => {
64 const byId = new Map<string, PluginDefinition>();
65 const providers = new Map<string, PluginDefinition>();
66
67 for (const definition of definitions) {
68 if (byId.has(definition.id)) {
69 throw new Error(`duplicate plugin id: ${definition.id}`);
70 }
71 byId.set(definition.id, definition);
72
73 for (const capability of Object.keys(definition.provides ?? {})) {
74 const previous = providers.get(capability);
75 if (previous !== undefined) {
76 throw new Error(`duplicate provider for ${capability}: ${previous.id}, ${definition.id}`);
77 }
78 providers.set(capability, definition);
79 }
80 }
81
82 interface VisitFrame {
83 readonly definition: PluginDefinition;
84 requirementIndex: number;
85 reason: string | undefined;
86 }
87
88 const states = new Map<string, "visiting" | "visited">();
89 const path: Array<string> = [];
90 const pathIndexes = new Map<string, number>();
91 const active: Array<PluginDefinition> = [];
92 const blocked: Record<string, string> = Object.create(null);
93
94 for (const root of definitions) {
95 if (states.has(root.id)) continue;
96
97 const stack: Array<VisitFrame> = [{ definition: root, requirementIndex: 0, reason: undefined }];
98 states.set(root.id, "visiting");
99 pathIndexes.set(root.id, path.length);
100 path.push(root.id);
101
102 while (stack.length > 0) {
103 const frame = stack.at(-1);
104 if (frame === undefined) break;
105 const requirements = frame.definition.requires ?? [];
106 const capability = requirements[frame.requirementIndex];
107
108 if (capability !== undefined) {
109 const provider = providers.get(capability);
110 if (provider === undefined) {
111 frame.reason ??= `missing dependency: ${capability}`;
112 frame.requirementIndex += 1;
113 continue;
114 }
115
116 const state = states.get(provider.id);
117 if (state === "visiting") {
118 const cycleStart = pathIndexes.get(provider.id);
119 if (cycleStart === undefined) throw new Error(`missing dependency path: ${provider.id}`);
120 const cycle = [...path.slice(cycleStart), provider.id];
121 throw new Error(`dependency cycle detected: ${cycle.join(" -> ")}`);
122 }
123 if (state === undefined) {
124 stack.push({ definition: provider, requirementIndex: 0, reason: undefined });
125 states.set(provider.id, "visiting");
126 pathIndexes.set(provider.id, path.length);
127 path.push(provider.id);
128 continue;
129 }
130
131 if (Object.hasOwn(blocked, provider.id)) {
132 frame.reason ??= `dependency unavailable: ${capability}`;
133 }
134 frame.requirementIndex += 1;
135 continue;
136 }
137
138 stack.pop();
139 path.pop();
140 pathIndexes.delete(frame.definition.id);
141 states.set(frame.definition.id, "visited");
142 if (frame.reason === undefined) {
143 active.push(frame.definition);
144 } else {
145 blocked[frame.definition.id] = frame.reason;
146 }
147 }
148 }
149 return { active, blocked, providers };
150};
151
152const sameDefinitionShape = (previous: PluginDefinition, desired: PluginDefinition) => {
153 const previousRequires = previous.requires ?? [];
154 const desiredRequires = desired.requires ?? [];
155 const previousProvidedServices = previous.provides ?? {};
156 const desiredProvidedServices = desired.provides ?? {};
157 const previousProvides = Object.keys(previousProvidedServices);
158 const desiredProvides = Object.keys(desiredProvidedServices);
159
160 return (
161 previous.version === desired.version &&
162 previousRequires.length === desiredRequires.length &&
163 previousRequires.every((capability, index) => capability === desiredRequires[index]) &&
164 previousProvides.length === desiredProvides.length &&
165 previousProvides.every(
166 (capability, index) =>
167 capability === desiredProvides[index] &&
168 Object.is(previousProvidedServices[capability], desiredProvidedServices[capability]),
169 )
170 );
171};
172
173const messageOf = (error: unknown) =>
174 error instanceof Error ? error.message : "plugin activation failed";
175
176export const createPureRuntime: PluginRuntimeFactory = (options = {}): PluginRuntime => {
177 let current = emptyComposition();
178 let disposed = false;
179 let transition = Promise.resolve();
180
181 const deactivate = async (instance: PluginInstance): Promise<Array<unknown>> => {
182 const errors: Array<unknown> = [];
183
184 if (instance.activated) {
185 try {
186 options.onLifecycle?.({
187 phase: "deactivate",
188 pluginId: instance.definition.id,
189 });
190 } catch (error) {
191 errors.push(error);
192 }
193 }
194
195 for (let index = instance.finalizers.length - 1; index >= 0; index -= 1) {
196 const finalizer = instance.finalizers[index];
197 if (finalizer === undefined) continue;
198 try {
199 await finalizer();
200 } catch (error) {
201 errors.push(error);
202 }
203 }
204
205 return errors;
206 };
207
208 const deactivateAll = async (
209 instances: ReadonlyArray<PluginInstance>,
210 ): Promise<Array<unknown>> => {
211 const errors: Array<unknown> = [];
212 for (let index = instances.length - 1; index >= 0; index -= 1) {
213 const instance = instances[index];
214 if (instance !== undefined) errors.push(...(await deactivate(instance)));
215 }
216 return errors;
217 };
218
219 const stage = async (
220 definitions: ReadonlyArray<PluginDefinition>,
221 previous: Composition,
222 ): Promise<Composition> => {
223 const plan = planComposition(definitions);
224 const previousById = new Map(
225 previous.instances.map((instance) => [instance.definition.id, instance]),
226 );
227 const restart = new Set<string>();
228
229 for (const definition of plan.active) {
230 const existing = previousById.get(definition.id);
231 if (existing === undefined || !sameDefinitionShape(existing.definition, definition)) {
232 restart.add(definition.id);
233 }
234 }
235
236 for (const definition of plan.active) {
237 if (
238 (definition.requires ?? []).some((capability) => {
239 const provider = plan.providers.get(capability);
240 return provider !== undefined && restart.has(provider.id);
241 })
242 ) {
243 restart.add(definition.id);
244 }
245 }
246
247 const services = new Map<string, unknown>();
248 const staged: Array<PluginInstance> = [];
249 const stagedById = new Map<string, PluginInstance>();
250
251 for (const definition of plan.active) {
252 if (restart.has(definition.id)) continue;
253 const instance = previousById.get(definition.id);
254 if (instance === undefined) {
255 throw new Error(`missing reusable plugin instance: ${definition.id}`);
256 }
257 for (const [capability, service] of Object.entries(instance.definition.provides ?? {})) {
258 services.set(capability, service);
259 }
260 }
261
262 try {
263 for (const definition of plan.active) {
264 if (!restart.has(definition.id)) continue;
265
266 const instance: PluginInstance = {
267 definition,
268 contributions: new Map(),
269 finalizers: [],
270 activated: false,
271 };
272 staged.push(instance);
273 stagedById.set(definition.id, instance);
274
275 let activating = true;
276 const assertActivating = () => {
277 if (!activating) {
278 throw new Error(`activation context for ${definition.id} is no longer active`);
279 }
280 };
281
282 try {
283 await definition.activate({
284 resolve: <Service>(capability: string): Service => {
285 assertActivating();
286 if (!services.has(capability)) {
287 throw new Error(`plugin ${definition.id} could not resolve ${capability}`);
288 }
289 return services.get(capability) as Service;
290 },
291 register: (slot, contribution) => {
292 assertActivating();
293 const items = instance.contributions.get(slot) ?? [];
294 items.push(Object.freeze({ id: contribution.id, label: contribution.label }));
295 instance.contributions.set(slot, items);
296 },
297 onDispose: (finalizer) => {
298 assertActivating();
299 instance.finalizers.push(finalizer);
300 },
301 });
302 } finally {
303 activating = false;
304 }
305
306 instance.activated = true;
307 options.onLifecycle?.({ phase: "activate", pluginId: definition.id });
308 for (const [capability, service] of Object.entries(definition.provides ?? {})) {
309 services.set(capability, service);
310 }
311 }
312
313 const instances = plan.active.map((definition) => {
314 const instance = stagedById.get(definition.id) ?? previousById.get(definition.id);
315 if (instance === undefined) {
316 throw new Error(`missing plugin instance after staging: ${definition.id}`);
317 }
318 return instance;
319 });
320 return {
321 instances,
322 snapshot: createSnapshot(instances, plan.blocked),
323 };
324 } catch (activationError) {
325 const cleanupErrors = await deactivateAll(staged);
326 if (cleanupErrors.length > 0) {
327 throw new Error(
328 `${messageOf(activationError)}; ${cleanupErrors.length} rollback finalizer(s) also failed`,
329 { cause: activationError },
330 );
331 }
332 throw activationError;
333 }
334 };
335
336 const runExclusive = <Result>(operation: () => Promise<Result>): Promise<Result> => {
337 const result = transition.then(operation);
338 transition = result.then(
339 () => undefined,
340 () => undefined,
341 );
342 return result;
343 };
344
345 return {
346 reconcile: (definitions) => {
347 const desired = [...definitions];
348 return runExclusive(async () => {
349 if (disposed) throw new Error("plugin runtime is disposed");
350
351 const previous = current;
352 const candidate = await stage(desired, previous);
353 current = candidate;
354
355 const retained = new Set(candidate.instances);
356 const retired = previous.instances.filter((instance) => !retained.has(instance));
357 const cleanupErrors = await deactivateAll(retired);
358 if (cleanupErrors.length > 0) {
359 throw new AggregateError(cleanupErrors, "previous composition deactivation failed");
360 }
361 return candidate.snapshot;
362 });
363 },
364 snapshot: () => current.snapshot,
365 dispose: () =>
366 runExclusive(async () => {
367 if (disposed) return;
368 disposed = true;
369
370 const previous = current;
371 current = emptyComposition();
372 const cleanupErrors = await deactivateAll(previous.instances);
373 if (cleanupErrors.length > 0) {
374 throw new AggregateError(cleanupErrors, "plugin runtime disposal failed");
375 }
376 }),
377 };
378};
packages/plugin-runtime-poc/src/runtimeContract.ts+331−0
@@ -0,0 +1,331 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import type { PluginDefinition, PluginRuntimeFactory, PluginRuntimeSnapshot } from "./contract.ts";
4
5const contributionLabels = (snapshot: PluginRuntimeSnapshot, slot: string) =>
6 snapshot.contributions[slot]?.map((item) => item.label) ?? [];
7
8const provider = (version = "1.0.0"): PluginDefinition => ({
9 id: "acme.database",
10 version,
11 provides: { "acme.database@1": { name: `database-${version}` } },
12 activate(context) {
13 context.register("status", { id: "database", label: `database ${version}` });
14 },
15});
16
17const consumer = (): PluginDefinition => ({
18 id: "acme.issues",
19 version: "1.0.0",
20 requires: ["acme.database@1"],
21 activate(context) {
22 const database = context.resolve<{ readonly name: string }>("acme.database@1");
23 context.register("commands", { id: "create-issue", label: database.name });
24 },
25});
26
27export function defineRuntimeContract(name: string, createRuntime: PluginRuntimeFactory) {
28 describe(name, () => {
29 it("activates providers before consumers regardless of manifest order", async () => {
30 const runtime = createRuntime();
31
32 const snapshot = await runtime.reconcile([consumer(), provider()]);
33
34 expect(snapshot.active).toEqual(["acme.database", "acme.issues"]);
35 expect(contributionLabels(snapshot, "commands")).toEqual(["database-1.0.0"]);
36 await runtime.dispose();
37 });
38
39 it("blocks missing dependencies without blocking independent plugins", async () => {
40 const runtime = createRuntime();
41 const independent: PluginDefinition = {
42 id: "acme.clock",
43 version: "1.0.0",
44 activate(context) {
45 context.register("status", { id: "clock", label: "clock" });
46 },
47 };
48
49 const snapshot = await runtime.reconcile([consumer(), independent]);
50
51 expect(snapshot.active).toEqual(["acme.clock"]);
52 expect(snapshot.blocked["acme.issues"]).toContain("acme.database@1");
53 expect(contributionLabels(snapshot, "status")).toEqual(["clock"]);
54 await runtime.dispose();
55 });
56
57 it("deactivates dependents before providers", async () => {
58 const lifecycle: Array<string> = [];
59 const runtime = createRuntime({
60 onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`),
61 });
62 await runtime.reconcile([provider(), consumer()]);
63 lifecycle.length = 0;
64
65 await runtime.reconcile([]);
66
67 expect(lifecycle).toEqual(["deactivate:acme.issues", "deactivate:acme.database"]);
68 await runtime.dispose();
69 });
70
71 it("runs plugin finalizers in reverse registration order", async () => {
72 const disposed: Array<string> = [];
73 const runtime = createRuntime();
74 await runtime.reconcile([
75 {
76 id: "acme.finalizers",
77 version: "1.0.0",
78 activate(context) {
79 context.onDispose(() => {
80 disposed.push("first");
81 });
82 context.onDispose(() => {
83 disposed.push("second");
84 });
85 },
86 },
87 ]);
88
89 await runtime.reconcile([]);
90
91 expect(disposed).toEqual(["second", "first"]);
92 await runtime.dispose();
93 });
94
95 it("keeps unchanged plugin scopes alive across reconciliation", async () => {
96 let activations = 0;
97 let disposals = 0;
98 const stable: PluginDefinition = {
99 id: "acme.stable",
100 version: "1.0.0",
101 activate(context) {
102 activations += 1;
103 context.onDispose(() => {
104 disposals += 1;
105 });
106 },
107 };
108 const runtime = createRuntime();
109
110 await runtime.reconcile([stable]);
111 await runtime.reconcile([stable]);
112
113 expect(activations).toBe(1);
114 expect(disposals).toBe(0);
115 await runtime.dispose();
116 expect(disposals).toBe(1);
117 });
118
119 it("restarts a changed provider and its dependents without touching independent plugins", async () => {
120 let independentActivations = 0;
121 const lifecycle: Array<string> = [];
122 const independent: PluginDefinition = {
123 id: "acme.independent",
124 version: "1.0.0",
125 activate() {
126 independentActivations += 1;
127 },
128 };
129 const runtime = createRuntime({
130 onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`),
131 });
132 await runtime.reconcile([consumer(), independent, provider()]);
133 lifecycle.length = 0;
134
135 const snapshot = await runtime.reconcile([consumer(), independent, provider("2.0.0")]);
136
137 expect(independentActivations).toBe(1);
138 expect(lifecycle).not.toContain("activate:acme.independent");
139 expect(lifecycle).not.toContain("deactivate:acme.independent");
140 expect(lifecycle.filter((event) => event.startsWith("activate:"))).toEqual([
141 "activate:acme.database",
142 "activate:acme.issues",
143 ]);
144 expect(lifecycle.filter((event) => event.startsWith("deactivate:"))).toEqual([
145 "deactivate:acme.issues",
146 "deactivate:acme.database",
147 ]);
148 expect(contributionLabels(snapshot, "commands")).toEqual(["database-2.0.0"]);
149 await runtime.dispose();
150 });
151
152 it("returns deeply frozen snapshots", async () => {
153 const runtime = createRuntime();
154
155 const snapshot = await runtime.reconcile([provider()]);
156 const status = snapshot.contributions.status;
157
158 expect(Object.isFrozen(snapshot)).toBe(true);
159 expect(Object.isFrozen(snapshot.active)).toBe(true);
160 expect(Object.isFrozen(snapshot.blocked)).toBe(true);
161 expect(Object.isFrozen(snapshot.contributions)).toBe(true);
162 expect(Object.isFrozen(status)).toBe(true);
163 expect(Object.isFrozen(status?.[0])).toBe(true);
164 await runtime.dispose();
165 });
166
167 it("treats every plugin id and contribution slot as data", async () => {
168 const runtime = createRuntime();
169 const unusualProvider: PluginDefinition = {
170 id: "toString",
171 version: "1.0.0",
172 provides: { "acme.unusual@1": "available" },
173 activate(context) {
174 context.register("__proto__", { id: "provider", label: "provider" });
175 },
176 };
177 const unusualConsumer: PluginDefinition = {
178 id: "constructor",
179 version: "1.0.0",
180 requires: ["acme.unusual@1"],
181 activate(context) {
182 context.resolve("acme.unusual@1");
183 context.register("toString", { id: "consumer", label: "consumer" });
184 },
185 };
186 const unusualBlocked: PluginDefinition = {
187 id: "__proto__",
188 version: "1.0.0",
189 requires: ["acme.missing@1"],
190 activate() {
191 throw new Error("blocked plugin activated");
192 },
193 };
194
195 const snapshot = await runtime.reconcile([unusualConsumer, unusualProvider, unusualBlocked]);
196
197 expect(snapshot.active).toEqual(["toString", "constructor"]);
198 expect(Object.hasOwn(snapshot.blocked, "__proto__")).toBe(true);
199 expect(snapshot.blocked.__proto__).toContain("acme.missing@1");
200 expect(Object.hasOwn(snapshot.contributions, "__proto__")).toBe(true);
201 expect(Object.hasOwn(snapshot.contributions, "toString")).toBe(true);
202 expect(contributionLabels(snapshot, "__proto__")).toEqual(["provider"]);
203 expect(contributionLabels(snapshot, "toString")).toEqual(["consumer"]);
204 await runtime.dispose();
205 });
206
207 it("restarts dependents when a provided value changes", async () => {
208 const lifecycle: Array<string> = [];
209 const runtime = createRuntime({
210 onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`),
211 });
212 const serviceProvider = (name: string): PluginDefinition => ({
213 id: "acme.service",
214 version: "1.0.0",
215 provides: { "acme.service@1": { name } },
216 activate() {},
217 });
218 const serviceConsumer: PluginDefinition = {
219 id: "acme.service-consumer",
220 version: "1.0.0",
221 requires: ["acme.service@1"],
222 activate(context) {
223 const service = context.resolve<{ readonly name: string }>("acme.service@1");
224 context.register("commands", { id: "service", label: service.name });
225 },
226 };
227 await runtime.reconcile([serviceProvider("first"), serviceConsumer]);
228 lifecycle.length = 0;
229
230 const snapshot = await runtime.reconcile([serviceProvider("second"), serviceConsumer]);
231
232 expect(contributionLabels(snapshot, "commands")).toEqual(["second"]);
233 expect(lifecycle.filter((event) => event.startsWith("activate:"))).toEqual([
234 "activate:acme.service",
235 "activate:acme.service-consumer",
236 ]);
237 await runtime.dispose();
238 });
239
240 it("does not report a failed candidate as activated", async () => {
241 const lifecycle: Array<string> = [];
242 const runtime = createRuntime({
243 onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`),
244 });
245 const broken: PluginDefinition = {
246 id: "acme.broken",
247 version: "1.0.0",
248 activate() {
249 throw new Error("activation failed");
250 },
251 };
252
253 await expect(runtime.reconcile([broken])).rejects.toThrow("activation failed");
254
255 expect(lifecycle).toEqual([]);
256 await runtime.dispose();
257 });
258
259 it("attempts every plugin cleanup when one finalizer fails", async () => {
260 const disposed: Array<string> = [];
261 const runtime = createRuntime();
262 await runtime.reconcile([
263 {
264 id: "acme.first-cleanup",
265 version: "1.0.0",
266 activate(context) {
267 context.onDispose(() => {
268 disposed.push("first");
269 });
270 },
271 },
272 {
273 id: "acme.second-cleanup",
274 version: "1.0.0",
275 activate(context) {
276 context.onDispose(() => {
277 disposed.push("second");
278 throw new Error("cleanup failed");
279 });
280 },
281 },
282 ]);
283
284 await expect(runtime.dispose()).rejects.toThrow();
285
286 expect(disposed).toEqual(["second", "first"]);
287 });
288
289 it("keeps the old plugin active when a replacement fails", async () => {
290 const runtime = createRuntime();
291 await runtime.reconcile([provider()]);
292 const broken: PluginDefinition = {
293 ...provider("2.0.0"),
294 async activate(context) {
295 context.register("status", { id: "database", label: "database 2.0.0" });
296 throw new Error("candidate failed");
297 },
298 };
299
300 await expect(runtime.reconcile([broken])).rejects.toThrow("candidate failed");
301
302 expect(runtime.snapshot().active).toEqual(["acme.database"]);
303 expect(contributionLabels(runtime.snapshot(), "status")).toEqual(["database 1.0.0"]);
304 await runtime.dispose();
305 });
306
307 it("rejects dependency cycles without disturbing the current composition", async () => {
308 const runtime = createRuntime();
309 await runtime.reconcile([provider()]);
310 const alpha: PluginDefinition = {
311 id: "acme.alpha",
312 version: "1.0.0",
313 requires: ["acme.beta@1"],
314 provides: { "acme.alpha@1": true },
315 activate() {},
316 };
317 const beta: PluginDefinition = {
318 id: "acme.beta",
319 version: "1.0.0",
320 requires: ["acme.alpha@1"],
321 provides: { "acme.beta@1": true },
322 activate() {},
323 };
324
325 await expect(runtime.reconcile([alpha, beta])).rejects.toThrow("cycle");
326
327 expect(runtime.snapshot().active).toEqual(["acme.database"]);
328 await runtime.dispose();
329 });
330 });
331}
packages/plugin-runtime-poc/tsconfig.json+7−0
@@ -0,0 +1,7 @@
1{
2 "extends": "../../tsconfig.base.json",
3 "compilerOptions": {
4 "types": ["node"]
5 },
6 "include": ["src"]
7}
pnpm-lock.yaml+13−0
@@ -830,6 +830,19 @@ importers: · 829 unmodified lines
830 specifier: 'catalog:'830 specifier: 'catalog:'
831 version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)831 version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
832 832
833 packages/plugin-runtime-poc:
834 dependencies:
835 effect:
836 specifier: 4.0.0-beta.103
837 version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)
838 devDependencies:
839 '@types/node':
840 specifier: 24.12.4
841 version: 24.12.4
842 vite-plus:
843 specifier: 'catalog:'
844 version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
845
833 packages/shared:846 packages/shared:
834 dependencies:847 dependencies:
835 '@noble/curves':848 '@noble/curves':