Unified · Split
| @@ -21,9 +21,9 @@ it also fits t3's existing effect runtime. adding cordis did not remove the need · 20 unmodified lines | |||
| 21 | 21 | ||
| 22 | | variant | source lines | new runtime dependency | result | | 22 | | variant | source lines | new runtime dependency | result | |
| 23 | | ----------------------- | -----------: | --------------------------------------------------- | ------------------------------------------------------------------ | | 23 | | ----------------------- | -----------: | --------------------------------------------------- | ------------------------------------------------------------------ | |
| 24 | | `effectScopeRuntime.ts` | 376 | none, effect is already used by t3 | best base for production | | 24 | | `effectScopeRuntime.ts` | 407 | none, effect is already used by t3 | best base for production | |
| 25 | | `cordisRuntime.ts` | 472 | cordis rc.8 and about 300 kib of installed packages | works, but adds a second lifecycle runtime and the largest adapter | | 25 | | `cordisRuntime.ts` | 493 | cordis rc.8 and about 300 kib of installed packages | works, but adds a second lifecycle runtime and the largest adapter | |
| 26 | | `pureRuntime.ts` | 378 | none | simplest planner, but manual finalizers are too easy to misuse | | 26 | | `pureRuntime.ts` | 394 | none | simplest planner, but manual finalizers are too easy to misuse | |
| 27 | 27 | ||
| 28 | `cordisRuntime.ts` uses real cordis `Context`, `Fiber`, `inject`, `provide`, `effect`, and `isolate` behavior. it still needs its own graph analysis and differential reconciliation so unchanged fibers survive an update and failed candidates never replace the live composition. | 28 | `cordisRuntime.ts` uses real cordis `Context`, `Fiber`, `inject`, `provide`, `effect`, and `isolate` behavior. it still needs its own graph analysis and differential reconciliation so unchanged fibers survive an update and failed candidates never replace the live composition. |
| 29 | 29 | ||
| @@ -31,24 +31,25 @@ cordis `4.0.0-rc.8` also ships extensionless declaration re-exports that do not · 1 unmodified line | |||
| 31 | 31 | ||
| 32 | ## behavior proved | 32 | ## behavior proved |
| 33 | 33 | ||
| 34 | all variants run the same contract suite. the full package currently has 44 tests. it checks: | 34 | all variants run the same contract suite. the full package currently has 57 tests. it checks: |
| 35 | 35 | ||
| 36 | - dependency-first activation from unordered manifests | 36 | - dependency-first activation from unordered manifests |
| 37 | - blocked plugins when required capabilities are missing | 37 | - blocked plugins when required capabilities are missing |
| 38 | - dependent-first teardown | 38 | - dependent-first teardown |
| 39 | - lifo plugin finalizers | 39 | - lifo plugin finalizers |
| 40 | - no restart for unchanged plugin scopes | 40 | - no restart for unchanged plugin scopes or reordered dependency declarations |
| 41 | - provider changes restart only transitive dependents | 41 | - targeted restart when versions, provided values, or activation implementations change |
| 42 | - provided service identity changes restart providers and dependents | 42 | - transitive dependent restart without touching unrelated plugins |
| 43 | - failed replacement keeps the old composition active | 43 | - preflight rejection for duplicate ids, duplicate capabilities, and dependency cycles |
| 44 | - cleanup attempts every plugin even when a finalizer fails | 44 | - immutable detached snapshots with prototype-safe plugin ids and contribution slots |
| 45 | - dependency cycles fail before activation | 45 | - rollback that preserves both the active composition and the original activation error |
| 46 | - plugin ids and contribution slots cannot collide with object prototype names | 46 | - cleanup error reporting without rejecting an already committed replacement |
| 47 | - lifecycle events only for committed plugin compositions | ||
| 47 | - deeply frozen public snapshots | 48 | - deeply frozen public snapshots |
| 48 | 49 | ||
| 49 | the pure planner also proves a 20,000-plugin dependency chain without recursive stack overflow. | 50 | the pure planner also proves a 20,000-plugin dependency chain without recursive stack overflow. |
| 50 | 51 | ||
| 51 | `manifest.ts` validates namespaced ids, semantic versions, versioned capabilities, permissions, surfaces, and declarative contributions. executable entry points are limited to server, web, and desktop. a mobile javascript entry point is rejected instead of silently ignored. | 52 | `manifest.ts` validates namespaced ids, semver 2.0.0 versions, versioned capabilities, permissions, surfaces, and declarative contributions. executable entry points are limited to server, web, and desktop and cannot escape the plugin directory through `..` segments. a mobile javascript entry point is rejected instead of silently ignored. |
| 52 | 53 | ||
| 53 | ## illustrative timings | 54 | ## illustrative timings |
| 54 | 55 | ||
| @@ -56,9 +57,9 @@ five runs used node `24.13.1`. each run activated a chain of 250 plugins, reconc · 1 unmodified line | |||
| 56 | 57 | ||
| 57 | | variant | activate 250 | unchanged reconcile | restart 250 | | 58 | | variant | activate 250 | unchanged reconcile | restart 250 | |
| 58 | | --------------- | -----------: | ------------------: | ----------: | | 59 | | --------------- | -----------: | ------------------: | ----------: | |
| 59 | | effect scope | 5.61 ms | 1.17 ms | 4.74 ms | | 60 | | effect scope | 5.44 ms | 1.16 ms | 4.31 ms | |
| 60 | | cordis | 100.9 ms | 1.31 ms | 304.89 ms | | 61 | | direct cordis | 111.44 ms | 1.31 ms | 333.40 ms | |
| 61 | | pure reconciler | 0.71 ms | 0.51 ms | 0.65 ms | | 62 | | pure reconciler | 0.74 ms | 0.59 ms | 0.69 ms | |
| 62 | 63 | ||
| 63 | all three produced the same contributions before and after a provider upgrade. | 64 | all three produced the same contributions before and after a provider upgrade. |
| 64 | 65 | ||
| @@ -19,8 +19,8 @@ export interface PluginActivationContext { · 18 unmodified lines | |||
| 19 | 19 | ||
| 20 | export interface PluginRuntimeSnapshot { | 20 | export interface PluginRuntimeSnapshot { |
| 21 | readonly active: ReadonlyArray<string>; | 21 | readonly active: ReadonlyArray<string>; |
| 22 | readonly blocked: Readonly<Record<string, string>>; | 22 | readonly blocked: Readonly<Partial<Record<string, string>>>; |
| 23 | readonly contributions: Readonly<Record<string, ReadonlyArray<Contribution>>>; | 23 | readonly contributions: Readonly<Partial<Record<string, ReadonlyArray<Contribution>>>>; |
| 24 | } | 24 | } |
| 25 | 25 | ||
| 26 | export interface PluginRuntime { | 26 | export interface PluginRuntime { |
| @@ -36,6 +36,10 @@ export interface PluginRuntimeOptions { · 9 unmodified lines | |||
| 36 | readonly phase: "activate" | "deactivate"; | 36 | readonly phase: "activate" | "deactivate"; |
| 37 | readonly pluginId: string; | 37 | readonly pluginId: string; |
| 38 | }) => void; | 38 | }) => void; |
| 39 | readonly onCleanupError?: (event: { | ||
| 40 | readonly phase: "retire" | "rollback"; | ||
| 41 | readonly error: unknown; | ||
| 42 | }) => void; | ||
| 39 | } | 43 | } |
| 40 | 44 | ||
| 41 | export type PluginRuntimeFactory = (options?: PluginRuntimeOptions) => PluginRuntime; | 45 | export type PluginRuntimeFactory = (options?: PluginRuntimeOptions) => PluginRuntime; |
| @@ -181,21 +181,28 @@ function analyzeDefinitions(definitions: ReadonlyArray<PluginDefinition>) { · 180 unmodified lines | |||
| 181 | }; | 181 | }; |
| 182 | } | 182 | } |
| 183 | 183 | ||
| 184 | function sameStrings(left: ReadonlyArray<string>, right: ReadonlyArray<string>) { | 184 | function sameStringsUnordered(left: ReadonlyArray<string>, right: ReadonlyArray<string>) { |
| 185 | return left.length === right.length && left.every((value, index) => value === right[index]); | 185 | if (left.length !== right.length) return false; |
| 186 | const sortedLeft = left.toSorted(); | ||
| 187 | const sortedRight = right.toSorted(); | ||
| 188 | return sortedLeft.every((value, index) => value === sortedRight[index]); | ||
| 186 | } | 189 | } |
| 187 | 190 | ||
| 188 | function sameDefinition(left: PluginDefinition, right: PluginDefinition) { | 191 | function sameDefinition(left: PluginDefinition, right: PluginDefinition) { |
| 189 | const leftProvides = Object.entries(left.provides ?? {}); | 192 | const leftProvides = left.provides ?? {}; |
| 190 | const rightProvides = Object.entries(right.provides ?? {}); | 193 | const rightProvides = right.provides ?? {}; |
| 194 | const leftCapabilities = Object.keys(leftProvides); | ||
| 195 | const rightCapabilities = Object.keys(rightProvides); | ||
| 191 | return ( | 196 | return ( |
| 192 | left.id === right.id && | 197 | left.id === right.id && |
| 193 | left.version === right.version && | 198 | left.version === right.version && |
| 194 | sameStrings(left.requires ?? [], right.requires ?? []) && | 199 | Object.is(left.activate, right.activate) && |
| 195 | leftProvides.length === rightProvides.length && | 200 | sameStringsUnordered(left.requires ?? [], right.requires ?? []) && |
| 196 | leftProvides.every( | 201 | leftCapabilities.length === rightCapabilities.length && |
| 197 | ([capability, service], index) => | 202 | leftCapabilities.every( |
| 198 | rightProvides[index]?.[0] === capability && Object.is(rightProvides[index]?.[1], service), | 203 | (capability) => |
| 204 | Object.hasOwn(rightProvides, capability) && | ||
| 205 | Object.is(leftProvides[capability], rightProvides[capability]), | ||
| 199 | ) | 206 | ) |
| 200 | ); | 207 | ); |
| 201 | } | 208 | } |
| @@ -280,11 +287,7 @@ function snapshotOf( · 78 unmodified lines | |||
| 280 | ); | 287 | ); |
| 281 | } | 288 | } |
| 282 | 289 | ||
| 283 | function aggregateErrorWithCause(errors: ReadonlyArray<unknown>, message: string, cause: unknown) { | 290 | async function cleanupComposition( |
| 284 | return new AggregateError(errors, message, { cause }); | ||
| 285 | } | ||
| 286 | |||
| 287 | async function disposeComposition( | ||
| 288 | composition: Composition, | 291 | composition: Composition, |
| 289 | onDeactivate?: (pluginId: string) => void, | 292 | onDeactivate?: (pluginId: string) => void, |
| 290 | ) { | 293 | ) { |
| @@ -302,6 +305,14 @@ async function disposeComposition( · 11 unmodified lines | |||
| 302 | } | 305 | } |
| 303 | errors.push(...plugin.cleanupErrors.splice(0)); | 306 | errors.push(...plugin.cleanupErrors.splice(0)); |
| 304 | } | 307 | } |
| 308 | return errors; | ||
| 309 | } | ||
| 310 | |||
| 311 | async function disposeComposition( | ||
| 312 | composition: Composition, | ||
| 313 | onDeactivate?: (pluginId: string) => void, | ||
| 314 | ) { | ||
| 315 | const errors = await cleanupComposition(composition, onDeactivate); | ||
| 305 | if (errors.length) { | 316 | if (errors.length) { |
| 306 | throw new AggregateError(errors, "plugin composition cleanup failed"); | 317 | throw new AggregateError(errors, "plugin composition cleanup failed"); |
| 307 | } | 318 | } |
| @@ -322,6 +333,16 @@ export const createCordisRuntime: PluginRuntimeFactory = (options = {}): PluginR · 14 unmodified lines | |||
| 322 | return result; | 333 | return result; |
| 323 | }; | 334 | }; |
| 324 | 335 | ||
| 336 | const reportCleanupErrors = (phase: "retire" | "rollback", errors: ReadonlyArray<unknown>) => { | ||
| 337 | for (const error of errors) { | ||
| 338 | try { | ||
| 339 | options.onCleanupError?.({ phase, error }); | ||
| 340 | } catch { | ||
| 341 | // Cleanup reporting must not replace activation errors or undo a committed snapshot. | ||
| 342 | } | ||
| 343 | } | ||
| 344 | }; | ||
| 345 | |||
| 325 | const reconcile = (definitions: ReadonlyArray<PluginDefinition>) => | 346 | const reconcile = (definitions: ReadonlyArray<PluginDefinition>) => |
| 326 | runExclusive(async () => { | 347 | runExclusive(async () => { |
| 327 | if (disposed) throw new Error("plugin runtime is disposed"); | 348 | if (disposed) throw new Error("plugin runtime is disposed"); |
| @@ -404,7 +425,6 @@ export const createCordisRuntime: PluginRuntimeFactory = (options = {}): PluginR · 76 unmodified lines | |||
| 404 | finalizers.push(finalizer); | 425 | finalizers.push(finalizer); |
| 405 | }, | 426 | }, |
| 406 | }); | 427 | }); |
| 407 | options.onLifecycle?.({ phase: "activate", pluginId: definition.id }); | ||
| 408 | }; | 428 | }; |
| 409 | cordisPlugin.inject = [...(definition.requires ?? [])]; | 429 | cordisPlugin.inject = [...(definition.requires ?? [])]; |
| 410 | cordisPlugin.provide = Object.keys(definition.provides ?? {}); | 430 | cordisPlugin.provide = Object.keys(definition.provides ?? {}); |
| @@ -415,29 +435,21 @@ export const createCordisRuntime: PluginRuntimeFactory = (options = {}): PluginR · 4 unmodified lines | |||
| 415 | try { | 435 | try { |
| 416 | await fiber; | 436 | await fiber; |
| 417 | } catch (error) { | 437 | } catch (error) { |
| 418 | try { | 438 | const cleanupErrors = await cleanupComposition({ |
| 419 | await disposeComposition({ plugins: [plugin], snapshot: emptySnapshot }); | 439 | plugins: [plugin], |
| 420 | } catch (cleanupError) { | 440 | snapshot: emptySnapshot, |
| 421 | throw aggregateErrorWithCause( | 441 | }); |
| 422 | [error, cleanupError], | 442 | reportCleanupErrors("rollback", cleanupErrors); |
| 423 | `plugin ${definition.id} activation and cleanup failed`, | ||
| 424 | cleanupError, | ||
| 425 | ); | ||
| 426 | } | ||
| 427 | throw error; | 443 | throw error; |
| 428 | } | 444 | } |
| 429 | staged.set(definition.id, plugin); | 445 | staged.set(definition.id, plugin); |
| 430 | } | 446 | } |
| 431 | } catch (error) { | 447 | } catch (error) { |
| 432 | try { | 448 | const cleanupErrors = await cleanupComposition({ |
| 433 | await disposeComposition({ plugins: [...staged.values()], snapshot: emptySnapshot }); | 449 | plugins: [...staged.values()], |
| 434 | } catch (cleanupError) { | 450 | snapshot: emptySnapshot, |
| 435 | throw aggregateErrorWithCause( | 451 | }); |
| 436 | [error, cleanupError], | 452 | reportCleanupErrors("rollback", cleanupErrors); |
| 437 | "plugin composition activation and rollback failed", | ||
| 438 | cleanupError, | ||
| 439 | ); | ||
| 440 | } | ||
| 441 | throw error; | 453 | throw error; |
| 442 | } | 454 | } |
| 443 | 455 | ||
| @@ -449,9 +461,14 @@ export const createCordisRuntime: PluginRuntimeFactory = (options = {}): PluginR · 5 unmodified lines | |||
| 449 | const candidate = { plugins, snapshot: snapshotOf(plugins, blocked) }; | 461 | const candidate = { plugins, snapshot: snapshotOf(plugins, blocked) }; |
| 450 | const previous = composition.plugins.filter((plugin) => !plugins.includes(plugin)); | 462 | const previous = composition.plugins.filter((plugin) => !plugins.includes(plugin)); |
| 451 | composition = candidate; | 463 | composition = candidate; |
| 452 | await disposeComposition({ plugins: previous, snapshot: emptySnapshot }, (pluginId) => | 464 | for (const plugin of staged.values()) { |
| 453 | options.onLifecycle?.({ phase: "deactivate", pluginId }), | 465 | options.onLifecycle?.({ phase: "activate", pluginId: plugin.definition.id }); |
| 466 | } | ||
| 467 | const cleanupErrors = await cleanupComposition( | ||
| 468 | { plugins: previous, snapshot: emptySnapshot }, | ||
| 469 | (pluginId) => options.onLifecycle?.({ phase: "deactivate", pluginId }), | ||
| 454 | ); | 470 | ); |
| 471 | reportCleanupErrors("retire", cleanupErrors); | ||
| 455 | return candidate.snapshot; | 472 | return candidate.snapshot; |
| 456 | }); | 473 | }); |
| 457 | 474 | ||
| @@ -132,20 +132,33 @@ const planComposition = (definitions: ReadonlyArray<PluginDefinition>): PlannedC · 131 unmodified lines | |||
| 132 | return { blocked: blockedRecord, definitions: ordered }; | 132 | return { blocked: blockedRecord, definitions: ordered }; |
| 133 | }; | 133 | }; |
| 134 | 134 | ||
| 135 | const sameStrings = (left: ReadonlyArray<string>, right: ReadonlyArray<string>) => | 135 | const sameStrings = (left: ReadonlyArray<string>, right: ReadonlyArray<string>) => { |
| 136 | left.length === right.length && left.every((value, index) => value === right[index]); | 136 | if (left.length !== right.length) return false; |
| 137 | const sortedLeft = [...left].sort(); | ||
| 138 | const sortedRight = [...right].sort(); | ||
| 139 | return sortedLeft.every((value, index) => value === sortedRight[index]); | ||
| 140 | }; | ||
| 137 | 141 | ||
| 138 | const sameDefinition = (left: PluginDefinition, right: PluginDefinition): boolean => { | 142 | const sameDefinition = (left: PluginDefinition, right: PluginDefinition): boolean => { |
| 139 | if (left.id !== right.id || left.version !== right.version) return false; | 143 | if ( |
| 144 | left.id !== right.id || | ||
| 145 | left.version !== right.version || | ||
| 146 | !Object.is(left.activate, right.activate) | ||
| 147 | ) { | ||
| 148 | return false; | ||
| 149 | } | ||
| 140 | if (!sameStrings(left.requires ?? [], right.requires ?? [])) return false; | 150 | if (!sameStrings(left.requires ?? [], right.requires ?? [])) return false; |
| 141 | 151 | ||
| 142 | const leftProvides = Object.entries(left.provides ?? {}); | 152 | const leftProvides = left.provides ?? {}; |
| 143 | const rightProvides = Object.entries(right.provides ?? {}); | 153 | const rightProvides = right.provides ?? {}; |
| 154 | const leftCapabilities = Object.keys(leftProvides); | ||
| 155 | const rightCapabilities = Object.keys(rightProvides); | ||
| 144 | return ( | 156 | return ( |
| 145 | leftProvides.length === rightProvides.length && | 157 | leftCapabilities.length === rightCapabilities.length && |
| 146 | leftProvides.every( | 158 | leftCapabilities.every( |
| 147 | ([capability, service], index) => | 159 | (capability) => |
| 148 | rightProvides[index]?.[0] === capability && Object.is(rightProvides[index]?.[1], service), | 160 | Object.hasOwn(rightProvides, capability) && |
| 161 | Object.is(leftProvides[capability], rightProvides[capability]), | ||
| 149 | ) | 162 | ) |
| 150 | ); | 163 | ); |
| 151 | }; | 164 | }; |
| @@ -210,7 +223,10 @@ const affectedPluginIds = ( · 58 unmodified lines | |||
| 210 | return affected; | 223 | return affected; |
| 211 | }; | 224 | }; |
| 212 | 225 | ||
| 213 | const closePlugins = async (plugins: ReadonlyArray<LivePlugin>): Promise<void> => { | 226 | const closePlugins = async ( |
| 227 | plugins: ReadonlyArray<LivePlugin>, | ||
| 228 | onDeactivate?: (pluginId: string) => void, | ||
| 229 | ): Promise<ReadonlyArray<unknown>> => { | ||
| 214 | const errors: Array<unknown> = []; | 230 | const errors: Array<unknown> = []; |
| 215 | for (const plugin of plugins.toReversed()) { | 231 | for (const plugin of plugins.toReversed()) { |
| 216 | try { | 232 | try { |
| @@ -218,10 +234,13 @@ const closePlugins = async (plugins: ReadonlyArray<LivePlugin>): Promise<void> = · 1 unmodified line | |||
| 218 | } catch (error) { | 234 | } catch (error) { |
| 219 | errors.push(error); | 235 | errors.push(error); |
| 220 | } | 236 | } |
| 237 | try { | ||
| 238 | onDeactivate?.(plugin.definition.id); | ||
| 239 | } catch (error) { | ||
| 240 | errors.push(error); | ||
| 241 | } | ||
| 221 | } | 242 | } |
| 222 | if (errors.length > 0) { | 243 | return errors; |
| 223 | throw new AggregateError(errors, "Failed to close plugin scopes"); | ||
| 224 | } | ||
| 225 | }; | 244 | }; |
| 226 | 245 | ||
| 227 | const snapshotOf = ( | 246 | const snapshotOf = ( |
| @@ -252,22 +271,23 @@ export const createEffectScopeRuntime: PluginRuntimeFactory = (options = {}): Pl · 24 unmodified lines | |||
| 252 | let disposed = false; | 271 | let disposed = false; |
| 253 | let transition: Promise<void> = Promise.resolve(); | 272 | let transition: Promise<void> = Promise.resolve(); |
| 254 | 273 | ||
| 274 | const reportCleanupErrors = (phase: "retire" | "rollback", errors: ReadonlyArray<unknown>) => { | ||
| 275 | for (const error of errors) { | ||
| 276 | try { | ||
| 277 | options.onCleanupError?.({ phase, error }); | ||
| 278 | } catch { | ||
| 279 | // Cleanup reporting must not replace activation errors or undo a committed snapshot. | ||
| 280 | } | ||
| 281 | } | ||
| 282 | }; | ||
| 283 | |||
| 255 | const activatePlugin = async ( | 284 | const activatePlugin = async ( |
| 256 | definition: PluginDefinition, | 285 | definition: PluginDefinition, |
| 257 | capabilities: ReadonlyMap<string, unknown>, | 286 | capabilities: ReadonlyMap<string, unknown>, |
| 258 | ): Promise<LivePlugin> => { | 287 | ): Promise<LivePlugin> => { |
| 259 | const scope = Effect.runSync(Scope.make("sequential")); | 288 | const scope = Effect.runSync(Scope.make("sequential")); |
| 260 | const contributions = new Map<string, Array<Contribution>>(); | 289 | const contributions = new Map<string, Array<Contribution>>(); |
| 261 | let activated = false; | 290 | const plugin = { definition, scope, contributions }; |
| 262 | |||
| 263 | Effect.runSync( | ||
| 264 | Scope.addFinalizer( | ||
| 265 | scope, | ||
| 266 | Effect.sync(() => { | ||
| 267 | if (activated) options.onLifecycle?.({ phase: "deactivate", pluginId: definition.id }); | ||
| 268 | }), | ||
| 269 | ), | ||
| 270 | ); | ||
| 271 | 291 | ||
| 272 | const context: PluginActivationContext = { | 292 | const context: PluginActivationContext = { |
| 273 | resolve: <Service>(capability: string): Service => { | 293 | resolve: <Service>(capability: string): Service => { |
| @@ -299,11 +319,10 @@ export const createEffectScopeRuntime: PluginRuntimeFactory = (options = {}): Pl · 25 unmodified lines | |||
| 299 | Effect.provideService(Scope.Scope, scope), | 319 | Effect.provideService(Scope.Scope, scope), |
| 300 | ), | 320 | ), |
| 301 | ); | 321 | ); |
| 302 | activated = true; | 322 | return plugin; |
| 303 | options.onLifecycle?.({ phase: "activate", pluginId: definition.id }); | ||
| 304 | return { definition, scope, contributions }; | ||
| 305 | } catch (error) { | 323 | } catch (error) { |
| 306 | await Effect.runPromise(Scope.close(scope, Exit.void)); | 324 | const cleanupErrors = await closePlugins([plugin]); |
| 325 | reportCleanupErrors("rollback", cleanupErrors); | ||
| 307 | throw error; | 326 | throw error; |
| 308 | } | 327 | } |
| 309 | }; | 328 | }; |
| @@ -346,7 +365,8 @@ export const createEffectScopeRuntime: PluginRuntimeFactory = (options = {}): Pl · 36 unmodified lines | |||
| 346 | } | 365 | } |
| 347 | } | 366 | } |
| 348 | } catch (error) { | 367 | } catch (error) { |
| 349 | await closePlugins([...staged.values()]); | 368 | const cleanupErrors = await closePlugins([...staged.values()]); |
| 369 | reportCleanupErrors("rollback", cleanupErrors); | ||
| 350 | throw error; | 370 | throw error; |
| 351 | } | 371 | } |
| 352 | 372 | ||
| @@ -360,7 +380,13 @@ export const createEffectScopeRuntime: PluginRuntimeFactory = (options = {}): Pl · 7 unmodified lines | |||
| 360 | plugins: nextPlugins, | 380 | plugins: nextPlugins, |
| 361 | snapshot: snapshotOf(nextPlugins, plan.blocked), | 381 | snapshot: snapshotOf(nextPlugins, plan.blocked), |
| 362 | }; | 382 | }; |
| 363 | await closePlugins(previous); | 383 | for (const plugin of staged.values()) { |
| 384 | options.onLifecycle?.({ phase: "activate", pluginId: plugin.definition.id }); | ||
| 385 | } | ||
| 386 | const cleanupErrors = await closePlugins(previous, (pluginId) => | ||
| 387 | options.onLifecycle?.({ phase: "deactivate", pluginId }), | ||
| 388 | ); | ||
| 389 | reportCleanupErrors("retire", cleanupErrors); | ||
| 364 | return current.snapshot; | 390 | return current.snapshot; |
| 365 | }), | 391 | }), |
| 366 | snapshot: () => current.snapshot, | 392 | snapshot: () => current.snapshot, |
| @@ -370,7 +396,12 @@ export const createEffectScopeRuntime: PluginRuntimeFactory = (options = {}): Pl · 3 unmodified lines | |||
| 370 | disposed = true; | 396 | disposed = true; |
| 371 | const previous = current.plugins; | 397 | const previous = current.plugins; |
| 372 | current = { plugins: [], snapshot: emptySnapshot() }; | 398 | current = { plugins: [], snapshot: emptySnapshot() }; |
| 373 | await closePlugins(previous); | 399 | const cleanupErrors = await closePlugins(previous, (pluginId) => |
| 400 | options.onLifecycle?.({ phase: "deactivate", pluginId }), | ||
| 401 | ); | ||
| 402 | if (cleanupErrors.length > 0) { | ||
| 403 | throw new AggregateError(cleanupErrors, "Failed to close plugin scopes"); | ||
| 404 | } | ||
| 374 | }), | 405 | }), |
| 375 | }; | 406 | }; |
| 376 | }; | 407 | }; |
| @@ -40,9 +40,25 @@ describe("PluginManifest", () => { · 39 unmodified lines | |||
| 40 | 40 | ||
| 41 | it("rejects malformed versions and capability ids", () => { | 41 | it("rejects malformed versions and capability ids", () => { |
| 42 | expect(() => decodeManifest({ ...validManifest, version: "next" })).toThrow(); | 42 | expect(() => decodeManifest({ ...validManifest, version: "next" })).toThrow(); |
| 43 | expect(() => decodeManifest({ ...validManifest, version: "01.2.3" })).toThrow(); | ||
| 44 | expect(() => decodeManifest({ ...validManifest, version: "1.2.3-.." })).toThrow(); | ||
| 45 | expect(decodeManifest({ ...validManifest, version: "1.2.3+build.7" }).version).toBe( | ||
| 46 | "1.2.3+build.7", | ||
| 47 | ); | ||
| 43 | expect(() => decodeManifest({ ...validManifest, requires: ["t3.commands"] })).toThrow(); | 48 | expect(() => decodeManifest({ ...validManifest, requires: ["t3.commands"] })).toThrow(); |
| 44 | }); | 49 | }); |
| 45 | 50 | ||
| 51 | it("rejects entrypoints that escape the plugin directory", () => { | ||
| 52 | for (const server of ["./../outside.js", "./dist/../../outside.js"]) { | ||
| 53 | expect(() => | ||
| 54 | decodeManifest({ | ||
| 55 | ...validManifest, | ||
| 56 | entrypoints: { ...validManifest.entrypoints, server }, | ||
| 57 | }), | ||
| 58 | ).toThrow(); | ||
| 59 | } | ||
| 60 | }); | ||
| 61 | |||
| 46 | it("keeps mobile declarative by excluding a mobile executable entrypoint", () => { | 62 | it("keeps mobile declarative by excluding a mobile executable entrypoint", () => { |
| 47 | const decoded = decodeManifest({ | 63 | const decoded = decodeManifest({ |
| 48 | ...validManifest, | 64 | ...validManifest, |
| @@ -5,12 +5,16 @@ const NamespacedId = Schema.String.check( · 4 unmodified lines | |||
| 5 | ); | 5 | ); |
| 6 | 6 | ||
| 7 | const SemanticVersion = Schema.String.check( | 7 | const SemanticVersion = Schema.String.check( |
| 8 | Schema.isPattern(/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/), | 8 | Schema.isPattern( |
| 9 | /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:0|[1-9]\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/, | ||
| 10 | ), | ||
| 9 | ); | 11 | ); |
| 10 | 12 | ||
| 11 | const CapabilityId = Schema.String.check(Schema.isPattern(/^[a-z0-9][a-z0-9.-]*@[1-9]\d*$/)); | 13 | const CapabilityId = Schema.String.check(Schema.isPattern(/^[a-z0-9][a-z0-9.-]*@[1-9]\d*$/)); |
| 12 | 14 | ||
| 13 | const RelativeEntrypoint = Schema.String.check(Schema.isPattern(/^\.\/[A-Za-z0-9_./-]+$/)); | 15 | const RelativeEntrypoint = Schema.String.check( |
| 16 | Schema.isPattern(/^\.\/(?!(?:\.\.(?:\/|$)|.*\/\.\.(?:\/|$)))[A-Za-z0-9_./-]+$/), | ||
| 17 | ); | ||
| 14 | const Permission = Schema.String.check(Schema.isPattern(/^[a-z][a-z-]*:.+$/)); | 18 | const Permission = Schema.String.check(Schema.isPattern(/^[a-z][a-z-]*:.+$/)); |
| 15 | 19 | ||
| 16 | const ContributionCatalog = Schema.Struct({ | 20 | const ContributionCatalog = Schema.Struct({ |
| @@ -150,8 +150,8 @@ const planComposition = (definitions: ReadonlyArray<PluginDefinition>): Composit · 149 unmodified lines | |||
| 150 | }; | 150 | }; |
| 151 | 151 | ||
| 152 | const sameDefinitionShape = (previous: PluginDefinition, desired: PluginDefinition) => { | 152 | const sameDefinitionShape = (previous: PluginDefinition, desired: PluginDefinition) => { |
| 153 | const previousRequires = previous.requires ?? []; | 153 | const previousRequires = [...(previous.requires ?? [])].sort(); |
| 154 | const desiredRequires = desired.requires ?? []; | 154 | const desiredRequires = [...(desired.requires ?? [])].sort(); |
| 155 | const previousProvidedServices = previous.provides ?? {}; | 155 | const previousProvidedServices = previous.provides ?? {}; |
| 156 | const desiredProvidedServices = desired.provides ?? {}; | 156 | const desiredProvidedServices = desired.provides ?? {}; |
| 157 | const previousProvides = Object.keys(previousProvidedServices); | 157 | const previousProvides = Object.keys(previousProvidedServices); |
| @@ -159,20 +159,18 @@ const sameDefinitionShape = (previous: PluginDefinition, desired: PluginDefiniti · 1 unmodified line | |||
| 159 | 159 | ||
| 160 | return ( | 160 | return ( |
| 161 | previous.version === desired.version && | 161 | previous.version === desired.version && |
| 162 | Object.is(previous.activate, desired.activate) && | ||
| 162 | previousRequires.length === desiredRequires.length && | 163 | previousRequires.length === desiredRequires.length && |
| 163 | previousRequires.every((capability, index) => capability === desiredRequires[index]) && | 164 | previousRequires.every((capability, index) => capability === desiredRequires[index]) && |
| 164 | previousProvides.length === desiredProvides.length && | 165 | previousProvides.length === desiredProvides.length && |
| 165 | previousProvides.every( | 166 | previousProvides.every( |
| 166 | (capability, index) => | 167 | (capability) => |
| 167 | capability === desiredProvides[index] && | 168 | Object.hasOwn(desiredProvidedServices, capability) && |
| 168 | Object.is(previousProvidedServices[capability], desiredProvidedServices[capability]), | 169 | Object.is(previousProvidedServices[capability], desiredProvidedServices[capability]), |
| 169 | ) | 170 | ) |
| 170 | ); | 171 | ); |
| 171 | }; | 172 | }; |
| 172 | 173 | ||
| 173 | const messageOf = (error: unknown) => | ||
| 174 | error instanceof Error ? error.message : "plugin activation failed"; | ||
| 175 | |||
| 176 | export const createPureRuntime: PluginRuntimeFactory = (options = {}): PluginRuntime => { | 174 | export const createPureRuntime: PluginRuntimeFactory = (options = {}): PluginRuntime => { |
| 177 | let current = emptyComposition(); | 175 | let current = emptyComposition(); |
| 178 | let disposed = false; | 176 | let disposed = false; |
| @@ -216,6 +214,16 @@ export const createPureRuntime: PluginRuntimeFactory = (options = {}): PluginRun · 37 unmodified lines | |||
| 216 | return errors; | 214 | return errors; |
| 217 | }; | 215 | }; |
| 218 | 216 | ||
| 217 | const reportCleanupErrors = (phase: "retire" | "rollback", errors: ReadonlyArray<unknown>) => { | ||
| 218 | for (const error of errors) { | ||
| 219 | try { | ||
| 220 | options.onCleanupError?.({ phase, error }); | ||
| 221 | } catch { | ||
| 222 | // Cleanup reporting must not replace an activation failure or invalidate a commit. | ||
| 223 | } | ||
| 224 | } | ||
| 225 | }; | ||
| 226 | |||
| 219 | const stage = async ( | 227 | const stage = async ( |
| 220 | definitions: ReadonlyArray<PluginDefinition>, | 228 | definitions: ReadonlyArray<PluginDefinition>, |
| 221 | previous: Composition, | 229 | previous: Composition, |
| @@ -303,8 +311,6 @@ export const createPureRuntime: PluginRuntimeFactory = (options = {}): PluginRun · 81 unmodified lines | |||
| 303 | activating = false; | 311 | activating = false; |
| 304 | } | 312 | } |
| 305 | 313 | ||
| 306 | instance.activated = true; | ||
| 307 | options.onLifecycle?.({ phase: "activate", pluginId: definition.id }); | ||
| 308 | for (const [capability, service] of Object.entries(definition.provides ?? {})) { | 314 | for (const [capability, service] of Object.entries(definition.provides ?? {})) { |
| 309 | services.set(capability, service); | 315 | services.set(capability, service); |
| 310 | } | 316 | } |
| @@ -323,12 +329,7 @@ export const createPureRuntime: PluginRuntimeFactory = (options = {}): PluginRun · 12 unmodified lines | |||
| 323 | }; | 329 | }; |
| 324 | } catch (activationError) { | 330 | } catch (activationError) { |
| 325 | const cleanupErrors = await deactivateAll(staged); | 331 | const cleanupErrors = await deactivateAll(staged); |
| 326 | if (cleanupErrors.length > 0) { | 332 | reportCleanupErrors("rollback", cleanupErrors); |
| 327 | throw new Error( | ||
| 328 | `${messageOf(activationError)}; ${cleanupErrors.length} rollback finalizer(s) also failed`, | ||
| 329 | { cause: activationError }, | ||
| 330 | ); | ||
| 331 | } | ||
| 332 | throw activationError; | 333 | throw activationError; |
| 333 | } | 334 | } |
| 334 | }; | 335 | }; |
| @@ -352,11 +353,26 @@ export const createPureRuntime: PluginRuntimeFactory = (options = {}): PluginRun · 17 unmodified lines | |||
| 352 | const candidate = await stage(desired, previous); | 353 | const candidate = await stage(desired, previous); |
| 353 | current = candidate; | 354 | current = candidate; |
| 354 | 355 | ||
| 356 | const lifecycleErrors: Array<unknown> = []; | ||
| 357 | for (const instance of candidate.instances) { | ||
| 358 | if (instance.activated) continue; | ||
| 359 | instance.activated = true; | ||
| 360 | try { | ||
| 361 | options.onLifecycle?.({ phase: "activate", pluginId: instance.definition.id }); | ||
| 362 | } catch (error) { | ||
| 363 | lifecycleErrors.push(error); | ||
| 364 | } | ||
| 365 | } | ||
| 366 | |||
| 355 | const retained = new Set(candidate.instances); | 367 | const retained = new Set(candidate.instances); |
| 356 | const retired = previous.instances.filter((instance) => !retained.has(instance)); | 368 | const retired = previous.instances.filter((instance) => !retained.has(instance)); |
| 357 | const cleanupErrors = await deactivateAll(retired); | 369 | const cleanupErrors = await deactivateAll(retired); |
| 358 | if (cleanupErrors.length > 0) { | 370 | reportCleanupErrors("retire", cleanupErrors); |
| 359 | throw new AggregateError(cleanupErrors, "previous composition deactivation failed"); | 371 | if (lifecycleErrors.length > 0) { |
| 372 | throw new AggregateError( | ||
| 373 | lifecycleErrors, | ||
| 374 | "committed composition lifecycle reporting failed", | ||
| 375 | ); | ||
| 360 | } | 376 | } |
| 361 | return candidate.snapshot; | 377 | return candidate.snapshot; |
| 362 | }); | 378 | }); |
| @@ -116,6 +116,78 @@ export function defineRuntimeContract(name: string, createRuntime: PluginRuntime · 115 unmodified lines | |||
| 116 | expect(disposals).toBe(1); | 116 | expect(disposals).toBe(1); |
| 117 | }); | 117 | }); |
| 118 | 118 | ||
| 119 | it("treats reordered requirements and providers as the same definition", async () => { | ||
| 120 | let activations = 0; | ||
| 121 | const firstService = {}; | ||
| 122 | const secondService = {}; | ||
| 123 | const activate: PluginDefinition["activate"] = () => { | ||
| 124 | activations += 1; | ||
| 125 | }; | ||
| 126 | const dependencies: ReadonlyArray<PluginDefinition> = [ | ||
| 127 | { | ||
| 128 | id: "acme.first-dependency", | ||
| 129 | version: "1.0.0", | ||
| 130 | provides: { "acme.first@1": true }, | ||
| 131 | activate() {}, | ||
| 132 | }, | ||
| 133 | { | ||
| 134 | id: "acme.second-dependency", | ||
| 135 | version: "1.0.0", | ||
| 136 | provides: { "acme.second@1": true }, | ||
| 137 | activate() {}, | ||
| 138 | }, | ||
| 139 | ]; | ||
| 140 | const runtime = createRuntime(); | ||
| 141 | await runtime.reconcile([ | ||
| 142 | ...dependencies, | ||
| 143 | { | ||
| 144 | id: "acme.stable-order", | ||
| 145 | version: "1.0.0", | ||
| 146 | requires: ["acme.first@1", "acme.second@1"], | ||
| 147 | provides: { "acme.output-one@1": firstService, "acme.output-two@1": secondService }, | ||
| 148 | activate, | ||
| 149 | }, | ||
| 150 | ]); | ||
| 151 | |||
| 152 | await runtime.reconcile([ | ||
| 153 | ...dependencies, | ||
| 154 | { | ||
| 155 | id: "acme.stable-order", | ||
| 156 | version: "1.0.0", | ||
| 157 | requires: ["acme.second@1", "acme.first@1"], | ||
| 158 | provides: { "acme.output-two@1": secondService, "acme.output-one@1": firstService }, | ||
| 159 | activate, | ||
| 160 | }, | ||
| 161 | ]); | ||
| 162 | |||
| 163 | expect(activations).toBe(1); | ||
| 164 | await runtime.dispose(); | ||
| 165 | }); | ||
| 166 | |||
| 167 | it("restarts a plugin when its activation implementation changes", async () => { | ||
| 168 | const runtime = createRuntime(); | ||
| 169 | const first: PluginDefinition = { | ||
| 170 | id: "acme.implementation", | ||
| 171 | version: "1.0.0", | ||
| 172 | activate(context) { | ||
| 173 | context.register("commands", { id: "implementation", label: "first" }); | ||
| 174 | }, | ||
| 175 | }; | ||
| 176 | const second: PluginDefinition = { | ||
| 177 | id: "acme.implementation", | ||
| 178 | version: "1.0.0", | ||
| 179 | activate(context) { | ||
| 180 | context.register("commands", { id: "implementation", label: "second" }); | ||
| 181 | }, | ||
| 182 | }; | ||
| 183 | |||
| 184 | await runtime.reconcile([first]); | ||
| 185 | const snapshot = await runtime.reconcile([second]); | ||
| 186 | |||
| 187 | expect(contributionLabels(snapshot, "commands")).toEqual(["second"]); | ||
| 188 | await runtime.dispose(); | ||
| 189 | }); | ||
| 190 | |||
| 119 | it("restarts a changed provider and its dependents without touching independent plugins", async () => { | 191 | it("restarts a changed provider and its dependents without touching independent plugins", async () => { |
| 120 | let independentActivations = 0; | 192 | let independentActivations = 0; |
| 121 | const lifecycle: Array<string> = []; | 193 | const lifecycle: Array<string> = []; |
| @@ -237,11 +309,16 @@ export function defineRuntimeContract(name: string, createRuntime: PluginRuntime · 115 unmodified lines | |||
| 237 | await runtime.dispose(); | 309 | await runtime.dispose(); |
| 238 | }); | 310 | }); |
| 239 | 311 | ||
| 240 | it("does not report a failed candidate as activated", async () => { | 312 | it("does not report a rolled-back candidate as activated", async () => { |
| 241 | const lifecycle: Array<string> = []; | 313 | const lifecycle: Array<string> = []; |
| 242 | const runtime = createRuntime({ | 314 | const runtime = createRuntime({ |
| 243 | onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`), | 315 | onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`), |
| 244 | }); | 316 | }); |
| 317 | const staged: PluginDefinition = { | ||
| 318 | id: "acme.staged", | ||
| 319 | version: "1.0.0", | ||
| 320 | activate() {}, | ||
| 321 | }; | ||
| 245 | const broken: PluginDefinition = { | 322 | const broken: PluginDefinition = { |
| 246 | id: "acme.broken", | 323 | id: "acme.broken", |
| 247 | version: "1.0.0", | 324 | version: "1.0.0", |
| @@ -250,12 +327,76 @@ export function defineRuntimeContract(name: string, createRuntime: PluginRuntime · 2 unmodified lines | |||
| 250 | }, | 327 | }, |
| 251 | }; | 328 | }; |
| 252 | 329 | ||
| 253 | await expect(runtime.reconcile([broken])).rejects.toThrow("activation failed"); | 330 | await expect(runtime.reconcile([staged, broken])).rejects.toThrow("activation failed"); |
| 254 | 331 | ||
| 255 | expect(lifecycle).toEqual([]); | 332 | expect(lifecycle).toEqual([]); |
| 256 | await runtime.dispose(); | 333 | await runtime.dispose(); |
| 257 | }); | 334 | }); |
| 258 | 335 | ||
| 336 | it("preserves activation failures when rollback cleanup also fails", async () => { | ||
| 337 | const activationError = new Error("activation failed"); | ||
| 338 | const cleanupError = new Error("rollback cleanup failed"); | ||
| 339 | const cleanupEvents: Array<{ readonly phase: string; readonly error: unknown }> = []; | ||
| 340 | const runtime = createRuntime({ | ||
| 341 | onCleanupError: (event) => { | ||
| 342 | cleanupEvents.push(event); | ||
| 343 | throw new Error("cleanup observer failed"); | ||
| 344 | }, | ||
| 345 | }); | ||
| 346 | const broken: PluginDefinition = { | ||
| 347 | id: "acme.rollback-error", | ||
| 348 | version: "1.0.0", | ||
| 349 | activate(context) { | ||
| 350 | context.onDispose(() => { | ||
| 351 | throw cleanupError; | ||
| 352 | }); | ||
| 353 | throw activationError; | ||
| 354 | }, | ||
| 355 | }; | ||
| 356 | |||
| 357 | await expect(runtime.reconcile([broken])).rejects.toBe(activationError); | ||
| 358 | expect(cleanupEvents).toEqual([{ phase: "rollback", error: cleanupError }]); | ||
| 359 | await runtime.dispose(); | ||
| 360 | }); | ||
| 361 | |||
| 362 | it("returns the committed snapshot when retiring an old plugin reports cleanup errors", async () => { | ||
| 363 | const cleanupError = new Error("retirement cleanup failed"); | ||
| 364 | const cleanupEvents: Array<{ readonly phase: string; readonly error: unknown }> = []; | ||
| 365 | const runtime = createRuntime({ | ||
| 366 | onCleanupError: (event) => { | ||
| 367 | cleanupEvents.push(event); | ||
| 368 | throw new Error("cleanup observer failed"); | ||
| 369 | }, | ||
| 370 | }); | ||
| 371 | await runtime.reconcile([ | ||
| 372 | { | ||
| 373 | id: "acme.retirement-error", | ||
| 374 | version: "1.0.0", | ||
| 375 | activate(context) { | ||
| 376 | context.register("commands", { id: "retirement", label: "old" }); | ||
| 377 | context.onDispose(() => { | ||
| 378 | throw cleanupError; | ||
| 379 | }); | ||
| 380 | }, | ||
| 381 | }, | ||
| 382 | ]); | ||
| 383 | |||
| 384 | const snapshot = await runtime.reconcile([ | ||
| 385 | { | ||
| 386 | id: "acme.retirement-error", | ||
| 387 | version: "2.0.0", | ||
| 388 | activate(context) { | ||
| 389 | context.register("commands", { id: "retirement", label: "new" }); | ||
| 390 | }, | ||
| 391 | }, | ||
| 392 | ]); | ||
| 393 | |||
| 394 | expect(contributionLabels(snapshot, "commands")).toEqual(["new"]); | ||
| 395 | expect(runtime.snapshot()).toBe(snapshot); | ||
| 396 | expect(cleanupEvents).toEqual([{ phase: "retire", error: cleanupError }]); | ||
| 397 | await runtime.dispose(); | ||
| 398 | }); | ||
| 399 | |||
| 259 | it("attempts every plugin cleanup when one finalizer fails", async () => { | 400 | it("attempts every plugin cleanup when one finalizer fails", async () => { |
| 260 | const disposed: Array<string> = []; | 401 | const disposed: Array<string> = []; |
| 261 | const runtime = createRuntime(); | 402 | const runtime = createRuntime(); |