| @@ -1,17 +1,19 @@ |
| 1 | import * as NodeAsyncHooks from "node:async_hooks"; | 1 | import * as NodeAsyncHooks from "node:async_hooks"; |
| 2 | | 2 | |
| 3 | import * as Cause from "effect/Cause"; | 3 | import * as Cause from "effect/Cause"; |
| | 4 | import * as Context from "effect/Context"; |
| 4 | import * as Effect from "effect/Effect"; | 5 | import * as Effect from "effect/Effect"; |
| 5 | import * as Exit from "effect/Exit"; | 6 | import * as Exit from "effect/Exit"; |
| | 7 | import * as Layer from "effect/Layer"; |
| 6 | import * as Schema from "effect/Schema"; | 8 | import * as Schema from "effect/Schema"; |
| | 9 | import * as Semaphore from "effect/Semaphore"; |
| 7 | import * as Scope from "effect/Scope"; | 10 | import * as Scope from "effect/Scope"; |
| 8 | | 11 | |
| 9 | import type { | 12 | import type { |
| 10 | Contribution, | 13 | Contribution, |
| 11 | PluginActivationContext, | 14 | PluginActivationContext, |
| 12 | PluginDefinition, | 15 | PluginDefinition, |
| 13 | PluginRuntime, | 16 | PluginRuntimeOptions, |
| 14 | PluginRuntimeFactory, | | |
| 15 | PluginRuntimeSnapshot, | 17 | PluginRuntimeSnapshot, |
| 16 | } from "./contract.ts"; | 18 | } from "./contract.ts"; |
| 17 | import { | 19 | import { |
| @@ -137,12 +139,26 @@ class PluginRuntimeCleanupError extends Schema.TaggedErrorClass<PluginRuntimeCle · 119 unmodified lines |
| 137 | } | 139 | } |
| 138 | } | 140 | } |
| 139 | | 141 | |
| 140 | type PluginReconcileError = | 142 | export type PluginRuntimeReconcileError = |
| 141 | | PluginPlanningError | 143 | | PluginPlanningError |
| 142 | | PluginCallbackError | 144 | | PluginCallbackError |
| 143 | | PluginRuntimeDisposedError | 145 | | PluginRuntimeDisposedError |
| | 146 | | PluginRuntimeReentrancyError |
| 144 | | PluginStagingError; | 147 | | PluginStagingError; |
| 145 | | 148 | |
| | 149 | export type PluginRuntimeDisposeError = PluginRuntimeCleanupError | PluginRuntimeReentrancyError; |
| | 150 | |
| | 151 | export class PluginRuntime extends Context.Service< |
| | 152 | PluginRuntime, |
| | 153 | { |
| | 154 | readonly reconcile: ( |
| | 155 | definitions: ReadonlyArray<PluginDefinition>, |
| | 156 | ) => Effect.Effect<PluginRuntimeSnapshot, PluginRuntimeReconcileError>; |
| | 157 | readonly snapshot: Effect.Effect<PluginRuntimeSnapshot>; |
| | 158 | readonly dispose: Effect.Effect<void, PluginRuntimeDisposeError>; |
| | 159 | } |
| | 160 | >()("@t3tools/plugin-runtime/runtime/PluginRuntime") {} |
| | 161 | |
| 146 | const createNullPrototypeRecord = <Value>(): Record<string, Value> => | 162 | const createNullPrototypeRecord = <Value>(): Record<string, Value> => |
| 147 | Object.create(null) as Record<string, Value>; | 163 | Object.create(null) as Record<string, Value>; |
| 148 | | 164 | |
| @@ -195,300 +211,297 @@ const snapshotOf = ( · 46 unmodified lines |
| 195 | }); | 211 | }); |
| 196 | }; | 212 | }; |
| 197 | | 213 | |
| 198 | export const createPluginRuntime: PluginRuntimeFactory = (options = {}): PluginRuntime => { | 214 | export const make = (options: PluginRuntimeOptions = {}) => |
| 199 | let current: LiveComposition = { plugins: [], snapshot: emptySnapshot() }; | 215 | Effect.gen(function* () { |
| 200 | let disposed = false; | 216 | const parentScope = yield* Effect.scope; |
| 201 | let transition: Promise<void> = Promise.resolve(); | 217 | const transitionSemaphore = yield* Semaphore.make(1); |
| 202 | let runtimeScope: Scope.Closeable | undefined; | 218 | let current: LiveComposition = { plugins: [], snapshot: emptySnapshot() }; |
| 203 | const callbackContext = new NodeAsyncHooks.AsyncLocalStorage<PluginCallbackContext>(); | 219 | let disposed = false; |
| 204 | | 220 | const callbackContext = new NodeAsyncHooks.AsyncLocalStorage<PluginCallbackContext>(); |
| 205 | const getRuntimeScope = (): Effect.Effect<Scope.Closeable> => | 221 | |
| 206 | Effect.gen(function* () { | 222 | const reportLifecycle = ( |
| 207 | if (runtimeScope === undefined) runtimeScope = yield* Scope.make("sequential"); | 223 | phase: "activate" | "deactivate", |
| 208 | return runtimeScope; | 224 | pluginId: string, |
| 209 | }); | 225 | ): Effect.Effect<void> => |
| 210 | | 226 | Effect.sync(() => { |
| 211 | const reportLifecycle = ( | | |
| 212 | phase: "activate" | "deactivate", | | |
| 213 | pluginId: string, | | |
| 214 | ): Effect.Effect<void> => | | |
| 215 | Effect.sync(() => { | | |
| 216 | try { | | |
| 217 | options.onLifecycle?.({ phase, pluginId }); | | |
| 218 | } catch (error) { | | |
| 219 | try { | | |
| 220 | options.onLifecycleError?.({ phase, pluginId, error }); | | |
| 221 | } catch { | | |
| 222 | // Observer error reporting must never interrupt a commit or cleanup. | | |
| 223 | } | | |
| 224 | } | | |
| 225 | }); | | |
| 226 | | | |
| 227 | const reportCleanupErrors = ( | | |
| 228 | phase: "retire" | "rollback", | | |
| 229 | failures: ReadonlyArray<CleanupFailure>, | | |
| 230 | ): Effect.Effect<void> => | | |
| 231 | Effect.sync(() => { | | |
| 232 | for (const { error } of failures) { | | |
| 233 | try { | 227 | try { |
| 234 | options.onCleanupError?.({ phase, error }); | 228 | options.onLifecycle?.({ phase, pluginId }); |
| 235 | } catch { | 229 | } catch (error) { |
| 236 | // Cleanup reporting must not replace activation errors or undo a committed snapshot. | 230 | try { |
| 237 | } | 231 | options.onLifecycleError?.({ phase, pluginId, error }); |
| 238 | } | 232 | } catch { |
| 239 | }); | 233 | // Observer error reporting must never interrupt a commit or cleanup. |
| 240 | | 234 | } |
| 241 | const closePlugins = ( | | |
| 242 | plugins: ReadonlyArray<LivePlugin>, | | |
| 243 | notifyDeactivation: boolean, | | |
| 244 | ): Effect.Effect<ReadonlyArray<CleanupFailure>> => | | |
| 245 | Effect.gen(function* () { | | |
| 246 | const failures: Array<CleanupFailure> = []; | | |
| 247 | for (const plugin of plugins.toReversed()) { | | |
| 248 | const closeExit = yield* Effect.exit(Scope.close(plugin.scope, Exit.void)); | | |
| 249 | if (Exit.isFailure(closeExit)) { | | |
| 250 | failures.push({ | | |
| 251 | error: Cause.squash(closeExit.cause), | | |
| 252 | pluginId: plugin.definition.id, | | |
| 253 | }); | | |
| 254 | } | | |
| 255 | for (const error of plugin.cleanupErrors.splice(0)) { | | |
| 256 | failures.push({ error, pluginId: plugin.definition.id }); | | |
| 257 | } | | |
| 258 | if (notifyDeactivation) { | | |
| 259 | yield* reportLifecycle("deactivate", plugin.definition.id); | | |
| 260 | } | 235 | } |
| 261 | } | 236 | }); |
| 262 | return failures; | | |
| 263 | }); | | |
| 264 | | 237 | |
| 265 | const invokePluginCallback = <Result>( | 238 | const reportCleanupErrors = ( |
| 266 | callback: PluginCallback, | 239 | phase: "retire" | "rollback", |
| 267 | pluginId: string, | 240 | failures: ReadonlyArray<CleanupFailure>, |
| 268 | invoke: () => Result | PromiseLike<Result>, | 241 | ): Effect.Effect<void> => |
| 269 | onSettled?: () => void, | 242 | Effect.sync(() => { |
| 270 | ): Effect.Effect<Result, PluginCallbackError> => | 243 | for (const { error } of failures) { |
| 271 | Effect.tryPromise({ | 244 | try { |
| 272 | try: async () => { | 245 | options.onCleanupError?.({ phase, error }); |
| 273 | const callbackState: PluginCallbackContext = { active: true, callback, pluginId }; | 246 | } catch { |
| 274 | let settled = false; | 247 | // Cleanup reporting must not replace activation errors or undo a committed snapshot. |
| 275 | const settle = () => { | | |
| 276 | if (settled) return; | | |
| 277 | settled = true; | | |
| 278 | callbackState.active = false; | | |
| 279 | onSettled?.(); | | |
| 280 | }; | | |
| 281 | try { | | |
| 282 | const result = callbackContext.run(callbackState, invoke); | | |
| 283 | if (typeof result === "object" && result !== null && "then" in result) { | | |
| 284 | return await Promise.resolve(result).finally(settle); | | |
| 285 | } | 248 | } |
| 286 | settle(); | | |
| 287 | return result; | | |
| 288 | } catch (error) { | | |
| 289 | settle(); | | |
| 290 | throw error; | | |
| 291 | } | 249 | } |
| 292 | }, | 250 | }); |
| 293 | catch: (cause) => new PluginCallbackError({ callback, cause, pluginId }), | | |
| 294 | }); | | |
| 295 | | 251 | |
| 296 | const activatePlugin = ( | 252 | const closePlugins = ( |
| 297 | definition: PluginDefinition, | 253 | plugins: ReadonlyArray<LivePlugin>, |
| 298 | capabilities: ReadonlyMap<string, unknown>, | 254 | notifyDeactivation: boolean, |
| 299 | ): Effect.Effect<LivePlugin, PluginCallbackError> => | 255 | ): Effect.Effect<ReadonlyArray<CleanupFailure>> => |
| 300 | Effect.gen(function* () { | 256 | Effect.gen(function* () { |
| 301 | const parentScope = yield* getRuntimeScope(); | 257 | const failures: Array<CleanupFailure> = []; |
| 302 | const scope = yield* Scope.fork(parentScope, "sequential"); | 258 | for (const plugin of plugins.toReversed()) { |
| 303 | const contributions = new Map<string, Array<Contribution>>(); | 259 | const closeExit = yield* Effect.exit(Scope.close(plugin.scope, Exit.void)); |
| 304 | const cleanupErrors: Array<unknown> = []; | 260 | if (Exit.isFailure(closeExit)) { |
| 305 | const finalizers: Array<() => void | Promise<void>> = []; | 261 | failures.push({ |
| 306 | const plugin: LivePlugin = { definition, scope, contributions, cleanupErrors }; | 262 | error: Cause.squash(closeExit.cause), |
| 307 | let activating = true; | 263 | pluginId: plugin.definition.id, |
| 308 | const assertActivating = (method: "resolve" | "register" | "onDispose") => { | 264 | }); |
| 309 | if (!activating) { | 265 | } |
| 310 | throw new PluginActivationContextExpiredError({ method, pluginId: definition.id }); | 266 | for (const error of plugin.cleanupErrors.splice(0)) { |
| | 267 | failures.push({ error, pluginId: plugin.definition.id }); |
| | 268 | } |
| | 269 | if (notifyDeactivation) { |
| | 270 | yield* reportLifecycle("deactivate", plugin.definition.id); |
| | 271 | } |
| 311 | } | 272 | } |
| 312 | }; | 273 | return failures; |
| | 274 | }); |
| 313 | | 275 | |
| 314 | const context: PluginActivationContext = { | 276 | const invokePluginCallback = <Result>( |
| 315 | resolve: <Service>(capability: string): Service => { | 277 | callback: PluginCallback, |
| 316 | assertActivating("resolve"); | 278 | pluginId: string, |
| 317 | if (!(definition.requires ?? []).includes(capability)) { | 279 | invoke: () => Result | PromiseLike<Result>, |
| 318 | throw new PluginUndeclaredCapabilityError({ capability, pluginId: definition.id }); | 280 | onSettled?: () => void, |
| 319 | } | 281 | ): Effect.Effect<Result, PluginCallbackError> => |
| 320 | if (!capabilities.has(capability)) { | 282 | Effect.tryPromise({ |
| 321 | throw new PluginResolutionError({ capability, pluginId: definition.id }); | 283 | try: async () => { |
| | 284 | const callbackState: PluginCallbackContext = { active: true, callback, pluginId }; |
| | 285 | let settled = false; |
| | 286 | const settle = () => { |
| | 287 | if (settled) return; |
| | 288 | settled = true; |
| | 289 | callbackState.active = false; |
| | 290 | onSettled?.(); |
| | 291 | }; |
| | 292 | try { |
| | 293 | const result = callbackContext.run(callbackState, invoke); |
| | 294 | if (typeof result === "object" && result !== null && "then" in result) { |
| | 295 | return await Promise.resolve(result).finally(settle); |
| | 296 | } |
| | 297 | settle(); |
| | 298 | return result; |
| | 299 | } catch (error) { |
| | 300 | settle(); |
| | 301 | throw error; |
| 322 | } | 302 | } |
| 323 | return capabilities.get(capability) as Service; | | |
| 324 | }, | | |
| 325 | register: (slot, contribution) => { | | |
| 326 | assertActivating("register"); | | |
| 327 | const values = contributions.get(slot) ?? []; | | |
| 328 | values.push(Object.freeze({ id: contribution.id, label: contribution.label })); | | |
| 329 | contributions.set(slot, values); | | |
| 330 | }, | | |
| 331 | onDispose: (finalizer) => { | | |
| 332 | assertActivating("onDispose"); | | |
| 333 | finalizers.push(finalizer); | | |
| 334 | }, | 303 | }, |
| 335 | }; | 304 | catch: (cause) => new PluginCallbackError({ callback, cause, pluginId }), |
| 336 | | 305 | }); |
| 337 | const activationExit = yield* Effect.exit( | 306 | |
| 338 | invokePluginCallback( | 307 | const activatePlugin = ( |
| 339 | "activate", | 308 | definition: PluginDefinition, |
| 340 | definition.id, | 309 | capabilities: ReadonlyMap<string, unknown>, |
| 341 | () => definition.activate(context), | 310 | ): Effect.Effect<LivePlugin, PluginCallbackError> => |
| 342 | () => { | 311 | Effect.gen(function* () { |
| 343 | activating = false; | 312 | const scope = yield* Scope.fork(parentScope, "sequential"); |
| | 313 | const contributions = new Map<string, Array<Contribution>>(); |
| | 314 | const cleanupErrors: Array<unknown> = []; |
| | 315 | const finalizers: Array<() => void | Promise<void>> = []; |
| | 316 | const plugin: LivePlugin = { definition, scope, contributions, cleanupErrors }; |
| | 317 | let activating = true; |
| | 318 | const assertActivating = (method: "resolve" | "register" | "onDispose") => { |
| | 319 | if (!activating) { |
| | 320 | throw new PluginActivationContextExpiredError({ method, pluginId: definition.id }); |
| | 321 | } |
| | 322 | }; |
| | 323 | |
| | 324 | const context: PluginActivationContext = { |
| | 325 | resolve: <Service>(capability: string): Service => { |
| | 326 | assertActivating("resolve"); |
| | 327 | if (!(definition.requires ?? []).includes(capability)) { |
| | 328 | throw new PluginUndeclaredCapabilityError({ capability, pluginId: definition.id }); |
| | 329 | } |
| | 330 | if (!capabilities.has(capability)) { |
| | 331 | throw new PluginResolutionError({ capability, pluginId: definition.id }); |
| | 332 | } |
| | 333 | return capabilities.get(capability) as Service; |
| 344 | }, | 334 | }, |
| 345 | ), | 335 | register: (slot, contribution) => { |
| 346 | ); | 336 | assertActivating("register"); |
| 347 | for (const finalizer of finalizers) { | 337 | const values = contributions.get(slot) ?? []; |
| 348 | const finalizerEffect = invokePluginCallback("finalizer", definition.id, finalizer).pipe( | 338 | values.push(Object.freeze({ id: contribution.id, label: contribution.label })); |
| 349 | Effect.catch((error) => | 339 | contributions.set(slot, values); |
| 350 | Effect.sync(() => { | 340 | }, |
| 351 | cleanupErrors.push(error); | 341 | onDispose: (finalizer) => { |
| 352 | }), | 342 | assertActivating("onDispose"); |
| | 343 | finalizers.push(finalizer); |
| | 344 | }, |
| | 345 | }; |
| | 346 | |
| | 347 | const activationExit = yield* Effect.exit( |
| | 348 | invokePluginCallback( |
| | 349 | "activate", |
| | 350 | definition.id, |
| | 351 | () => definition.activate(context), |
| | 352 | () => { |
| | 353 | activating = false; |
| | 354 | }, |
| 353 | ), | 355 | ), |
| 354 | ); | 356 | ); |
| 355 | yield* Scope.addFinalizer(scope, finalizerEffect); | 357 | for (const finalizer of finalizers) { |
| 356 | } | 358 | const finalizerEffect = invokePluginCallback("finalizer", definition.id, finalizer).pipe( |
| 357 | | 359 | Effect.catch((error) => |
| 358 | if (Exit.isFailure(activationExit)) { | 360 | Effect.sync(() => { |
| 359 | const failures = yield* closePlugins([plugin], false); | 361 | cleanupErrors.push(error); |
| 360 | yield* reportCleanupErrors("rollback", failures); | 362 | }), |
| 361 | return yield* Effect.failCause(activationExit.cause); | 363 | ), |
| 362 | } | 364 | ); |
| 363 | return plugin; | 365 | yield* Scope.addFinalizer(scope, finalizerEffect); |
| 364 | }); | 366 | } |
| 365 | | 367 | |
| 366 | const reconcileEffect = ( | 368 | if (Exit.isFailure(activationExit)) { |
| 367 | definitions: ReadonlyArray<PluginDefinition>, | 369 | const failures = yield* closePlugins([plugin], false); |
| 368 | ): Effect.Effect<PluginRuntimeSnapshot, PluginReconcileError> => | 370 | yield* reportCleanupErrors("rollback", failures); |
| 369 | Effect.gen(function* () { | 371 | return yield* Effect.failCause(activationExit.cause); |
| 370 | if (disposed) { | 372 | } |
| 371 | return yield* new PluginRuntimeDisposedError({ operation: "reconcile" }); | 373 | return plugin; |
| 372 | } | | |
| 373 | | | |
| 374 | const plan = yield* Effect.try({ | | |
| 375 | try: () => planComposition(definitions), | | |
| 376 | catch: (error) => { | | |
| 377 | if (!isPluginPlanningError(error)) throw error; | | |
| 378 | return error; | | |
| 379 | }, | | |
| 380 | }); | 374 | }); |
| 381 | const affected = affectedPluginIds( | 375 | |
| 382 | current.plugins.map((plugin) => plugin.definition), | 376 | const reconcileEffect = ( |
| 383 | plan.definitions, | 377 | definitions: ReadonlyArray<PluginDefinition>, |
| 384 | ); | 378 | ): Effect.Effect<PluginRuntimeSnapshot, PluginRuntimeReconcileError> => |
| 385 | const currentById = new Map(current.plugins.map((plugin) => [plugin.definition.id, plugin])); | 379 | Effect.gen(function* () { |
| 386 | const capabilities = new Map<string, unknown>(); | 380 | if (disposed) { |
| 387 | for (const plugin of current.plugins) { | 381 | return yield* new PluginRuntimeDisposedError({ operation: "reconcile" }); |
| 388 | if (affected.has(plugin.definition.id)) continue; | | |
| 389 | for (const [capability, service] of Object.entries(plugin.definition.provides ?? {})) { | | |
| 390 | capabilities.set(capability, service); | | |
| 391 | } | 382 | } |
| 392 | } | 383 | |
| 393 | | 384 | const plan = yield* Effect.try({ |
| 394 | const staged = new Map<string, LivePlugin>(); | 385 | try: () => planComposition(definitions), |
| 395 | const candidateExit = yield* Effect.exit( | 386 | catch: (error) => { |
| 396 | Effect.gen(function* () { | 387 | if (!isPluginPlanningError(error)) throw error; |
| 397 | for (const definition of plan.definitions) { | 388 | return error; |
| 398 | if (!affected.has(definition.id)) continue; | 389 | }, |
| 399 | const plugin = yield* activatePlugin(definition, capabilities); | 390 | }); |
| 400 | staged.set(definition.id, plugin); | 391 | const affected = affectedPluginIds( |
| 401 | for (const [capability, service] of Object.entries(definition.provides ?? {})) { | 392 | current.plugins.map((plugin) => plugin.definition), |
| 402 | capabilities.set(capability, service); | 393 | plan.definitions, |
| 403 | } | 394 | ); |
| | 395 | const currentById = new Map( |
| | 396 | current.plugins.map((plugin) => [plugin.definition.id, plugin]), |
| | 397 | ); |
| | 398 | const capabilities = new Map<string, unknown>(); |
| | 399 | for (const plugin of current.plugins) { |
| | 400 | if (affected.has(plugin.definition.id)) continue; |
| | 401 | for (const [capability, service] of Object.entries(plugin.definition.provides ?? {})) { |
| | 402 | capabilities.set(capability, service); |
| 404 | } | 403 | } |
| | 404 | } |
| 405 | | 405 | |
| 406 | const nextPlugins: Array<LivePlugin> = []; | 406 | const staged = new Map<string, LivePlugin>(); |
| 407 | for (const definition of plan.definitions) { | 407 | const candidateExit = yield* Effect.exit( |
| 408 | const plugin = staged.get(definition.id) ?? currentById.get(definition.id); | 408 | Effect.gen(function* () { |
| 409 | if (plugin === undefined) { | 409 | for (const definition of plan.definitions) { |
| 410 | return yield* new PluginStagingError({ pluginId: definition.id }); | 410 | if (!affected.has(definition.id)) continue; |
| | 411 | const plugin = yield* activatePlugin(definition, capabilities); |
| | 412 | staged.set(definition.id, plugin); |
| | 413 | for (const [capability, service] of Object.entries(definition.provides ?? {})) { |
| | 414 | capabilities.set(capability, service); |
| | 415 | } |
| 411 | } | 416 | } |
| 412 | nextPlugins.push(plugin); | | |
| 413 | } | | |
| 414 | return { | | |
| 415 | plugins: nextPlugins, | | |
| 416 | snapshot: snapshotOf(nextPlugins, plan.blocked), | | |
| 417 | }; | | |
| 418 | }), | | |
| 419 | ); | | |
| 420 | if (Exit.isFailure(candidateExit)) { | | |
| 421 | const failures = yield* closePlugins([...staged.values()], false); | | |
| 422 | yield* reportCleanupErrors("rollback", failures); | | |
| 423 | return yield* Effect.failCause(candidateExit.cause); | | |
| 424 | } | | |
| 425 | | | |
| 426 | const previous = current.plugins.filter((plugin) => affected.has(plugin.definition.id)); | | |
| 427 | current = candidateExit.value; | | |
| 428 | for (const plugin of staged.values()) { | | |
| 429 | yield* reportLifecycle("activate", plugin.definition.id); | | |
| 430 | } | | |
| 431 | const failures = yield* closePlugins(previous, true); | | |
| 432 | yield* reportCleanupErrors("retire", failures); | | |
| 433 | return current.snapshot; | | |
| 434 | }); | | |
| 435 | | 417 | |
| 436 | const disposeEffect = (): Effect.Effect<void, PluginRuntimeCleanupError> => | 418 | const nextPlugins: Array<LivePlugin> = []; |
| 437 | Effect.gen(function* () { | 419 | for (const definition of plan.definitions) { |
| 438 | if (disposed) return; | 420 | const plugin = staged.get(definition.id) ?? currentById.get(definition.id); |
| 439 | disposed = true; | 421 | if (plugin === undefined) { |
| 440 | const previous = current.plugins; | 422 | return yield* new PluginStagingError({ pluginId: definition.id }); |
| 441 | current = { plugins: [], snapshot: emptySnapshot() }; | 423 | } |
| 442 | const failures = [...(yield* closePlugins(previous, true))]; | 424 | nextPlugins.push(plugin); |
| 443 | const parentScope = runtimeScope; | 425 | } |
| 444 | runtimeScope = undefined; | 426 | return { |
| 445 | if (parentScope !== undefined) { | 427 | plugins: nextPlugins, |
| 446 | const closeExit = yield* Effect.exit(Scope.close(parentScope, Exit.void)); | 428 | snapshot: snapshotOf(nextPlugins, plan.blocked), |
| 447 | if (Exit.isFailure(closeExit)) { | 429 | }; |
| 448 | failures.push({ error: Cause.squash(closeExit.cause), pluginId: "plugin-runtime" }); | 430 | }), |
| | 431 | ); |
| | 432 | if (Exit.isFailure(candidateExit)) { |
| | 433 | const failures = yield* closePlugins([...staged.values()], false); |
| | 434 | yield* reportCleanupErrors("rollback", failures); |
| | 435 | return yield* Effect.failCause(candidateExit.cause); |
| 449 | } | 436 | } |
| 450 | } | | |
| 451 | if (failures.length > 0) { | | |
| 452 | return yield* new PluginRuntimeCleanupError({ | | |
| 453 | failures: failures.map(({ error, pluginId }) => ({ cause: error, pluginId })), | | |
| 454 | }); | | |
| 455 | } | | |
| 456 | }); | | |
| 457 | | 437 | |
| 458 | const runPromiseAdapter = <Result, Failure>( | 438 | const previous = current.plugins.filter((plugin) => affected.has(plugin.definition.id)); |
| 459 | operation: RuntimeOperation, | 439 | current = candidateExit.value; |
| 460 | effect: () => Effect.Effect<Result, Failure>, | 440 | for (const plugin of staged.values()) { |
| 461 | ): Promise<Result> => { | 441 | yield* reportLifecycle("activate", plugin.definition.id); |
| 462 | const callback = callbackContext.getStore(); | 442 | } |
| 463 | if (callback?.active === true) { | 443 | const failures = yield* closePlugins(previous, true); |
| 464 | return Promise.reject( | 444 | yield* reportCleanupErrors("retire", failures); |
| 465 | new PluginRuntimeReentrancyError({ | 445 | return current.snapshot; |
| 466 | callback: callback.callback, | 446 | }); |
| 467 | operation, | 447 | |
| 468 | pluginId: callback.pluginId, | 448 | const disposeEffect = (): Effect.Effect<void, PluginRuntimeCleanupError> => |
| 469 | }), | 449 | Effect.gen(function* () { |
| 470 | ); | 450 | if (disposed) return; |
| 471 | } | 451 | disposed = true; |
| | 452 | const previous = current.plugins; |
| | 453 | current = { plugins: [], snapshot: emptySnapshot() }; |
| | 454 | const failures = [...(yield* closePlugins(previous, true))]; |
| | 455 | if (failures.length > 0) { |
| | 456 | return yield* new PluginRuntimeCleanupError({ |
| | 457 | failures: failures.map(({ error, pluginId }) => ({ cause: error, pluginId })), |
| | 458 | }); |
| | 459 | } |
| | 460 | }); |
| | 461 | |
| | 462 | const runTransition = <Result, Failure>( |
| | 463 | operation: RuntimeOperation, |
| | 464 | effect: () => Effect.Effect<Result, Failure>, |
| | 465 | ): Effect.Effect<Result, Failure | PluginRuntimeReentrancyError> => |
| | 466 | Effect.suspend<Result, Failure | PluginRuntimeReentrancyError, never>(() => { |
| | 467 | const callback = callbackContext.getStore(); |
| | 468 | if (callback?.active === true) { |
| | 469 | return Effect.fail( |
| | 470 | new PluginRuntimeReentrancyError({ |
| | 471 | callback: callback.callback, |
| | 472 | operation, |
| | 473 | pluginId: callback.pluginId, |
| | 474 | }), |
| | 475 | ); |
| | 476 | } |
| | 477 | return transitionSemaphore.withPermits(1)(effect()); |
| | 478 | }); |
| 472 | | 479 | |
| 473 | const result = transition.then(() => Effect.runPromise(effect())); | 480 | yield* Effect.addFinalizer(() => |
| 474 | transition = result.then( | 481 | disposeEffect().pipe( |
| 475 | () => undefined, | 482 | Effect.catch((error) => |
| 476 | () => undefined, | 483 | reportCleanupErrors( |
| | 484 | "retire", |
| | 485 | error.failures.map(({ cause, pluginId }) => ({ error: cause, pluginId })), |
| | 486 | ), |
| | 487 | ), |
| | 488 | ), |
| 477 | ); | 489 | ); |
| 478 | return result; | 490 | |
| 479 | }; | 491 | return { |
| 480 | | 492 | reconcile: (definitions) => { |
| 481 | return { | 493 | let desired: ReadonlyArray<PluginDefinition>; |
| 482 | reconcile: (definitions) => { | 494 | try { |
| 483 | let desired: ReadonlyArray<PluginDefinition>; | 495 | desired = snapshotDefinitions(definitions); |
| 484 | try { | 496 | } catch (error) { |
| 485 | desired = snapshotDefinitions(definitions); | 497 | return Effect.die(error); |
| 486 | } catch (error) { | 498 | } |
| 487 | return Promise.reject(error); | 499 | return runTransition("reconcile", () => reconcileEffect(desired)); |
| 488 | } | 500 | }, |
| 489 | return runPromiseAdapter("reconcile", () => reconcileEffect(desired)); | 501 | snapshot: Effect.sync(() => current.snapshot), |
| 490 | }, | 502 | dispose: runTransition("dispose", disposeEffect), |
| 491 | snapshot: () => current.snapshot, | 503 | } satisfies PluginRuntime["Service"]; |
| 492 | dispose: () => runPromiseAdapter("dispose", disposeEffect), | 504 | }); |
| 493 | }; | 505 | |
| 494 | }; | 506 | export const layer = (options: PluginRuntimeOptions = {}) => |
| | 507 | Layer.effect(PluginRuntime, make(options)); |