From e84bd84f9b1b1ae63b3aac0fed94a9e56390bb27 Mon Sep 17 00:00:00 2001 From: SandroMaglione Date: Sun, 6 Sep 2026 20:38:35 +0200 Subject: [PATCH 1/2] Clarify machine implementation ownership and runtime strategies --- .changeset/clear-machine-ownership.md | 7 + CONTRIBUTING.md | 20 + packages/effect-machine/README.md | 5 +- packages/effect-machine/src/Machine.ts | 29 +- .../src/internal/machine/childRegistry.ts | 2 +- .../src/internal/machine/cluster.ts | 80 +- .../src/internal/machine/clusterProtocol.ts | 56 + .../src/internal/machine/commandRuntime.ts | 2 +- .../src/internal/machine/configuration.ts | 98 +- .../src/internal/machine/executionPlan.ts | 11 +- .../src/internal/machine/implementation.ts | 41 + .../src/internal/machine/invocation.ts | 2 +- .../src/internal/machine/machine.ts | 11 +- .../src/internal/machine/planner.ts | 23 +- .../src/internal/machine/process.ts | 73 +- .../src/internal/machine/runtime.ts | 3055 +---------------- .../src/internal/machine/runtimeCompiled.ts | 1166 +++++++ .../src/internal/machine/runtimeGeneric.ts | 799 +++++ .../src/internal/machine/runtimeProtocol.ts | 1200 +++++++ .../src/internal/machine/topology.ts | 3 +- .../src/internal/testing/machine/probe.ts | 2 +- .../src/unstable/cluster/ClusterMachine.ts | 26 +- .../effect-machine/test/examples/guard.ts | 16 + .../internal/machine/processLifecycle.test.ts | 46 +- .../test/machine/SnapshotStructure.test.ts | 37 + .../unstable/cluster/ClusterMachine.tst.ts | 18 + scripts/api-reference/examples.test.mjs | 9 +- scripts/check-architecture.mjs | 21 +- scripts/check-architecture.test.mjs | 11 + 29 files changed, 3604 insertions(+), 3265 deletions(-) create mode 100644 .changeset/clear-machine-ownership.md create mode 100644 packages/effect-machine/src/internal/machine/clusterProtocol.ts create mode 100644 packages/effect-machine/src/internal/machine/implementation.ts create mode 100644 packages/effect-machine/src/internal/machine/runtimeCompiled.ts create mode 100644 packages/effect-machine/src/internal/machine/runtimeGeneric.ts create mode 100644 packages/effect-machine/src/internal/machine/runtimeProtocol.ts create mode 100644 packages/effect-machine/test/examples/guard.ts create mode 100644 packages/effect-machine/test/machine/SnapshotStructure.test.ts diff --git a/.changeset/clear-machine-ownership.md b/.changeset/clear-machine-ownership.md new file mode 100644 index 0000000..bc2c350 --- /dev/null +++ b/.changeset/clear-machine-ownership.md @@ -0,0 +1,7 @@ +--- +"@typeonce/effect-machine": patch +--- + +Clarify declarative guards in the machine documentation and add a checked example of guarded state construction. + +Improve internal typing and organization for machine definitions, execution strategies, snapshot validation, and Cluster contracts. Existing public APIs and machine behavior remain unchanged. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fc55094..9d5aa4f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,6 +42,26 @@ and process layers. Testing implementations are isolated under `packages/effect-machine/src/internal/testing` and may only be consumed by the public testing module or other testing internals. +`implementation.ts` owns the captured handler representation. Public handler +callbacks author transitions through selectors; captured handlers contain the +executable transitions. Semantic layers use `toImpl` to access that representation +instead of treating the public erased handler fields as `any`. Existing public +fields and type parameters remain available for compatibility. + +Runtime responsibilities are separated as follows: + +- `runtime.ts` allocates a root runtime and selects an execution strategy. +- `runtimeProtocol.ts` owns shared process contracts, child ownership, mailboxes, + and observation. It receives startup functions from the coordinator so child + startup does not create a dependency back to either execution strategy. +- `runtimeGeneric.ts` runs the general Effect worker and supervisor. +- `runtimeCompiled.ts` runs compiled statecharts, including synchronous owned + child startup. It preserves the same lifecycle contract as the generic runtime. + +Consumers import directly from the module that owns a contract. Runtime modules +must remain independent of planning and statechart semantics. The Cluster adapter +similarly shares its checkpoint service and wire schemas through `clusterProtocol.ts`. + `pnpm check:architecture` builds a TypeScript dependency graph using the project's NodeNext resolver. It distinguishes type-only and runtime edges, understands imports, re-exports, and dynamic imports, and enforces: diff --git a/packages/effect-machine/README.md b/packages/effect-machine/README.md index dffbe82..b41c8e6 100644 --- a/packages/effect-machine/README.md +++ b/packages/effect-machine/README.md @@ -939,8 +939,9 @@ pnpm install --frozen-lockfile pnpm check ``` -Declarative first-class guards are not currently part of the API; use ordinary -TypeScript conditions. Pull requests that change `src/` or `package.json` need +Use `.guard(predicate)` to decline a transition before state construction, or +`.resolve(..., { declinable: true })` to use `decline()` during resolution. +Pull requests that change `src/` or `package.json` need a changeset and the performance checks described in `AGENTS.md`. When equivalent Machine modules ship in Effect, this package is intended to diff --git a/packages/effect-machine/src/Machine.ts b/packages/effect-machine/src/Machine.ts index 973e079..251f8cd 100644 --- a/packages/effect-machine/src/Machine.ts +++ b/packages/effect-machine/src/Machine.ts @@ -28,7 +28,7 @@ import * as internal from "./internal/machine/machine.js" import { InitialEventTypeId } from "./internal/machine/machine.js" import type { EnsureExecutable } from "./internal/machine/readiness.js" import type { ExcludeCompatibleRuntime } from "./internal/machine/requirements.js" -import type * as internalRuntime from "./internal/machine/runtime.js" +import type * as internalRuntime from "./internal/machine/runtimeProtocol.js" import type * as StateDefinition from "./internal/machine/stateDefinition.js" import type * as Topology from "./internal/machine/topology.js" @@ -111,9 +111,30 @@ type IsAny = 0 extends (1 & A) ? true : false * * **Gotchas** * - * Declarative first-class guards are not part of the current API. Conditional - * behavior can be expressed in typed handlers with ordinary TypeScript control - * flow. Use `after` for cancellable state-scoped delayed events. + * Use `.guard(predicate)` to decline a transition before constructing its next + * state. Use `.resolve(..., { declinable: true })` when conditional resolution + * needs to return `decline()`. Use `after` for cancellable state-scoped delays. + * + * **Example** + * + * ```ts + * import { Machine } from "@typeonce/effect-machine" + * import { Schema } from "effect" + * + * const events = Machine.events({ Add: { by: Schema.Number } }) + * export const counter = Machine.make({ + * root: Machine.state({ fields: { count: Schema.Number } }), + * events, + * initial: (root) => root.from(() => ({ count: 0 })) + * }).handle({ + * on: { + * Add: (to) => + * to.self.update.guard(({ event }) => event.by > 0).from(({ current, event }) => ({ + * count: current.count + event.by + * })) + * } + * }) + * ``` * * @category models * @since 0.4.0 diff --git a/packages/effect-machine/src/internal/machine/childRegistry.ts b/packages/effect-machine/src/internal/machine/childRegistry.ts index 90e6a97..38be386 100644 --- a/packages/effect-machine/src/internal/machine/childRegistry.ts +++ b/packages/effect-machine/src/internal/machine/childRegistry.ts @@ -3,7 +3,7 @@ import * as Deferred from "effect/Deferred" import * as Effect from "effect/Effect" import * as Option from "effect/Option" import type * as Scope from "effect/Scope" -import type { MachineRef } from "./runtime.js" +import type { MachineRef } from "./runtimeProtocol.js" export type ChildDescriptor = { readonly id: string diff --git a/packages/effect-machine/src/internal/machine/cluster.ts b/packages/effect-machine/src/internal/machine/cluster.ts index 9c47fc1..d34b4ac 100644 --- a/packages/effect-machine/src/internal/machine/cluster.ts +++ b/packages/effect-machine/src/internal/machine/cluster.ts @@ -4,92 +4,26 @@ * @since 0.4.0 */ import * as Cause from "effect/Cause" -import * as Context from "effect/Context" import * as Effect from "effect/Effect" import * as Layer from "effect/Layer" import * as Option from "effect/Option" import * as Schema from "effect/Schema" -import { ClusterError, ClusterSchema, Entity, EntityAddress, MessageStorage, Snowflake } from "effect/unstable/cluster" +import { ClusterSchema, Entity, MessageStorage } from "effect/unstable/cluster" +import type { EntityAddress, Snowflake } from "effect/unstable/cluster" import { Rpc } from "effect/unstable/rpc" import type * as Machine from "../../Machine.js" -import type { Checkpoint, ClusterMachine, LoadResult } from "../../unstable/cluster/ClusterMachine.js" +import type { Checkpoint } from "../../unstable/cluster/ClusterMachine.js" +import type { ClusterMachine } from "../../unstable/cluster/ClusterMachine.js" +import { Accepted, CommitResult, Rejected, type RejectionReason, SendResult, Storage } from "./clusterProtocol.js" +import { toImpl } from "./implementation.js" import * as internalMachine from "./machine.js" import * as Protocol from "./protocol.js" import type { EnsureExecutable } from "./readiness.js" import type { ExcludeCompatibleRuntime } from "./requirements.js" type EntityAddress = EntityAddress.EntityAddress -type PersistenceError = ClusterError.PersistenceError type Snowflake = Snowflake.Snowflake -export type CommitResult = CommitResult.Committed | CommitResult.Duplicate - -export const CommitResult = { - Committed: (): CommitResult.Committed => ({ _tag: "Committed" }), - Duplicate: (): CommitResult.Duplicate => ({ _tag: "Duplicate" }) -} - -export declare namespace CommitResult { - /** - * Indicates that the request id and checkpoint were committed atomically. - * - * @category models - * @since 0.4.0 - */ - export interface Committed { - readonly _tag: "Committed" - } - - /** - * Indicates that the request id was already committed. - * - * @category models - * @since 0.4.0 - */ - export interface Duplicate { - readonly _tag: "Duplicate" - } -} - -export class Storage extends Context.Service Effect.Effect - readonly commit: ( - address: EntityAddress, - checkpoint: Checkpoint - ) => Effect.Effect -}>()("effect/cluster/ClusterMachine/Storage") {} - -export class Accepted extends Schema.TaggedClass("effect/cluster/ClusterMachine/Accepted")( - "Accepted", - {} -) {} - -export const RejectionReason = Schema.Literals([ - "MachineIdMismatch", - "VersionMismatch", - "InvalidCheckpoint", - "UnsupportedProcessLocal", - "TransitionFailure", - "SnapshotEncodeFailure", - "PersistenceFailure", - "EmissionFailure" -]) - -export type RejectionReason = typeof RejectionReason.Type - -export class Rejected extends Schema.TaggedClass("effect/cluster/ClusterMachine/Rejected")( - "Rejected", - { - reason: RejectionReason, - message: Schema.String - } -) {} - -export const SendResult = Schema.Union([Accepted, Rejected]) - type SendRpc> = Rpc.Rpc< "send", Schema.Union, @@ -101,7 +35,7 @@ type MachineEvents = Machine.Machine.InputEvents< type MachineEmits = Machine.Machine.EmittedEvents const hasInvokes = (machine: Machine.Machine.Any): boolean => - Reflect.ownKeys(machine.handlers).some((key) => machine.handlers[key as string]?.invoke !== undefined) + Reflect.ownKeys(toImpl(machine).handlers).some((key) => toImpl(machine).handlers[key as string]?.invoke !== undefined) const reject = (reason: RejectionReason, message: string): Rejected => new Rejected({ reason, message }) diff --git a/packages/effect-machine/src/internal/machine/clusterProtocol.ts b/packages/effect-machine/src/internal/machine/clusterProtocol.ts new file mode 100644 index 0000000..5d0cad2 --- /dev/null +++ b/packages/effect-machine/src/internal/machine/clusterProtocol.ts @@ -0,0 +1,56 @@ +/** Canonical checkpoint service and wire schemas for the Cluster adapter. */ +import * as Context from "effect/Context" +import type * as Effect from "effect/Effect" +import * as Schema from "effect/Schema" +import type { ClusterError, EntityAddress, Snowflake } from "effect/unstable/cluster" +import type * as Public from "../../unstable/cluster/ClusterMachine.js" +import type { Checkpoint, LoadResult } from "../../unstable/cluster/ClusterMachine.js" +type EntityAddress = EntityAddress.EntityAddress +type PersistenceError = ClusterError.PersistenceError +type Snowflake = Snowflake.Snowflake + +export type CommitResult = Public.CommitResult + +export const CommitResult = { + Committed: (): Public.CommitResult.Committed => ({ _tag: "Committed" }), + Duplicate: (): Public.CommitResult.Duplicate => ({ _tag: "Duplicate" }) +} + +export class Storage extends Context.Service Effect.Effect + readonly commit: ( + address: EntityAddress, + checkpoint: Checkpoint + ) => Effect.Effect +}>()("effect/cluster/ClusterMachine/Storage") {} + +export class Accepted extends Schema.TaggedClass("effect/cluster/ClusterMachine/Accepted")( + "Accepted", + {} +) {} + +export const RejectionReason = Schema.Literals([ + "MachineIdMismatch", + "VersionMismatch", + "InvalidCheckpoint", + "UnsupportedProcessLocal", + "TransitionFailure", + "SnapshotEncodeFailure", + "PersistenceFailure", + "EmissionFailure" +]) + +export type RejectionReason = typeof RejectionReason.Type + +export class Rejected extends Schema.TaggedClass("effect/cluster/ClusterMachine/Rejected")( + "Rejected", + { + reason: RejectionReason, + message: Schema.String + } +) {} + +export const SendResult = Schema.Union([Accepted, Rejected]) diff --git a/packages/effect-machine/src/internal/machine/commandRuntime.ts b/packages/effect-machine/src/internal/machine/commandRuntime.ts index 04df1f4..ed17cac 100644 --- a/packages/effect-machine/src/internal/machine/commandRuntime.ts +++ b/packages/effect-machine/src/internal/machine/commandRuntime.ts @@ -8,7 +8,7 @@ import * as Effect from "effect/Effect" import type { Machine, Runtime } from "../../Machine.js" import type { RuntimeCommand } from "./command.js" import { decodeEmit, decodeEvent } from "./protocol.js" -import type { ProcessScope } from "./runtime.js" +import type { ProcessScope } from "./runtimeProtocol.js" export const makeLiveRuntime = ( machine: Machine.Any, diff --git a/packages/effect-machine/src/internal/machine/configuration.ts b/packages/effect-machine/src/internal/machine/configuration.ts index 4b20a15..e841954 100644 --- a/packages/effect-machine/src/internal/machine/configuration.ts +++ b/packages/effect-machine/src/internal/machine/configuration.ts @@ -10,6 +10,7 @@ import * as Option from "effect/Option" import { hasProperty } from "effect/Predicate" import type { Machine, MachineTarget } from "../../Machine.js" import { MachineSchemaDecodeError } from "./errors.js" +import { type CapturedStateConfig, toImpl } from "./implementation.js" import { decodeBoundary, decodeOutputValue, @@ -485,6 +486,51 @@ export const snapshotFromConfigurationAtPath = +): Machine.AtomicSnapshot => { + if (!hasProperty(current, "state") || !isSnapshot(current.state)) { + throw new Error(`Machine expected compound snapshot "${node.path}" to include an active child state`) + } + const child = getNode(machine, String(current.state.path)) + if (child.parent !== node.path) { + throw new Error(`Machine expected snapshot "${child.path}" to be a child of "${node.path}"`) + } + return current.state +} + +const parallelSnapshotRegions = ( + node: Machine.StateNode, + current: Machine.AtomicSnapshot +): Readonly> => { + if (!hasProperty(current, "states") || typeof current.states !== "object" || current.states === null) { + throw new Error(`Machine expected parallel snapshot "${node.path}" to include active child regions`) + } + return current.states as Readonly> +} + +const parallelSnapshotChild = ( + machine: Machine.Any, + node: Machine.StateNode, + childPath: string, + states: Readonly> +): Machine.AtomicSnapshot => { + const child = getNode(machine, childPath) + const childSnapshot = states[child.key] + if (!hasOwn(states, child.key) || !isSnapshot(childSnapshot)) { + throw new Error(`Machine expected parallel snapshot "${node.path}" to include region "${child.key}"`) + } + const snapshotChild = getNode(machine, String(childSnapshot.path)) + if (snapshotChild.path !== child.path) { + throw new Error(`Machine expected snapshot "${snapshotChild.path}" to be region "${child.path}"`) + } + return childSnapshot +} + export const configurationFromSnapshot = ( machine: Machine.Any, snapshot: Machine.AtomicSnapshot @@ -504,31 +550,12 @@ export const configurationFromSnapshot = ( values.set(node.path, decodeStateValueSync(machine, node, current.value)) } if (node.type === "compound") { - if (!hasProperty(current, "state") || !isSnapshot(current.state)) { - throw new Error(`Machine expected compound snapshot "${node.path}" to include an active child state`) - } - const child = getNode(machine, String(current.state.path)) - if (child.parent !== node.path) { - throw new Error(`Machine expected snapshot "${child.path}" to be a child of "${node.path}"`) - } - visit(current.state) + visit(compoundSnapshotChild(machine, node, current)) } if (node.type === "parallel") { - if (!hasProperty(current, "states") || typeof current.states !== "object" || current.states === null) { - throw new Error(`Machine expected parallel snapshot "${node.path}" to include active child regions`) - } - const states = current.states as Readonly> + const states = parallelSnapshotRegions(node, current) for (const childPath of node.children) { - const child = getNode(machine, childPath) - const childSnapshot = states[child.key] - if (!hasOwn(states, child.key) || !isSnapshot(childSnapshot)) { - throw new Error(`Machine expected parallel snapshot "${node.path}" to include region "${child.key}"`) - } - const snapshotChild = getNode(machine, String(childSnapshot.path)) - if (snapshotChild.path !== child.path) { - throw new Error(`Machine expected snapshot "${snapshotChild.path}" to be region "${child.path}"`) - } - visit(childSnapshot) + visit(parallelSnapshotChild(machine, node, childPath, states)) } } } @@ -591,31 +618,12 @@ export const configurationFromSnapshotEffect = Effect.fnUntraced(function*( values.set(node.path, yield* decodeStateValue(machine, node, current.value)) } if (node.type === "compound") { - if (!hasProperty(current, "state") || !isSnapshot(current.state)) { - throw new Error(`Machine expected compound snapshot "${node.path}" to include an active child state`) - } - const child = getNode(machine, String(current.state.path)) - if (child.parent !== node.path) { - throw new Error(`Machine expected snapshot "${child.path}" to be a child of "${node.path}"`) - } - yield* visit(current.state) + yield* visit(compoundSnapshotChild(machine, node, current)) } if (node.type === "parallel") { - if (!hasProperty(current, "states") || typeof current.states !== "object" || current.states === null) { - throw new Error(`Machine expected parallel snapshot "${node.path}" to include active child regions`) - } - const states = current.states as Readonly> + const states = parallelSnapshotRegions(node, current) for (const childPath of node.children) { - const child = getNode(machine, childPath) - const childSnapshot = states[child.key] - if (!hasOwn(states, child.key) || !isSnapshot(childSnapshot)) { - throw new Error(`Machine expected parallel snapshot "${node.path}" to include region "${child.key}"`) - } - const snapshotChild = getNode(machine, String(childSnapshot.path)) - if (snapshotChild.path !== child.path) { - throw new Error(`Machine expected snapshot "${snapshotChild.path}" to be region "${child.path}"`) - } - yield* visit(childSnapshot) + yield* visit(parallelSnapshotChild(machine, node, childPath, states)) } } }) @@ -1140,7 +1148,7 @@ export const normalizeTargetConfigurationSync = machine.handlers[path] +): CapturedStateConfig | undefined => toImpl(machine).handlers[path] export const getActiveChildPath = ( machine: Machine.Any, diff --git a/packages/effect-machine/src/internal/machine/executionPlan.ts b/packages/effect-machine/src/internal/machine/executionPlan.ts index 932320a..e2a64c9 100644 --- a/packages/effect-machine/src/internal/machine/executionPlan.ts +++ b/packages/effect-machine/src/internal/machine/executionPlan.ts @@ -24,6 +24,7 @@ import { withMachineReferences } from "./configuration.js" import { InfiniteTransitionError, StoppedError } from "./errors.js" +import { type CapturedStateConfig, toImpl } from "./implementation.js" import * as InvocationEvent from "./invocationEvent.js" import { broadenTransitionBoundary, @@ -114,7 +115,7 @@ const indexedStateConfigKeys: ReadonlySet = new Set([ // Fail closed so a newly introduced semantic field must explicitly opt into // indexed execution instead of being accepted before the kernel supports it. -const supportsIndexedStateConfig = (config: Machine.AnyStateConfig | undefined): boolean => { +const supportsIndexedStateConfig = (config: CapturedStateConfig | undefined): boolean => { if (config === undefined) { return true } @@ -149,7 +150,7 @@ const compileIndexedExecutionDescriptor = ( finalPaths.push(node.path) } - const config = machine.handlers[node.path] as Machine.AnyStateConfig | undefined + const config = toImpl(machine).handlers[node.path] as CapturedStateConfig | undefined if (!supportsIndexedStateConfig(config)) { return undefined } @@ -212,7 +213,7 @@ const compileIndexedExecutionDescriptor = ( // the hierarchical planner so their entry and exit boundaries stay explicit. flat: nodes.every((node) => node.parent === undefined && (node.type === "atomic" || node.type === "final")) || ( nodes[0]?.path === "" && nodes[0].type === "compound" && nodes[0].schema === undefined && - (machine.handlers[""] === undefined || Reflect.ownKeys(machine.handlers[""]).length === 0) && + (toImpl(machine).handlers[""] === undefined || Reflect.ownKeys(toImpl(machine).handlers[""]!).length === 0) && nodes.slice(1).every((node) => node.parent === "" && (node.type === "atomic" || node.type === "final")) && [...transitionsByPath.values()].every((events) => [...events.values()].every((transition) => @@ -816,7 +817,7 @@ const planIndexedFlatState = ( } const sourcePath = descriptor.nodes[sourceIndex]!.path - const transition = normalizeTransition(machine.handlers[sourcePath]?.on?.[event._tag]) + const transition = normalizeTransition(toImpl(machine).handlers[sourcePath]?.on?.[event._tag]) if (transition !== undefined) { const transitionResult = collectIndexedTransition( machine, @@ -1131,7 +1132,7 @@ const makeIndexedExecutionPlan = ( planIndexedState(machine, indexed, state as OwnedIndexedState, event, retainMicrosteps, machineReferences), // Initializers may enqueue commands and emissions. Managed startup owns that // work through the generic initial planner; indexed event execution remains valid. - ...(Object.values(machine.handlers as Record).some((config) => + ...(Object.values(toImpl(machine).handlers as Record).some((config) => "initialize" in config ) ? {} : diff --git a/packages/effect-machine/src/internal/machine/implementation.ts b/packages/effect-machine/src/internal/machine/implementation.ts new file mode 100644 index 0000000..7733dd6 --- /dev/null +++ b/packages/effect-machine/src/internal/machine/implementation.ts @@ -0,0 +1,41 @@ +import type { Enqueue, Machine } from "../../Machine.js" +import type { InvocationDefinition } from "./invocationDefinition.js" +import type { EventTransition } from "./transition.js" + +// Callback contexts are erased after the public builder checks their dependent +// state/event types. Stored transitions are evaluated callbacks, not the public +// selector factories accepted by Definition.handle. +type Transition = EventTransition +type Action = (context: any, enqueue: Enqueue) => undefined + +export interface CapturedStateConfig { + readonly entry?: Action + readonly exit?: Action + readonly on?: Readonly> + readonly always?: Transition + readonly onDone?: Transition + readonly choice?: Exclude unknown> + readonly invoke?: InvocationDefinition | ReadonlyArray + readonly initialize?: (context: any) => unknown + readonly output?: (context: any) => unknown + readonly history?: Readonly< + Record) => Machine.HandlerResult + }> + > +} + +/** + * Captured machine implementation consumed by semantic layers. + * + * The public erased view retains its existing fields for compatibility. Internal + * code enters through this view so handler lookup cannot silently produce `any`. + * Construction captures these containers before handing the machine to a planner. + */ +export interface MachineInternal extends Machine.Any { + readonly handlers: Readonly> + readonly makeTargetBuilder: (source: string) => Machine.TargetBuilder +} + +/** The single conversion from an erased public machine to its captured implementation. */ +export const toImpl = (machine: Machine.Any): MachineInternal => machine as MachineInternal diff --git a/packages/effect-machine/src/internal/machine/invocation.ts b/packages/effect-machine/src/internal/machine/invocation.ts index f28da8c..933c48d 100644 --- a/packages/effect-machine/src/internal/machine/invocation.ts +++ b/packages/effect-machine/src/internal/machine/invocation.ts @@ -13,7 +13,7 @@ import { InfiniteTransitionError, MachineSchemaDecodeError, StoppedError } from import * as InvocationDefinition from "./invocationDefinition.js" import * as InvocationEvent from "./invocationEvent.js" import * as Planner from "./planner.js" -import * as Runtime from "./runtime.js" +import * as Runtime from "./runtimeProtocol.js" import { ChildMachineLogicTypeId } from "./symbols.js" /** @internal */ diff --git a/packages/effect-machine/src/internal/machine/machine.ts b/packages/effect-machine/src/internal/machine/machine.ts index f890e25..ffaf5b2 100644 --- a/packages/effect-machine/src/internal/machine/machine.ts +++ b/packages/effect-machine/src/internal/machine/machine.ts @@ -29,13 +29,14 @@ import type { import * as Activities from "./activities.js" import * as Configuration from "./configuration.js" import type { ChildAlreadyExistsError, InfiniteTransitionError, StartupError } from "./errors.js" +import type { CapturedStateConfig } from "./implementation.js" import * as InvocationDefinition from "./invocationDefinition.js" import * as internalPlanner from "./planner.js" import * as internalProcess from "./process.js" import * as Protocol from "./protocol.js" import type { EnsureExecutable } from "./readiness.js" import type { ExcludeCompatibleRuntime } from "./requirements.js" -import * as internalRuntime from "./runtime.js" +import * as internalRuntime from "./runtimeProtocol.js" import * as Serialization from "./serialization.js" import * as StateDefinition from "./stateDefinition.js" import { ChildMachineLogicTypeId } from "./symbols.js" @@ -92,7 +93,7 @@ const Proto = { const makeWithHandlers = ( self: Definition.Any, - handlers: Machine.StateConfigs + handlers: Readonly> ): Machine.Any => { const machine = Object.create(Proto) machine.states = self.states @@ -1059,7 +1060,7 @@ const captureInvokeDefinition = ( } const flattenHandlers = ( - handlers: Record, + handlers: Record, stateNodes: Machine.StateNodes, states: Machine.StateTree, prefix: string, @@ -1111,7 +1112,7 @@ const flattenHandlers = ( throw new Error(`Machine choice state "${path}" requires a transition`) } } - handlers[path] = stateConfig as Machine.AnyStateConfig + handlers[path] = stateConfig as CapturedStateConfig if (childConfig !== undefined) { const node = Topology.getStateNodeDefinition(path, states[key]!) if (node.states === undefined) { @@ -1127,7 +1128,7 @@ const flattenHandlers = ( const makeHandle = (self: Definition.Any): Definition.Any["handle"] => ((config: Record) => { - const handlers: Record = Object.create(null) + const handlers: Record = Object.create(null) flattenHandlers(handlers, self.stateNodes, self.states, "", { "": config }) return makeWithHandlers(self, handlers) }) as Definition.Any["handle"] diff --git a/packages/effect-machine/src/internal/machine/planner.ts b/packages/effect-machine/src/internal/machine/planner.ts index f2caf4d..c1031be 100644 --- a/packages/effect-machine/src/internal/machine/planner.ts +++ b/packages/effect-machine/src/internal/machine/planner.ts @@ -39,6 +39,7 @@ import { validateInitialConfiguration } from "./configuration.js" import { InfiniteTransitionError, MachineSchemaDecodeError, StartupError, StoppedError } from "./errors.js" +import { type CapturedStateConfig, toImpl } from "./implementation.js" import { getStateInitializeValues, makeStateInitializeBuilder } from "./initialization.js" import * as InvocationDefinition from "./invocationDefinition.js" import * as InvocationEvent from "./invocationEvent.js" @@ -330,7 +331,7 @@ const completeHistoryConfiguration = ( } active.add(child.path) if (child.schema !== undefined) { - const initializer = machine.handlers[path]?.initialize + const initializer = toImpl(machine).handlers[path]?.initialize if (initializer === undefined) { values.set(child.path, decodeStateValueSync(machine, child, makeStateInput({}))) changed = true @@ -363,7 +364,7 @@ const completeHistoryConfiguration = ( history: configuration.history, machineReferences: configuration.machineReferences } as ActiveConfiguration - const initializer = valuedMissing.length === 0 ? undefined : machine.handlers[path]?.initialize + const initializer = valuedMissing.length === 0 ? undefined : toImpl(machine).handlers[path]?.initialize const initialized = initializer === undefined ? undefined : collectStateInitializer(machine, initializer, { ...resolveMachineReferences(machine, current), state: current.values.get(path), @@ -472,7 +473,7 @@ function resolveHistoryTarget( } const key = node.key - const fallback = machine.handlers[target.parent]?.history?.[key]?.default + const fallback = toImpl(machine).handlers[target.parent]?.history?.[key]?.default if (fallback === undefined) { throw new Error(`Machine history state "${target.path}" requires a default implementation`) } @@ -766,7 +767,7 @@ const collectStateActions = < R >( machine, - machine.handlers[path]?.[key], + toImpl(machine).handlers[path]?.[key], makeStateActionContext>( machine, configuration, @@ -813,7 +814,7 @@ const selectAlwaysTransitions = < const capturedSnapshot = () => snapshot ??= snapshotFromConfiguration(machine, configuration) for (const leaf of getActiveLeafPaths(machine, configuration)) { for (const path of getLeafCandidatePaths(machine, leaf)) { - const always = normalizeTransition(machine.handlers[path]?.always) + const always = normalizeTransition(toImpl(machine).handlers[path]?.always) if (always !== undefined) { let candidate = evaluatedSources.get(path) if (!evaluatedSources.has(path)) { @@ -891,7 +892,7 @@ const selectDoneTransitions = < let snapshot: Machine.Snapshot | undefined const capturedSnapshot = () => snapshot ??= snapshotFromConfiguration(machine, configuration) for (const completion of completions) { - const onDone = normalizeTransition(machine.handlers[completion.path]?.onDone) + const onDone = normalizeTransition(toImpl(machine).handlers[completion.path]?.onDone) if (onDone !== undefined && !selectedSources.has(completion.path)) { selectedSources.add(completion.path) const candidate = resolveDeclinableCandidate(machine, { @@ -959,7 +960,7 @@ const selectEventTransitions = < const capturedSnapshot = () => snapshot ??= snapshotFromConfiguration(machine, configuration) for (const leaf of getActiveLeafPaths(machine, configuration)) { for (const path of getLeafCandidatePaths(machine, leaf)) { - const transition = normalizeTransition(machine.handlers[path]?.on?.[event._tag]) + const transition = normalizeTransition(toImpl(machine).handlers[path]?.on?.[event._tag]) if (transition !== undefined) { let candidate = evaluatedSources.get(path) if (!evaluatedSources.has(path)) { @@ -1015,7 +1016,7 @@ const selectInvocationTransition = < event: InvocationEvent.InvocationEvent ): ReadonlyArray> => { if (!configuration.active.has(event.path)) return [] - const config = machine.handlers[event.path] as Machine.AnyStateConfig | undefined + const config = toImpl(machine).handlers[event.path] as CapturedStateConfig | undefined const invoke = InvocationDefinition.definitions(config?.invoke).find((definition) => { const id = "child" in definition ? definition.child?.id : definition.id return String(id) === event.id @@ -1265,7 +1266,7 @@ function resolveChoiceTarget( if (node.type !== "choice" || node.parent !== extracted.target.parent) { throw new Error(`Machine expected choice target "${extracted.target.path}" to resolve to its declared parent`) } - const choice = machine.handlers[node.path]?.choice + const choice = toImpl(machine).handlers[node.path]?.choice if (choice === undefined || typeof choice.transition !== "function") { throw new Error(`Machine choice state "${node.path}" requires an implementation`) } @@ -1876,7 +1877,7 @@ export const enabled = < const tags: Array> = [] const seen = new Set() for (const path of getCandidatePaths(machine, configuration)) { - for (const tag of Reflect.ownKeys(machine.handlers[path]?.on ?? {})) { + for (const tag of Reflect.ownKeys(toImpl(machine).handlers[path]?.on ?? {})) { if (!seen.has(tag)) { seen.add(tag) tags.push(tag as Machine.TagOf) @@ -2090,7 +2091,7 @@ const settle = < const completed = completeConfigurationSync(machine, currentState, currentEvent) currentState = completed.configuration pendingCompletions.push( - ...completed.completions.filter((completion) => machine.handlers[completion.path]?.onDone !== undefined) + ...completed.completions.filter((completion) => toImpl(machine).handlers[completion.path]?.onDone !== undefined) ) while (pendingCompletions.length > 0 && !currentState.active.has(pendingCompletions[0]!.path)) { pendingCompletions.shift() diff --git a/packages/effect-machine/src/internal/machine/process.ts b/packages/effect-machine/src/internal/machine/process.ts index b83777f..47c17ea 100644 --- a/packages/effect-machine/src/internal/machine/process.ts +++ b/packages/effect-machine/src/internal/machine/process.ts @@ -14,10 +14,12 @@ import * as Configuration from "./configuration.js" import { InfiniteTransitionError, MachineSchemaDecodeError, StartupError } from "./errors.js" import type { StoppedError } from "./errors.js" import * as ExecutionPlan from "./executionPlan.js" +import { type CapturedStateConfig, toImpl } from "./implementation.js" import * as Invocation from "./invocation.js" import * as internalPlanner from "./planner.js" import type { ExcludeCompatibleRuntime } from "./requirements.js" import * as internalRuntime from "./runtime.js" +import * as internalRuntimeProtocol from "./runtimeProtocol.js" import * as Serialization from "./serialization.js" type ProcessEntry = @@ -80,7 +82,7 @@ const hasInvokeCapability = (machine: Machine.Any): boolean => { return cached } const hasInvokes = Object.values( - machine.handlers as Record + toImpl(machine).handlers as Record ).some((config) => config.invoke !== undefined) invokeCapabilityCache.set(machine, hasInvokes) return hasInvokes @@ -90,7 +92,7 @@ const makeChildlessCompiledDrain = ( machine: Machine.Any, checkInitialFinal: boolean ): ( - context: internalRuntime.CompiledProcessContext + context: internalRuntimeProtocol.CompiledProcessContext ) => Effect.Effect, any, any> => { const executionPlan = ExecutionPlan.compileExecutionPlan(machine) return (context) => { @@ -114,7 +116,7 @@ const makeChildlessCompiledDrain = ( } const message = pending.value - const acknowledged = internalRuntime.isAcknowledgedMessage(message) + const acknowledged = internalRuntimeProtocol.isAcknowledgedMessage(message) const event = acknowledged ? message.event : message const before = current @@ -180,7 +182,7 @@ const makeChildlessCompiledDrain = ( beforeCommit.pipe(Effect.andThen(Effect.suspend(commitAndContinue))) ) }) - return internalRuntime.provideMachineRuntime(loop, context.scope) + return internalRuntimeProtocol.provideMachineRuntime(loop, context.scope) } } @@ -207,7 +209,7 @@ const makeInvokingCompiledDrain = ( machine: Machine.Any, checkInitialFinal: boolean ): ( - context: internalRuntime.CompiledProcessContext + context: internalRuntimeProtocol.CompiledProcessContext ) => Effect.Effect, any, any> => { const executionPlan = ExecutionPlan.compileExecutionPlan(machine) return (context) => { @@ -238,7 +240,7 @@ const makeInvokingCompiledDrain = ( } const message = pending.value - const acknowledged = internalRuntime.isAcknowledgedMessage(message) + const acknowledged = internalRuntimeProtocol.isAcknowledgedMessage(message) const event = acknowledged ? message.event : message const before = current @@ -364,7 +366,7 @@ const makeInvokingCompiledDrain = ( execution.initialized = true return starting === undefined ? loop : starting.pipe(Effect.andThen(loop)) } - return internalRuntime.provideMachineRuntime(Effect.suspend(initialize), scope) + return internalRuntimeProtocol.provideMachineRuntime(Effect.suspend(initialize), scope) } } @@ -383,12 +385,12 @@ const makeProcessLogic: < >( machine: Machine, entry: ProcessEntry -) => internalRuntime.ProcessLogic< +) => internalRuntimeProtocol.ProcessLogic< Machine.Snapshot, Machine.EventOf, E | ActionError | InfiniteTransitionError | MachineSchemaDecodeError | StoppedError, ExcludeCompatibleRuntime< - Exclude, internalRuntime.MachineRuntime>, + Exclude, internalRuntimeProtocol.MachineRuntime>, Machine.EventOf, Machine.EmittedEventOf >, @@ -421,7 +423,7 @@ const makeProcessLogic: < const initialArgs = entry._tag === "Initial" ? entry.args : [] const compiledInitial = entry._tag === "Initial" ? executionPlan.initial : undefined const makeCompiledInitial = compiledInitial === undefined ? undefined : ( - scope: internalRuntime.ProcessScope> + scope: internalRuntimeProtocol.ProcessScope> ) => { try { const planned = compiledInitial(initialArgs, scope) @@ -448,10 +450,10 @@ const makeProcessLogic: < } } const makeInitial = ( - scope: internalRuntime.ProcessScope> + scope: internalRuntimeProtocol.ProcessScope> ) => compiledInitial === undefined - ? internalRuntime.provideMachineRuntime( + ? internalRuntimeProtocol.provideMachineRuntime( internalPlanner.planInitial(internalPlanner.withMachineReferences(machine, scope), ...initialArgs).pipe( Effect.flatMap((planned) => { scope.inspectInitial(planned.initialEntryPaths, planned.microsteps) @@ -495,10 +497,13 @@ const makeProcessLogic: < }, initial: (scope) => entry._tag === "Resume" - ? internalRuntime.provideMachineRuntime(Serialization.normalizeSnapshotEffect(machine, entry.snapshot), scope) + ? internalRuntimeProtocol.provideMachineRuntime( + Serialization.normalizeSnapshotEffect(machine, entry.snapshot), + scope + ) : makeInitial(scope).pipe(Effect.map((initialized) => initialized.state)), run: (context) => - internalRuntime.provideMachineRuntime( + internalRuntimeProtocol.provideMachineRuntime( Effect.gen(function*() { const { completeMessage, pollMessage, receiveMessage, state, setState } = context if (completeMessage === undefined || pollMessage === undefined || receiveMessage === undefined) { @@ -523,12 +528,13 @@ const makeProcessLogic: < // per iteration; every iteration still crosses Effect boundaries, // so the Effect scheduler remains responsible for cooperative yield. let configuration: Configuration.ActiveConfiguration | undefined - let pendingMessage: Option.Option>> = Option.none() + let pendingMessage: Option.Option>> = Option + .none() let liveRuntime: Runtime, Machine.EmittedEventOf> | undefined while (terminal === undefined) { const message = Option.isSome(pendingMessage) ? pendingMessage.value : yield* receiveMessage pendingMessage = Option.none() - const acknowledged = internalRuntime.isAcknowledgedMessage(message) + const acknowledged = internalRuntimeProtocol.isAcknowledgedMessage(message) const event = acknowledged ? message.event : message const before = current let planned @@ -631,7 +637,8 @@ const makeProcessLogic: < // As above, keep the normalized configuration only while this // worker can continue draining an already queued batch. configuration = undefined - let pendingMessage: Option.Option>> = Option.none() + let pendingMessage: Option.Option>> = Option + .none() let liveRuntime: Runtime, Machine.EmittedEventOf> | undefined // Match the compact non-invoke loop while retaining state-scoped @@ -639,7 +646,7 @@ const makeProcessLogic: < while (terminal === undefined) { const message = Option.isSome(pendingMessage) ? pendingMessage.value : yield* receiveMessage pendingMessage = Option.none() - const acknowledged = internalRuntime.isAcknowledgedMessage(message) + const acknowledged = internalRuntimeProtocol.isAcknowledgedMessage(message) const event = acknowledged ? message.event : message const before = current let planned @@ -727,12 +734,12 @@ const makeProcessLogic: < }), context ) - }) as internalRuntime.ProcessLogic< + }) as internalRuntimeProtocol.ProcessLogic< Machine.Snapshot, Machine.EventOf, E | ActionError | InfiniteTransitionError | MachineSchemaDecodeError | StoppedError, ExcludeCompatibleRuntime< - Exclude, internalRuntime.MachineRuntime>, + Exclude, internalRuntimeProtocol.MachineRuntime>, Machine.EventOf, Machine.EmittedEventOf >, @@ -749,7 +756,7 @@ const makeProcessLogic: < const initialProcessLogicCache = new WeakMap< Machine.Any, - internalRuntime.ProcessLogic + internalRuntimeProtocol.ProcessLogic >() export const toProcessLogic: < @@ -767,12 +774,12 @@ export const toProcessLogic: < >( machine: Machine, ...args: [...Machine.InputArgs] -) => internalRuntime.ProcessLogic< +) => internalRuntimeProtocol.ProcessLogic< Machine.Snapshot, Machine.EventOf, E | ActionError | InfiniteTransitionError | MachineSchemaDecodeError | StoppedError, ExcludeCompatibleRuntime< - Exclude, internalRuntime.MachineRuntime>, + Exclude, internalRuntimeProtocol.MachineRuntime>, Machine.EventOf, Machine.EmittedEventOf >, @@ -805,7 +812,7 @@ export const toProcessLogic: < const toResumedProcessLogic = ( machine: Machine.Any, snapshot: Machine.Snapshot -): internalRuntime.ProcessLogic => +): internalRuntimeProtocol.ProcessLogic => (makeProcessLogic as any)(machine, { _tag: "Resume", snapshot }) /** @internal Test-only runtime strategy selection for a fresh machine. */ @@ -813,7 +820,7 @@ export const startWithRuntimeStrategyForTesting = ( machine: Machine.Any, strategy: internalRuntime.ProcessRuntimeStrategy, ...args: ReadonlyArray -): Effect.Effect, any, any> => +): Effect.Effect, any, any> => internalRuntime.startProcessWithStrategyForTesting( (toProcessLogic as any)(machine, ...args), strategy, @@ -825,7 +832,7 @@ export const prepareWithRuntimeStrategyForTesting = ( machine: Machine.Any, strategy: internalRuntime.ProcessRuntimeStrategy, ...args: ReadonlyArray -): Effect.Effect, any, any> => +): Effect.Effect, any, any> => internalRuntime.prepareProcessWithStrategyForTesting( (toProcessLogic as any)(machine, ...args), strategy, @@ -837,7 +844,7 @@ export const resumeWithRuntimeStrategyForTesting = ( machine: Machine.Any, snapshot: Machine.Snapshot, strategy: internalRuntime.ProcessRuntimeStrategy -): Effect.Effect, any, any> => +): Effect.Effect, any, any> => internalRuntime.startProcessWithStrategyForTesting( toResumedProcessLogic(machine, snapshot), strategy, @@ -860,7 +867,7 @@ export const start: < machine: Machine, ...args: [...Machine.InputArgs] ) => Effect.Effect< - internalRuntime.MachineRef< + internalRuntimeProtocol.MachineRef< Machine.Snapshot, Machine.EventOf, | E @@ -878,7 +885,7 @@ export const start: < | StartupError | StoppedError, ExcludeCompatibleRuntime< - Exclude, internalRuntime.MachineRuntime>, + Exclude, internalRuntimeProtocol.MachineRuntime>, Machine.EventOf, Machine.EmittedEventOf > @@ -904,7 +911,7 @@ export const prepare: < machine: Machine, ...args: [...Machine.InputArgs] ) => Effect.Effect< - internalRuntime.PreparedProcess< + internalRuntimeProtocol.PreparedProcess< Machine.Snapshot, Machine.EventOf, | E @@ -922,7 +929,7 @@ export const prepare: < | StartupError | StoppedError, ExcludeCompatibleRuntime< - Exclude, internalRuntime.MachineRuntime>, + Exclude, internalRuntimeProtocol.MachineRuntime>, Machine.EventOf, Machine.EmittedEventOf > @@ -949,7 +956,7 @@ export const resume: < machine: Machine, snapshot: Machine.Snapshot ) => Effect.Effect< - internalRuntime.MachineRef< + internalRuntimeProtocol.MachineRef< Machine.Snapshot, Machine.EventOf, E | ActionError | InfiniteTransitionError | MachineSchemaDecodeError | StoppedError, @@ -957,7 +964,7 @@ export const resume: < >, MachineSchemaDecodeError, ExcludeCompatibleRuntime< - Exclude, internalRuntime.MachineRuntime>, + Exclude, internalRuntimeProtocol.MachineRuntime>, Machine.EventOf, Machine.EmittedEventOf > diff --git a/packages/effect-machine/src/internal/machine/runtime.ts b/packages/effect-machine/src/internal/machine/runtime.ts index c5515c9..b4b1aa2 100644 --- a/packages/effect-machine/src/internal/machine/runtime.ts +++ b/packages/effect-machine/src/internal/machine/runtime.ts @@ -1,3047 +1,28 @@ -/** - * Internal machine process runtime helpers. - * - * @since 0.4.0 - */ +/** Local process startup and execution strategy selection. */ -import * as Cause from "effect/Cause" -import * as Channel from "effect/Channel" -import * as Context from "effect/Context" import * as Deferred from "effect/Deferred" import * as Effect from "effect/Effect" -import * as Exit from "effect/Exit" -import * as Fiber from "effect/Fiber" -import * as Option from "effect/Option" -import * as PubSub from "effect/PubSub" -import * as Queue from "effect/Queue" -import * as Scope from "effect/Scope" import * as Stream from "effect/Stream" -import * as SynchronizedRef from "effect/SynchronizedRef" -import type * as Take from "effect/Take" -import type { RuntimeOutcome as PublicRuntimeOutcome, RuntimeSnapshot as PublicRuntimeSnapshot } from "../../Machine.js" -import type { ChildMachine, Inspection, Machine as MachineDefinition, MachineTarget } from "../../Machine.js" -import { - type ChildDescriptor, - type ChildEntry, - type ChildKey, - type ChildObserver, - type ChildRegistry, - type ChildSelector, - matchesChild, - offerChildObservation, - registerChild, - selectRegistryChild, - takeChildObservations, - unregisterChild -} from "./childRegistry.js" -import { ChildAlreadyExistsError, StoppedError } from "./errors.js" import * as InspectionRuntime from "./inspectionRuntime.js" -import { ChildMachineLogicTypeId } from "./symbols.js" - -/** @internal */ -export const activeSnapshotObserver: unique symbol = Symbol.for("effect/Machine/activeSnapshotObserver") - -/** @internal */ -export const sendParentOverride: unique symbol = Symbol.for("effect/Machine/sendParentOverride") - -/** @internal */ -export const acknowledgedSend: unique symbol = Symbol.for("effect/Machine/acknowledgedSend") - -/** @internal */ -export interface AcknowledgedDelivery { - readonly before: State - readonly plan: unknown - readonly after: State -} - -const AcknowledgedMessageTypeId: unique symbol = Symbol("effect/Machine/AcknowledgedMessage") - -/** @internal */ -export interface AcknowledgedMessage { - readonly [AcknowledgedMessageTypeId]: true - readonly event: Event - readonly deferred?: Deferred.Deferred, unknown> - readonly inspection?: InspectedDelivery -} - -/** @internal */ -export type ProcessMessage = Event | AcknowledgedMessage - -/** @internal */ -export const isAcknowledgedMessage = ( - message: ProcessMessage -): message is AcknowledgedMessage => - typeof message === "object" && message !== null && AcknowledgedMessageTypeId in message - -/** @internal */ -export const messageEvent = (message: ProcessMessage): Event => - isAcknowledgedMessage(message) ? message.event : message - -const succeedAcknowledgedMessage = ( - message: ProcessMessage | undefined, - delivery: AcknowledgedDelivery -): void => { - if (message !== undefined && isAcknowledgedMessage(message)) { - if (message.deferred !== undefined) { - Deferred.doneUnsafe(message.deferred, Effect.succeed(delivery as AcknowledgedDelivery)) - } - message.inspection?.complete(delivery as AcknowledgedDelivery) - } -} - -const failAcknowledgedMessage = ( - message: ProcessMessage | undefined, - cause: Cause.Cause -): void => { - if (message !== undefined && isAcknowledgedMessage(message)) { - if (message.deferred !== undefined) Deferred.doneUnsafe(message.deferred, Effect.failCause(cause)) - } -} - -const stopAcknowledgedMessage = (message: ProcessMessage | undefined): void => { - if (message !== undefined && isAcknowledgedMessage(message)) { - if (message.deferred !== undefined) Deferred.doneUnsafe(message.deferred, Effect.fail(new StoppedError())) - } -} - -interface InspectedDelivery { - readonly deliveryId: number - readonly macrostepId: number - readonly source: Inspection.Subject | undefined - readonly event: unknown - readonly causedBy: Inspection.Causation | undefined - readonly complete: (delivery: AcknowledgedDelivery) => void -} - -type InspectedOffer = ( - event: Event, - source: Inspection.Subject | undefined, - causedBy: Inspection.Causation | undefined, - deferred?: Deferred.Deferred, unknown> -) => Effect.Effect - -export type RuntimeSnapshot = PublicRuntimeSnapshot - -interface VersionedSnapshot { - readonly revision: number - readonly snapshot: RuntimeSnapshot - readonly terminalizing: boolean - readonly changes: PubSub.PubSub>> | undefined - /** Compiled drains retain one non-empty publication chunk until their next Effect boundary. */ - pendingChanges?: VersionedSnapshotBatch | undefined -} - -type VersionedSnapshotBatch = [ - VersionedSnapshot, - ...Array> -] - -export type RuntimeOutcome = PublicRuntimeOutcome - -export interface MachineRef { - readonly id: string - readonly sessionId: string - readonly state: Effect.Effect - readonly snapshot: Effect.Effect> - readonly changes: Stream.Stream> - readonly emissions: Stream.Stream - readonly join: Effect.Effect - readonly stop: Effect.Effect - readonly send: (event: Event) => Effect.Effect - /** @internal */ - readonly inspectionSubject?: Inspection.Subject - /** @internal */ - readonly sendInspected?: ProcessAddress["sendInspected"] - readonly [acknowledgedSend]?: ( - event: Event - ) => Effect.Effect, Error | StoppedError> - readonly child: (child: any) => Effect.Effect> - readonly childChanges: (child: any) => Stream.Stream> -} - -export interface PreparedProcess< - out State, - in Event, - out Error, - out Output, - out Emitted, - out StartError, - StartRequirements -> { - readonly id: string - readonly sessionId: string - readonly changes: Stream.Stream, StartError> - readonly emissions: Stream.Stream - readonly inspection: Stream.Stream - readonly start: Effect.Effect, StartError, StartRequirements> -} - -interface ProcessAddress { - readonly id: string - readonly sessionId: string - readonly inspectionSubject?: Inspection.Subject - readonly stop: Effect.Effect - readonly send: (event: Event) => Effect.Effect - readonly sendInspected?: ( - event: Event, - source: Inspection.Subject | undefined, - causedBy: Inspection.Causation | undefined - ) => Effect.Effect - readonly [acknowledgedSend]?: ( - event: Event - ) => Effect.Effect, unknown | StoppedError> -} - -const isMachineTarget = (value: unknown): value is MachineTarget => - typeof value === "object" && value !== null && "send" in value && typeof value.send === "function" - -const sendMachineTarget = ( - target: MachineTarget, - event: unknown, - source: Inspection.Subject | undefined, - causedBy: Inspection.Causation | undefined -): Effect.Effect => - "inspectionSubject" in target && "sendInspected" in target && typeof target.sendInspected === "function" - ? target.sendInspected(event, source, causedBy) - : target.send(event) - -export interface ProcessScope { - readonly self: ProcessAddress - readonly parent: ProcessAddress | undefined - readonly spawn: ProcessSpawn - readonly sendParent: (event: unknown) => Effect.Effect - readonly emit: (event: unknown) => Effect.Effect - readonly sendTo: { - (target: MachineTarget, event: TargetEvent): Effect.Effect - (child: ChildSelector, event: unknown): Effect.Effect - } - readonly stopChild: (child: ChildSelector) => Effect.Effect - /** @internal */ - readonly failCause: (cause: Cause.Cause) => Effect.Effect - /** @internal */ - readonly inspectInitial: ( - initialEntryPaths: ReadonlyArray, - microsteps?: ReadonlyArray - ) => void -} - -export interface ProcessContext extends ProcessScope { - readonly receive: Effect.Effect - /** @internal */ - readonly poll?: Effect.Effect> - /** @internal */ - readonly receiveMessage?: Effect.Effect> - /** @internal */ - readonly pollMessage?: Effect.Effect>> - /** @internal */ - readonly completeMessage?: (delivery: AcknowledgedDelivery) => void - readonly state: Effect.Effect - readonly setState: (state: State) => Effect.Effect - readonly updateState: ( - f: (state: State) => Effect.Effect - ) => Effect.Effect - /** Present only when a compiled statechart is forced through the generic runtime. @internal */ - readonly ownedChildren?: OwnedChildRuntime -} - -/** - * Owner-local execution context for compiled statecharts. - * - * Unlike `ProcessContext`, synchronous mailbox and state operations do not - * introduce an Effect boundary. The compiled drain still returns an Effect so - * machine commands, invokes, observation callbacks, interruption, and the Effect - * scheduler remain explicit at their actual boundaries. - * - * @internal - */ -export interface CompiledProcessContext { - readonly scope: ProcessScope - readonly ownedChildren: OwnedChildRuntime - readonly poll: () => Option.Option - readonly pollMessage: () => Option.Option> - readonly state: () => State - readonly completeMessage: (delivery: AcknowledgedDelivery) => void - readonly commit: (state: State) => Effect.Effect | undefined - /** - * Publishes the current synchronous segment before continuing with work that - * may suspend, run user effects, or make the committed state observable. - */ - readonly runAfterChanges: (effect: Effect.Effect) => Effect.Effect - executionState: unknown -} - -type CompiledProcessInitial = - | { readonly state: State; readonly done: false; readonly output: undefined } - | { readonly state: State; readonly done: true; readonly output: Output } - | { - readonly state: State - readonly done: boolean - readonly output: Output | undefined - readonly executionState: unknown - } - -export type CompiledProcessDrain = - | { - readonly _tag: "Process" - readonly run: ( - context: ProcessContext - ) => Effect.Effect, Error, Requirements> - } - | { - readonly _tag: "Owned" - readonly run: ( - context: CompiledProcessContext - ) => Effect.Effect, Error, Requirements> - } - -/** - * The complete capability descriptor consumed by the compact process runtime. - * Generic process logic omits this field entirely. - * - * @internal - */ -export interface CompiledProcessExecution< - State, - Event, - Error, - Requirements, - Output, - InitialError -> { - readonly _tag: "Compiled" - readonly childless: boolean - readonly initial?: ( - scope: ProcessScope - ) => Effect.Effect, InitialError, Requirements> - readonly initialSync?: ( - scope: ProcessScope - ) => CompiledProcessInitial - readonly drain: CompiledProcessDrain -} - -export type ProcessExecution = - | { - readonly _tag: "Childless" - } - | CompiledProcessExecution - -const executionIsChildless = ( - execution: ProcessExecution | undefined -): boolean => execution?._tag === "Childless" || execution?.childless === true - -interface CompactProcessMailbox { - items: Array> | undefined - index: number - closed: boolean -} - -const offerCompactMailbox = (mailbox: CompactProcessMailbox, event: ProcessMessage): void => { - const items = mailbox.items ?? [] - mailbox.items = items - items.push(event) -} - -const pollCompactMailbox = (mailbox: CompactProcessMailbox): Option.Option> => { - if (mailbox.items === undefined) { - return Option.none() - } - const event = mailbox.items[mailbox.index]! - mailbox.index += 1 - if (mailbox.index === mailbox.items.length) { - mailbox.items = undefined - mailbox.index = 0 - } - return Option.some(event) -} - -const closeCompactMailbox = (mailbox: CompactProcessMailbox): void => { - if (mailbox.items !== undefined) { - for (let index = mailbox.index; index < mailbox.items.length; index += 1) { - stopAcknowledgedMessage(mailbox.items[index]) - } - } - mailbox.closed = true - mailbox.items = undefined - mailbox.index = 0 -} - -export interface ProcessLogic< - State, - Event, - out Error = never, - out Requirements = never, - out Output = never, - out InitialError = never -> { - /** @internal */ - readonly execution?: ProcessExecution - /** @internal */ - readonly inspection?: { - readonly kind: Inspection.Subject["kind"] - readonly definition?: MachineDefinition.Any - } - initial(scope: ProcessScope): Effect.Effect - run(context: ProcessContext): Effect.Effect -} - -export interface ProcessSpawn { - ( - child: Child & ChildMachine.Executable & ChildMachine.ParentCompatibility, - ...options: ChildMachine.SpawnArgs - ): Effect.Effect< - ChildMachine.Ref, - ChildAlreadyExistsError | ChildMachine.StartError, - ChildMachine.StartRequirements - > - ( - logic: ProcessLogic - ): Effect.Effect< - MachineRef, - ChildInitialError, - Exclude - > - ( - logic: ProcessLogic, - options: { - readonly id: string - readonly descriptor?: ChildDescriptor - readonly onOutcome?: ( - outcome: RuntimeOutcome - ) => Effect.Effect - readonly [activeSnapshotObserver]?: ( - snapshot: Extract, { readonly status: "active" }> - ) => Effect.Effect - readonly [sendParentOverride]?: (event: unknown) => Effect.Effect - } - ): Effect.Effect< - MachineRef, - ChildAlreadyExistsError | ChildInitialError, - Exclude - > -} - -export class MachineRuntime extends Context.Service>()( - "effect/Machine/MachineRuntime" -) {} - -export const provideMachineRuntime = ( - effect: Effect.Effect, - scope: ProcessScope -): Effect.Effect> => - Effect.provideService(effect, MachineRuntime, scope as ProcessScope) - -const classifyOutcome = ( - snapshot: RuntimeSnapshot -): RuntimeOutcome | undefined => { - switch (snapshot.status) { - case "active": { - return undefined - } - case "done": { - return { - _tag: "Done", - output: snapshot.output, - snapshot - } - } - case "error": { - const failure = snapshot.cause.reasons.find(Cause.isFailReason) - if (failure !== undefined) { - return { - _tag: "Failure", - error: failure.error, - cause: snapshot.cause, - snapshot - } - } - const defect = snapshot.cause.reasons.find(Cause.isDieReason) - if (defect !== undefined) { - return { - _tag: "Defect", - defect: defect.defect, - cause: snapshot.cause, - snapshot - } - } - const interrupted = snapshot.cause.reasons.find(Cause.isInterruptReason) - if (interrupted !== undefined) { - return { - _tag: "Interrupted", - cause: snapshot.cause, - snapshot - } - } - return { - _tag: "Cause", - cause: snapshot.cause, - snapshot - } - } - case "stopped": { - return { - _tag: "Stopped", - snapshot - } - } - } -} - -const notifyActiveSnapshot = ( - onSnapshot: ( - snapshot: Extract, { readonly status: "active" }> - ) => Effect.Effect, - snapshot: Extract, { readonly status: "active" }> -): Effect.Effect => - Effect.suspend(() => onSnapshot(snapshot)).pipe( - Effect.exit, - Effect.asVoid - ) - -export const watch = ( - ref: MachineRef -): Stream.Stream> => - ref.changes.pipe( - Stream.filter((snapshot) => snapshot.status !== "active"), - Stream.map((snapshot) => classifyOutcome(snapshot)!), - Stream.take(1) - ) - -interface ProcessRuntime { - readonly nextSessionId: Effect.Effect - inspection?: InspectionRuntime.Runtime -} - -const makeProcessRuntime: Effect.Effect = Effect.sync(() => { - let sessionIdCounter = 0 - return { - nextSessionId: Effect.sync(() => `machine:${sessionIdCounter++}`) - } -}) - -const inspectionSubject = ( - logic: ProcessLogic, - id: string, - sessionId: string -): Inspection.Subject => ({ - id, - sessionId, - kind: logic.inspection?.kind ?? "Logic" -}) - -const messageCausation = ( - message: ProcessMessage | undefined, - initializing: boolean -): Inspection.Causation | undefined => - initializing - ? { _tag: "Initialization" } - : message !== undefined && isAcknowledgedMessage(message) && message.inspection !== undefined - ? { _tag: "Macrostep", macrostepId: message.inspection.macrostepId } - : undefined - -const makeInspectedMessage = ( - inspection: InspectionRuntime.Runtime, - subject: Inspection.Subject, - event: Event, - source: Inspection.Subject | undefined, - causedBy: Inspection.Causation | undefined, - deferred?: Deferred.Deferred, unknown> -): AcknowledgedMessage => { - const deliveryId = inspection.nextDeliveryId() - const macrostepId = inspection.nextMacrostepId() - const inspected: InspectedDelivery = { - deliveryId, - macrostepId, - source, - event, - causedBy, - complete: (delivery) => { - const microsteps = InspectionRuntime.microsteps(delivery.plan) - inspection.publishUnsafe({ - _tag: "EventProcessed", - subject, - macrostepId, - deliveryId, - source, - event, - before: { status: "active", state: delivery.before }, - after: { status: "active", state: delivery.after }, - handled: microsteps.some((microstep) => microstep.transitions.length > 0), - configurationChanged: microsteps.some((microstep) => microstep.changed), - microsteps - }) - } - } - return { - [AcknowledgedMessageTypeId]: true, - event, - ...(deferred === undefined ? undefined : { deferred }), - inspection: inspected - } -} - -const publishInspectedSent = ( - inspection: InspectionRuntime.Runtime, - subject: Inspection.Subject, - message: ProcessMessage -): void => { - if (!isAcknowledgedMessage(message) || message.inspection === undefined) return - const delivery = message.inspection - inspection.publishUnsafe({ - _tag: "EventSent", - subject, - deliveryId: delivery.deliveryId, - source: delivery.source, - target: InspectionRuntime.endpoint(subject), - event: delivery.event, - causedBy: delivery.causedBy - }) -} - -interface StartInternalOptions { - readonly detached?: boolean - readonly id?: string - readonly sessionId?: string - readonly emissions?: EmissionRuntime - readonly onOutcome?: (outcome: RuntimeOutcome) => Effect.Effect - readonly onSnapshot?: ( - snapshot: Extract, { readonly status: "active" }> - ) => Effect.Effect - readonly onReady?: ( - ref: MachineRef, - requestStop: Effect.Effect - ) => Effect.Effect - readonly onReadySync?: (ref: MachineRef) => boolean - readonly onStop?: Effect.Effect - readonly onStopSync?: () => void - readonly skipStoppedOutcome?: boolean - readonly parent?: ProcessAddress - readonly runtime: ProcessRuntime - readonly sendParent?: (event: unknown) => Effect.Effect - readonly origin?: Inspection.Origin - readonly activity?: { - readonly id: string - readonly owner: Inspection.Subject - readonly ownerPath: string - readonly kind: Inspection.Activity["kind"] - } - readonly inspectionRoot?: boolean -} - -/** @internal */ -export interface OwnedChildSpawnOptions { - readonly key: string - readonly path: string - readonly id: string - readonly duplicateId: string - readonly descriptor?: ChildDescriptor - readonly onOutcome: ( - isCurrent: () => boolean, - outcome: RuntimeOutcome, - activitySessionId: string | undefined - ) => Effect.Effect - readonly onSnapshot?: ( - isCurrent: () => boolean, - snapshot: Extract, { readonly status: "active" }> - ) => Effect.Effect - readonly sendParent: ( - isCurrent: () => boolean, - event: unknown - ) => Effect.Effect - readonly activityKind?: Inspection.Activity["kind"] -} - -/** @internal */ -export interface OwnedChildRuntime { - readonly spawn: ( - makeLogic: () => ProcessLogic, - options: OwnedChildSpawnOptions - ) => Effect.Effect - readonly stopAll: () => Effect.Effect - readonly stopPaths: (paths: ReadonlyArray) => Effect.Effect | undefined -} - -interface ChildRuntime { - readonly close: (exit: Exit.Exit) => Effect.Effect - readonly spawn: ProcessSpawn - readonly get: ( - child: ChildSelector - ) => Effect.Effect>> - readonly changes: ( - child: ChildSelector - ) => Stream.Stream>> - readonly sendTo: ( - child: ChildSelector, - event: unknown, - source?: Inspection.Subject, - causedBy?: Inspection.Causation - ) => Effect.Effect - readonly stop: (child: ChildSelector) => Effect.Effect - readonly owned: OwnedChildRuntime -} - -class OwnedChildRuntimeImpl implements OwnedChildRuntime { - private scopedServices: Context.Context | undefined - - constructor( - private readonly registry: ChildRegistry, - private readonly self: ProcessAddress, - private readonly runtime: ProcessRuntime, - private readonly services?: Context.Context, - private readonly sendAcknowledged?: ( - event: unknown - ) => Effect.Effect, unknown | StoppedError> - ) {} - - private has(key: string): boolean { - for (const entry of this.registry.children.values()) { - if (entry.ownerActive && entry.ownerKey === key) return true - } - return false - } - - private stopEntry(entry: ChildEntry): Effect.Effect { - entry.ownerActive = false - return entry._tag === "Started" ? entry.ref.stop : Effect.void - } - - spawn( - makeLogic: () => ProcessLogic, - options: OwnedChildSpawnOptions - ): Effect.Effect { - const token = Symbol() - let startedChild: MachineRef | undefined - const isCurrent = (): boolean => { - const entry = this.registry.children.get(options.id) - return entry?.token === token && entry.ownerKey === options.key && entry.ownerActive === true - } - return Effect.suspend(() => { - if (this.registry.closed) return Effect.interrupt - if (this.has(options.key) || this.registry.children.has(options.id)) { - return Effect.fail(new ChildAlreadyExistsError({ id: options.duplicateId })) - } - const logic = makeLogic() - const scope = this.registry.scope ??= Scope.makeUnsafe("parallel") - this.registry.children.set(options.id, { - _tag: "Starting", - token, - ownerKey: options.key, - ownerPath: options.path, - ownerActive: true - }) - const parent: ProcessAddress = { - ...this.self, - send: (event) => options.sendParent(isCurrent, event), - sendInspected: (event, source, causedBy) => - isCurrent() ? sendMachineTarget(this.self, event, source, causedBy) : Effect.void, - ...(this.sendAcknowledged === undefined - ? undefined - : { - [acknowledgedSend]: (event: unknown) => - isCurrent() - ? this.sendAcknowledged!(event) - : Effect.interrupt - }) - } - const startOptions: StartInternalOptions = { - detached: true, - id: options.id, - sendParent: (event) => options.sendParent(isCurrent, event), - onOutcome: (outcome) => options.onOutcome(isCurrent, outcome, startedChild?.sessionId), - ...(options.onSnapshot === undefined - ? undefined - : { onSnapshot: (snapshot) => options.onSnapshot!(isCurrent, snapshot) }), - onReadySync: (child) => { - startedChild = child - return registerChild(this.registry, options.id, token, child, options.descriptor) - }, - onStopSync: () => unregisterChild(this.registry, options.id, token), - skipStoppedOutcome: true, - parent, - runtime: this.runtime, - origin: { _tag: "Invoke", ownerPath: options.path, invokeId: options.duplicateId }, - ...(options.activityKind === undefined || this.runtime.inspection === undefined || - this.self.inspectionSubject === undefined - ? undefined - : { - activity: { - id: options.duplicateId, - owner: this.self.inspectionSubject, - ownerPath: options.path, - kind: options.activityKind - } - }) - } - const execution = logic.execution - const synchronous = this.services !== undefined && options.onSnapshot === undefined && - execution?._tag === "Compiled" && execution.childless && execution.drain._tag === "Owned" && - execution.initialSync !== undefined - const start = synchronous - ? Effect.flatMap( - this.runtime.nextSessionId, - (sessionId) => - new CompiledProcess( - logic, - startOptions, - this.scopedServices ??= Context.add(this.services!, Scope.Scope, scope), - sessionId - ).initializeCompiledSync() - ) - : startLogicInternal(logic, startOptions) - const guarded = start.pipe( - Effect.onExit((exit) => { - if (Exit.isSuccess(exit)) return Effect.void - unregisterChild(this.registry, options.id, token) - return startedChild === undefined ? Effect.void : startedChild.stop - }) - ) - return (synchronous ? guarded : Scope.provide(guarded, scope)).pipe(Effect.asVoid) - }) - } - - stopAll(): Effect.Effect { - return Effect.suspend(() => { - const effects: Array> = [] - for (const entry of this.registry.children.values()) { - if (entry.ownerActive) effects.push(this.stopEntry(entry)) - } - return effects.length === 0 - ? Effect.void - : effects.length === 1 - ? effects[0]! - : Effect.all(effects, { concurrency: "unbounded", discard: true }) - }) - } - - stopPaths(paths: ReadonlyArray): Effect.Effect | undefined { - if (paths.length === 0) return undefined - const pathSet = new Set(paths) - const effects: Array> = [] - for (const entry of this.registry.children.values()) { - if (entry.ownerActive && entry.ownerPath !== undefined && pathSet.has(entry.ownerPath)) { - effects.push(this.stopEntry(entry)) - } - } - return effects.length === 0 - ? undefined - : effects.length === 1 - ? effects[0]! - : Effect.all(effects, { concurrency: "unbounded", discard: true }) - } -} - -const noChildChanges = Stream.succeed(Option.none()).pipe(Stream.concat(Stream.never)) -const noParentSend = (_event: unknown): Effect.Effect => Effect.void -const noInspectInitial = (_paths: ReadonlyArray, _microsteps?: ReadonlyArray): void => {} -const noCausation = (): Inspection.Causation | undefined => undefined -const EmissionsClosed: unique symbol = Symbol("effect/Machine/EmissionsClosed") - -type LazyEmissions = PubSub.PubSub | typeof EmissionsClosed | undefined - -interface EmissionRuntime { - readonly emit: (event: unknown) => Effect.Effect - readonly close: () => Effect.Effect - readonly stream: Stream.Stream -} - -const makeEmissionRuntime = (): EmissionRuntime => { - let emissions: LazyEmissions - const getOrCreate: Effect.Effect | undefined> = Effect.suspend(() => { - const observed = emissions - if (observed === EmissionsClosed) return Effect.succeed(undefined) - if (observed !== undefined) return Effect.succeed(observed) - return PubSub.unbounded().pipe( - Effect.flatMap((candidate) => - Effect.sync(() => { - const latest = emissions - if (latest === EmissionsClosed) return [undefined, true] as const - if (latest !== undefined) return [latest, true] as const - emissions = candidate - return [candidate, false] as const - }).pipe( - Effect.flatMap(([selected, discard]) => - discard ? PubSub.shutdown(candidate).pipe(Effect.as(selected)) : Effect.succeed(selected) - ) - ) - ) - ) - }) - return { - emit: (event) => - Effect.suspend(() => - emissions === undefined || emissions === EmissionsClosed - ? Effect.void - : PubSub.publish(emissions, event).pipe(Effect.asVoid) - ), - close: () => { - const observed = emissions - emissions = EmissionsClosed - return observed === undefined || observed === EmissionsClosed ? Effect.void : PubSub.shutdown(observed) - }, - stream: Stream.unwrap( - getOrCreate.pipe( - Effect.map((emissions) => emissions === undefined ? Stream.empty : Stream.fromPubSub(emissions)) - ) - ) - } -} - -const childlessRuntime: ChildRuntime = { - close: () => Effect.void, - spawn: (() => Effect.die(new Error("Childless machine logic cannot spawn a process"))) as ProcessSpawn, - get: () => Effect.succeed(Option.none()), - changes: () => noChildChanges, - sendTo: () => Effect.void, - stop: () => Effect.void, - owned: { - spawn: () => Effect.die(new Error("Childless machine logic cannot spawn an owned process")), - stopAll: () => Effect.void, - stopPaths: () => undefined - } -} - -const makeChildRuntimeSync = ( - self: ProcessAddress, - runtime: ProcessRuntime, - services?: Context.Context, - sendAcknowledged?: ( - event: unknown - ) => Effect.Effect, unknown | StoppedError> -): ChildRuntime => { - // Child-registry decisions are synchronous and every access below runs in - // one Effect.sync / Effect.suspend step. Keep the unobserved representation - // compact; selector-specific handoffs are installed only while - // childChanges streams are running. - const registry: ChildRegistry = { - closed: false, - children: new Map(), - observers: undefined, - scope: undefined - } - - const close = (exit: Exit.Exit): Effect.Effect => - Effect.suspend(() => { - if (registry.closed) { - return Effect.void - } - registry.closed = true - if (registry.scope === undefined) { - return Effect.void - } - const finalizers = Scope.closeUnsafe(registry.scope, exit) - let first: Effect.Effect | undefined - let rest: Array> | undefined - for (const entry of registry.children.values()) { - if (entry._tag !== "Started") { - continue - } - if (first === undefined) { - first = entry.ref.stop - } else { - rest ??= [first] - rest.push(entry.ref.stop) - } - } - if (finalizers !== undefined) { - if (first === undefined) { - first = finalizers - } else { - rest ??= [first] - rest.push(finalizers) - } - } - const cleanup = rest ?? first - return cleanup === undefined - ? Effect.void - : Array.isArray(cleanup) - ? Effect.all(cleanup, { concurrency: "unbounded", discard: true }) - : cleanup - }) - - const unregister = ( - key: ChildKey, - token: symbol - ): Effect.Effect => Effect.sync(() => unregisterChild(registry, key, token)) - - const register = ( - key: ChildKey, - token: symbol, - ref: MachineRef, - descriptor: ChildDescriptor | undefined - ): Effect.Effect => Effect.sync(() => registerChild(registry, key, token, ref, descriptor)) - - const get: ChildRuntime["get"] = (child) => { - const id = typeof child === "string" ? child : child.id - return Effect.sync(() => { - if (registry.closed) { - return Option.none() - } - const entry = registry.children.get(id) - return entry !== undefined && matchesChild(entry, child) - ? Option.some(entry.ref) - : Option.none() - }) - } - - const changes: ChildRuntime["changes"] = (child) => { - const id = typeof child === "string" ? child : child.id - return Stream.fromChannel( - Channel.fromTransform((_, streamScope) => - Effect.sync((): ChildObserver => ({ child, id, values: undefined, waiter: undefined })).pipe( - Effect.flatMap((observer) => { - const removeObserver = Effect.sync(() => { - if (registry.observers !== undefined) { - registry.observers.delete(observer) - if (registry.observers.size === 0) { - registry.observers = undefined - } - } - observer.values = undefined - observer.waiter = undefined - }) - return Scope.addFinalizer(streamScope, removeObserver).pipe( - Effect.andThen( - Effect.sync(() => { - if (!registry.closed && streamScope.state._tag !== "Closed") { - registry.observers ??= new Set() - registry.observers.add(observer) - } - offerChildObservation(observer, selectRegistryChild(registry, id, child)) - }) - ), - Effect.as(takeChildObservations(observer)) - ) - }) - ) - ) - ) - } - - const sendTo = ( - child: ChildSelector, - event: unknown, - source?: Inspection.Subject, - causedBy?: Inspection.Causation - ): Effect.Effect => { - const id = typeof child === "string" ? child : child.id - return Effect.suspend(() => { - if (registry.closed) { - return Effect.void - } - const entry = registry.children.get(id) - return entry !== undefined && matchesChild(entry, child) - ? sendMachineTarget(entry.ref, event, source, causedBy) - : Effect.void - }) - } - - const stop = (child: ChildSelector): Effect.Effect => { - const id = typeof child === "string" ? child : child.id - return Effect.suspend(() => { - if (registry.closed) { - return Effect.void - } - const entry = registry.children.get(id) - return entry !== undefined && matchesChild(entry, child) - ? entry.ref.stop - : Effect.void - }) - } - - function spawn( - child: Child & ChildMachine.Executable & ChildMachine.ParentCompatibility, - ...options: ChildMachine.SpawnArgs - ): Effect.Effect< - ChildMachine.Ref, - ChildAlreadyExistsError | ChildMachine.StartError, - ChildMachine.StartRequirements - > - function spawn( - logic: ProcessLogic - ): Effect.Effect< - MachineRef, - ChildInitialError, - Exclude - > - function spawn( - logic: ProcessLogic, - spawnOptions: { - readonly id: string - readonly descriptor?: ChildDescriptor - readonly onOutcome?: ( - outcome: RuntimeOutcome - ) => Effect.Effect - readonly [activeSnapshotObserver]?: ( - snapshot: Extract, { readonly status: "active" }> - ) => Effect.Effect - readonly [sendParentOverride]?: (event: unknown) => Effect.Effect - } - ): Effect.Effect< - MachineRef, - ChildAlreadyExistsError | ChildInitialError, - Exclude - > - function spawn( - logicOrChild: ProcessLogic | ChildMachine.Any, - options?: { - readonly id: string - readonly descriptor?: ChildDescriptor - readonly onOutcome?: ( - outcome: RuntimeOutcome - ) => Effect.Effect - readonly [activeSnapshotObserver]?: ( - snapshot: Extract, { readonly status: "active" }> - ) => Effect.Effect - readonly [sendParentOverride]?: (event: unknown) => Effect.Effect - } | { readonly input?: unknown } - ): Effect.Effect, any, any> { - const descriptor = typeof logicOrChild === "object" && logicOrChild !== null && - ChildMachineLogicTypeId in logicOrChild - ? logicOrChild as ChildMachine.Any - : undefined - const logic = descriptor === undefined - ? logicOrChild as ProcessLogic - : descriptor[ChildMachineLogicTypeId]( - (options as { readonly input?: unknown } | undefined)?.input - ) as unknown as ProcessLogic - const spawnOptions = descriptor === undefined - ? options as { - readonly id: string - readonly descriptor?: ChildDescriptor - readonly onOutcome?: (outcome: RuntimeOutcome) => Effect.Effect - readonly [activeSnapshotObserver]?: ( - snapshot: Extract, { readonly status: "active" }> - ) => Effect.Effect - readonly [sendParentOverride]?: (event: unknown) => Effect.Effect - } | undefined - : { id: descriptor.id, descriptor } - const token = Symbol() - const key = spawnOptions?.id ?? token - let startedChild: MachineRef | undefined - return Effect.suspend(() => { - if (registry.closed) { - return Effect.interrupt - } - if (typeof key === "string" && registry.children.has(key)) { - return Effect.fail(new ChildAlreadyExistsError({ id: key })) - } - registry.scope ??= Scope.makeUnsafe("parallel") - registry.children.set(key, { _tag: "Starting", token }) - return startLogicInternal(logic, { - detached: true, - ...(spawnOptions?.id === undefined ? undefined : { id: spawnOptions.id }), - ...(spawnOptions?.onOutcome === undefined ? undefined : { onOutcome: spawnOptions.onOutcome }), - ...(spawnOptions?.[activeSnapshotObserver] === undefined - ? undefined - : { onSnapshot: spawnOptions[activeSnapshotObserver] }), - ...(spawnOptions?.[sendParentOverride] === undefined - ? undefined - : { sendParent: spawnOptions[sendParentOverride] }), - onReady: (child, requestChildStop) => - Effect.sync(() => { - startedChild = child - }).pipe( - Effect.andThen(register(key, token, child, spawnOptions?.descriptor)), - Effect.flatMap((registered) => registered ? Effect.void : requestChildStop) - ), - onStop: unregister(key, token), - parent: self, - runtime, - origin: { _tag: "Spawn", address: spawnOptions?.id } - }).pipe( - Effect.onExit((exit) => - Exit.isFailure(exit) - ? unregister(key, token).pipe( - Effect.andThen(startedChild === undefined ? Effect.void : startedChild.stop) - ) - : Effect.void - ), - Scope.provide(registry.scope) - ) - }) - } - - return { - close, - spawn, - get, - changes, - sendTo, - stop, - owned: new OwnedChildRuntimeImpl(registry, self, runtime, services, sendAcknowledged) - } -} - -const makeChildRuntime = ( - self: ProcessAddress, - runtime: ProcessRuntime, - services?: Context.Context, - sendAcknowledged?: ( - event: unknown - ) => Effect.Effect, unknown | StoppedError> -): Effect.Effect => Effect.sync(() => makeChildRuntimeSync(self, runtime, services, sendAcknowledged)) - -// `Machine.logic` permits an arbitrary Effect program, including programs that -// suspend or supervise their own fibers. Keep its two-fiber worker/supervisor -// protocol as the general contract rather than weakening it for statecharts. -const startGenericInternal: < - State, - Event, - Error = never, - Requirements = never, - Output = never, - InitialError = never ->( - logic: ProcessLogic, - options: StartInternalOptions -) => Effect.Effect< - MachineRef, - InitialError, - Requirements -> = Effect.fnUntraced(function*( - logic: ProcessLogic, - options: StartInternalOptions -) { - const { - detached, - id: requestedId, - onOutcome, - onReady, - onReadySync, - onSnapshot, - onStop, - onStopSync, - parent, - runtime, - sendParent: overrideSendParent - } = options - type ProcessTermination = - | { readonly _tag: "Stopped" } - | { readonly _tag: "Done"; readonly output: Output } - | { readonly _tag: "Failure"; readonly cause: Cause.Cause } - - const sessionId = options.sessionId ?? (yield* runtime.nextSessionId) - const id = requestedId ?? sessionId - const inspector = runtime.inspection - const subject = inspector === undefined ? undefined : inspectionSubject(logic, id, sessionId) - const activity: Inspection.Activity | undefined = inspector === undefined || options.activity === undefined - ? undefined - : { ...options.activity, sessionId } - let initialEntryPaths: ReadonlyArray | undefined - let initialMicrosteps: ReadonlyArray | undefined - const queue = yield* Queue.unbounded>() - const emissions = options.emissions ?? makeEmissionRuntime() - const termination = yield* Deferred.make() - const done = yield* Deferred.make() - const awaitCompletion = Deferred.await(done).pipe(Effect.exit, Effect.asVoid) - let initializing = true - let inFlightMessage: ProcessMessage | undefined - const requestStop = Deferred.succeed(termination, { _tag: "Stopped" }).pipe(Effect.asVoid) - const offerDirect = (message: ProcessMessage): Effect.Effect => - Queue.offer(queue, message).pipe( - Effect.flatMap((accepted) => accepted ? Effect.void : Effect.fail(new StoppedError())) - ) - const offerInspected: InspectedOffer | undefined = inspector === undefined ? undefined : ( - event, - source, - causedBy, - deferred?: Deferred.Deferred, unknown> - ) => - Effect.suspend(() => { - const message: ProcessMessage = inspector.isActive() - ? makeInspectedMessage(inspector, subject!, event, source, causedBy, deferred) - : deferred === undefined - ? event - : { [AcknowledgedMessageTypeId]: true as const, event, deferred } - return offerDirect(message).pipe( - Effect.tap(() => Effect.sync(() => publishInspectedSent(inspector, subject!, message))) - ) - }) - const sendAcknowledged: - | ((event: Event) => Effect.Effect, Error | StoppedError>) - | undefined = logic.execution?._tag !== "Compiled" - ? undefined - : (event) => - Effect.uninterruptibleMask((restore) => - Deferred.make, unknown>().pipe( - Effect.flatMap((deferred) => { - const offered = inspector === undefined - ? offerDirect({ [AcknowledgedMessageTypeId]: true as const, event, deferred }) - : offerInspected!(event, undefined, undefined, deferred) - return offered.pipe(Effect.andThen(restore(Deferred.await(deferred)))) - }), - Effect.map((delivery) => delivery as AcknowledgedDelivery) - ) - ) as Effect.Effect, Error | StoppedError> - - const self: ProcessAddress = { - id, - sessionId, - // Initialization must finish constructing a state before a stopped - // snapshot can be published. A stop requested there is therefore recorded - // and returns so initialization can finish. Once running, the requesting - // process waits forever and is interrupted by the supervisor after the - // stop request wins, so execution never continues after `self.stop`. - stop: Effect.suspend(() => - initializing - ? requestStop - : requestStop.pipe(Effect.andThen(Effect.never)) - ), - send: inspector === undefined - ? (event) => offerDirect(event) - : (event) => offerInspected!(event, undefined, undefined), - ...(inspector === undefined - ? undefined - : { inspectionSubject: subject!, sendInspected: offerInspected! }) - } - - let { - changes: childChanges, - close: closeChildren, - get: getChild, - owned: ownedChildren, - sendTo, - spawn, - stop: stopChild - } = childlessRuntime - if (!executionIsChildless(logic.execution)) { - ;({ - changes: childChanges, - close: closeChildren, - get: getChild, - owned: ownedChildren, - sendTo, - spawn, - stop: stopChild - } = yield* makeChildRuntime( - self, - runtime, - undefined, - sendAcknowledged === undefined ? undefined : (event) => sendAcknowledged(event as Event) - )) - } - const cleanupStartupFailure = (exit: Exit.Exit): Effect.Effect => { - if (Exit.isSuccess(exit)) return Effect.void - if (inspector !== undefined) { - inspector.publishUnsafe( - activity === undefined - ? { _tag: "StartFailed", subject: subject!, cause: exit.cause } - : { _tag: "ActivityStopped", subject: activity.owner, activity, exit } - ) - } - return closeChildren(exit).pipe( - Effect.andThen(emissions.close()), - Effect.andThen(options.inspectionRoot === true && inspector !== undefined ? inspector.close : Effect.void) - ) - } - const cleanup = onStopSync === undefined ? onStop ?? Effect.void : Effect.sync(onStopSync) - const currentCausation = inspector === undefined - ? noCausation - : (): Inspection.Causation | undefined => messageCausation(inFlightMessage, initializing) - const sendParent = overrideSendParent ?? (parent === undefined - ? noParentSend - : inspector === undefined - ? parent.send - : (event) => sendMachineTarget(parent, event, subject, currentCausation())) - const emit = inspector === undefined - ? emissions.emit - : (event: unknown) => - emissions.emit(event).pipe( - Effect.tap(() => - Effect.sync(() => - inspector.publishUnsafe({ - _tag: "Emitted", - subject: subject!, - emission: event, - causedBy: currentCausation() - }) - ) - ) - ) - const sendToTarget: ProcessScope["sendTo"] = inspector === undefined - ? ((target: unknown, event: unknown) => - isMachineTarget(target) ? target.send(event) : sendTo(target as ChildSelector, event)) as ProcessScope< - Event - >["sendTo"] - : ((target: unknown, event: unknown) => - isMachineTarget(target) - ? sendMachineTarget(target, event, subject, currentCausation()) - : sendTo(target as ChildSelector, event, subject, currentCausation())) as ProcessScope["sendTo"] - - const scope: ProcessScope = { - self, - parent, - spawn, - sendParent, - emit, - sendTo: sendToTarget, - stopChild, - failCause: (cause) => - Deferred.succeed(termination, { - _tag: "Failure", - cause: cause as Cause.Cause - }).pipe(Effect.asVoid), - inspectInitial: inspector === undefined - ? noInspectInitial - : (paths, microsteps = []) => { - initialEntryPaths = paths - initialMicrosteps = microsteps - } - } - - if (inspector !== undefined) { - inspector.publishUnsafe( - activity === undefined - ? { - _tag: "Created", - subject: subject!, - parent: parent?.inspectionSubject, - origin: options.origin ?? { _tag: "Root" }, - definition: logic.inspection?.definition - } - : { _tag: "ActivityStarted", subject: activity.owner, activity } - ) - } - - const initial = yield* logic.initial(scope).pipe( - Effect.onExit(cleanupStartupFailure), - Effect.ensuring(Effect.sync(() => { - initializing = false - })) - ) - const current = yield* SynchronizedRef.make>({ - revision: 0, - terminalizing: false, - changes: undefined, - snapshot: { - status: "active", - state: initial - } - }) - if (activity === undefined) { - inspector?.publishUnsafe({ - _tag: "Initialized", - subject: subject!, - snapshot: { status: "active", state: initial }, - initialEntryPaths: initialEntryPaths ?? [], - microsteps: InspectionRuntime.microsteps({ microsteps: initialMicrosteps ?? [] }) - }) - } - const publishSnapshot: ( - snapshot: VersionedSnapshot - ) => Effect.Effect> = onSnapshot === undefined - ? (snapshot) => - snapshot.changes === undefined - ? Effect.succeed(snapshot) - : PubSub.publish(snapshot.changes, [snapshot] as const).pipe(Effect.as(snapshot)) - : (snapshot) => { - const publish = snapshot.changes === undefined - ? Effect.succeed(snapshot) - : PubSub.publish(snapshot.changes, [snapshot] as const).pipe(Effect.as(snapshot)) - const runtimeSnapshot = snapshot.snapshot - return runtimeSnapshot.status !== "active" - ? publish - : publish.pipe(Effect.tap(() => notifyActiveSnapshot(onSnapshot, runtimeSnapshot))) - } - - const completeChanges = ( - snapshot: VersionedSnapshot - ): Effect.Effect => - snapshot.changes === undefined - ? Effect.void - : PubSub.publish(snapshot.changes, Exit.succeed(undefined)).pipe(Effect.asVoid) - - const completeIfTerminal = ( - snapshot: VersionedSnapshot - ): Effect.Effect> => { - if (snapshot.snapshot.status === "active") { - return Effect.succeed(snapshot) - } - return completeChanges(snapshot).pipe(Effect.as(snapshot)) - } - - const publishIfCurrent = ( - snapshot: VersionedSnapshot - ): Effect.Effect | undefined> => - SynchronizedRef.get(current).pipe( - Effect.flatMap(( - currentSnapshot - ): Effect.Effect | undefined> => - currentSnapshot.revision === snapshot.revision - ? publishSnapshot(snapshot).pipe(Effect.flatMap(completeIfTerminal)) - : Effect.succeed(undefined) - ) - ) - - type SnapshotModification = readonly [ - VersionedSnapshot | undefined, - VersionedSnapshot - ] - - const updateSnapshot = ( - f: ( - snapshot: RuntimeSnapshot - ) => Effect.Effect | undefined, E2, R2> - ): Effect.Effect | undefined, E2, R2> => - SynchronizedRef.modifyEffect( - current, - (current) => - current.terminalizing - ? Effect.succeed([undefined, current] as const) - : Effect.map( - f(current.snapshot), - (next) => { - if (next === undefined) { - return [undefined, current] as const - } - const versioned = { - revision: current.revision + 1, - snapshot: next, - terminalizing: false, - changes: current.changes - } - return [versioned, versioned] as const - } - ) - ).pipe( - Effect.flatMap((versioned) => versioned === undefined ? Effect.succeed(undefined) : publishIfCurrent(versioned)), - Effect.map((published) => published?.snapshot) - ) - - const reserveTerminalSnapshot = ( - f: ( - snapshot: Extract, { readonly status: "active" }> - ) => RuntimeSnapshot - ): Effect.Effect | undefined> => - SynchronizedRef.modify( - current, - (current): SnapshotModification => { - if (current.terminalizing || current.snapshot.status !== "active") { - return [undefined, current] - } - return [ - { - revision: current.revision + 1, - snapshot: f(current.snapshot), - terminalizing: true, - changes: current.changes - }, - { ...current, terminalizing: true } - ] - } - ).pipe(Effect.map((versioned) => versioned?.snapshot)) - - const setAndPublishSnapshot = ( - snapshot: RuntimeSnapshot - ): Effect.Effect => - SynchronizedRef.updateAndGet(current, (current) => ({ - revision: current.revision + 1, - snapshot, - terminalizing: true, - changes: current.changes - })).pipe( - Effect.flatMap(publishSnapshot), - Effect.flatMap(completeIfTerminal), - Effect.asVoid - ) - - const setActiveStateDirect = (state: State) => - updateSnapshot((snapshot) => - Effect.succeed( - snapshot.status === "active" - ? { - status: "active", - state - } - : undefined - ) - ).pipe(Effect.asVoid) - - const setActiveState = inspector === undefined ? - setActiveStateDirect : - (state: State) => - SynchronizedRef.get(current).pipe( - Effect.flatMap((before) => - updateSnapshot((snapshot) => - Effect.succeed( - snapshot.status === "active" - ? { - status: "active", - state - } - : undefined - ) - ).pipe( - Effect.tap((after) => - Effect.sync(() => { - if (logic.inspection?.kind === "Machine" || after === undefined) return - inspector.publishUnsafe({ - _tag: "StateChanged", - subject: subject!, - before: before.snapshot.state, - after: state, - causedByDeliveryId: inFlightMessage !== undefined && isAcknowledgedMessage(inFlightMessage) - ? inFlightMessage.inspection?.deliveryId - : undefined - }) - }) - ) - ) - ), - Effect.asVoid - ) - - const terminalizeWith = ( - snapshot: RuntimeSnapshot, - exit: Exit.Exit, - completeDone: Effect.Effect - ): Effect.Effect => { - const notifyOutcome = - onOutcome === undefined || (snapshot.status === "stopped" && options.skipStoppedOutcome === true) - ? Effect.void - : Effect.suspend(() => onOutcome(classifyOutcome(snapshot)!)).pipe( - Effect.exit, - Effect.asVoid - ) - const closeEmissionsAndInspect = inspector === undefined - ? emissions.close() - : emissions.close().pipe( - Effect.andThen(Effect.sync(() => - inspector.publishUnsafe( - activity === undefined - ? { _tag: "Terminated", subject: subject!, snapshot } - : { - _tag: "ActivityStopped", - subject: activity.owner, - activity, - exit: snapshot.status === "stopped" ? Exit.interrupt() : exit - } - ) - )) - ) - return Effect.uninterruptible( - Effect.sync(() => { - while (true) { - const pending = Queue.takeUnsafe(queue) - if (pending === undefined || Exit.isFailure(pending)) break - stopAcknowledgedMessage(pending.value) - } - }).pipe( - Effect.andThen(Queue.shutdown(queue)), - Effect.andThen(closeChildren(exit)), - Effect.andThen(setAndPublishSnapshot(snapshot)), - Effect.andThen(closeEmissionsAndInspect), - Effect.andThen(Effect.sync(() => { - if (Exit.isFailure(exit)) { - failAcknowledgedMessage(inFlightMessage, exit.cause) - } else { - stopAcknowledgedMessage(inFlightMessage) - } - inFlightMessage = undefined - })), - Effect.andThen(notifyOutcome), - Effect.andThen(cleanup), - Effect.andThen(options.inspectionRoot === true && inspector !== undefined ? inspector.close : Effect.void), - Effect.andThen(completeDone) - ) - ) - } - - const reserveStoppedSnapshot = reserveTerminalSnapshot((snapshot) => ({ - status: "stopped", - state: snapshot.state - })) - - const reserveFailureSnapshot = (cause: Cause.Cause) => - reserveTerminalSnapshot((snapshot) => ({ - status: "error", - state: snapshot.state, - cause - })) - - const reserveSuccessSnapshot = (output: Output) => - reserveTerminalSnapshot((snapshot) => ({ - status: "done", - state: snapshot.state, - output - })) - - const terminalizeReservedStop = ( - snapshot: RuntimeSnapshot - ): Effect.Effect => { - const exit = Exit.void - return terminalizeWith( - snapshot, - exit, - Deferred.fail(done, new StoppedError()) - ) - } - - const terminalizeReservedFailure = ( - snapshot: RuntimeSnapshot, - cause: Cause.Cause - ): Effect.Effect => { - const exit = Exit.failCause(cause) - return terminalizeWith(snapshot, exit, Deferred.failCause(done, cause)) - } - - const terminalizeReservedSuccess = ( - snapshot: RuntimeSnapshot, - output: Output - ): Effect.Effect => { - const exit = Exit.succeed(output) - return terminalizeWith(snapshot, exit, Deferred.succeed(done, output)) - } - - const stop: Effect.Effect = Effect.uninterruptible( - requestStop.pipe(Effect.andThen(awaitCompletion)) - ) - - const acknowledgedContext: - | Pick< - ProcessContext, - "receiveMessage" | "pollMessage" | "completeMessage" - > - | undefined = logic.execution?._tag !== "Compiled" ? undefined : { - receiveMessage: Queue.take(queue).pipe( - Effect.tap((message) => - Effect.sync(() => { - inFlightMessage = isAcknowledgedMessage(message) ? message : undefined - }) - ) - ), - pollMessage: Queue.poll(queue).pipe( - Effect.tap((message) => - Effect.sync(() => { - if (Option.isSome(message)) { - inFlightMessage = isAcknowledgedMessage(message.value) ? message.value : undefined - } - }) - ) - ), - completeMessage: (delivery) => { - succeedAcknowledgedMessage(inFlightMessage, delivery) - inFlightMessage = undefined - } - } - const receive = inspector === undefined || logic.execution?._tag === "Compiled" - ? Queue.take(queue).pipe(Effect.map(messageEvent)) - : Queue.take(queue).pipe( - Effect.tap((message) => - Effect.sync(() => { - inFlightMessage = message - }) - ), - Effect.map(messageEvent) - ) - const poll = inspector === undefined || logic.execution?._tag === "Compiled" - ? Queue.poll(queue).pipe(Effect.map(Option.map(messageEvent))) - : Queue.poll(queue).pipe( - Effect.tap((message) => - Effect.sync(() => { - if (Option.isSome(message)) inFlightMessage = message.value - }) - ), - Effect.map(Option.map(messageEvent)) - ) - const updateStateDirect = (f: (state: State) => Effect.Effect) => - updateSnapshot((snapshot) => - snapshot.status === "active" - ? f(snapshot.state).pipe( - Effect.map((state) => ({ - status: "active" as const, - state - })) - ) - : Effect.succeed(undefined) - ).pipe(Effect.asVoid) - const updateState: ProcessContext["updateState"] = inspector === undefined - ? updateStateDirect - : (f) => - SynchronizedRef.get(current).pipe( - Effect.flatMap((before) => - updateSnapshot((snapshot) => - snapshot.status === "active" - ? f(snapshot.state).pipe( - Effect.map((state) => ({ - status: "active" as const, - state - })) - ) - : Effect.succeed(undefined) - ).pipe( - Effect.tap((after) => - Effect.sync(() => { - if (logic.inspection?.kind === "Machine" || after?.status !== "active") return - inspector.publishUnsafe({ - _tag: "StateChanged", - subject: subject!, - before: before.snapshot.state, - after: after.state, - causedByDeliveryId: inFlightMessage !== undefined && isAcknowledgedMessage(inFlightMessage) - ? inFlightMessage.inspection?.deliveryId - : undefined - }) - }) - ) - ) - ), - Effect.asVoid - ) - const context: ProcessContext = { - ...scope, - ...(logic.execution?._tag === "Compiled" && !logic.execution.childless ? { ownedChildren } : undefined), - ...acknowledgedContext, - receive, - poll, - state: SynchronizedRef.get(current).pipe(Effect.map((current) => current.snapshot.state)), - setState: setActiveState, - updateState - } - - const getOrCreateChanges = SynchronizedRef.modifyEffect( - current, - (current) => { - if (current.snapshot.status !== "active") { - return Effect.succeed([undefined, current] as const) - } - if (current.changes !== undefined) { - return Effect.succeed([current.changes, current] as const) - } - return PubSub.unbounded>>({ replay: 1 }).pipe( - Effect.map((changes) => [changes, { ...current, changes }] as const) - ) - } - ) - - const changesStream: Stream.Stream> = Stream.unwrap( - Effect.gen(function*() { - const changes = yield* getOrCreateChanges - if (changes === undefined) { - return Stream.succeed((yield* SynchronizedRef.get(current)).snapshot) - } - const subscription = yield* PubSub.subscribe(changes) - const captured = yield* SynchronizedRef.get(current) - if (captured.snapshot.status !== "active") { - return Stream.succeed(captured.snapshot) - } - return Stream.succeed(captured.snapshot).pipe( - Stream.concat( - Stream.fromChannel(Channel.fromEffectTake(PubSub.take(subscription))).pipe( - Stream.filter((next) => next.revision > captured.revision), - Stream.map((next) => next.snapshot) - ) - ) - ) - }) - ) - - const ref: MachineRef = { - id, - sessionId, - ...(inspector === undefined - ? undefined - : { inspectionSubject: subject!, sendInspected: offerInspected! }), - state: SynchronizedRef.get(current).pipe(Effect.map((current) => current.snapshot.state)), - snapshot: SynchronizedRef.get(current).pipe(Effect.map((current) => current.snapshot)), - changes: changesStream, - emissions: emissions.stream as Stream.Stream, - join: Deferred.await(done), - stop, - send: self.send, - ...(sendAcknowledged === undefined ? undefined : { [acknowledgedSend]: sendAcknowledged }), - child: getChild, - childChanges - } - - if (onReadySync !== undefined && !onReadySync(ref)) { - yield* requestStop - } else if (onReady !== undefined) { - yield* onReady(ref, requestStop) - } - if (onSnapshot !== undefined) { - yield* notifyActiveSnapshot(onSnapshot, { status: "active", state: initial }) - } - - const reserveTermination = (termination: ProcessTermination) => { - switch (termination._tag) { - case "Stopped": - return reserveStoppedSnapshot - case "Done": - return reserveSuccessSnapshot(termination.output) - case "Failure": - return reserveFailureSnapshot(termination.cause) - } - } - - const completeTermination = ( - termination: ProcessTermination, - snapshot: RuntimeSnapshot - ) => { - switch (termination._tag) { - case "Stopped": - return terminalizeReservedStop(snapshot) - case "Done": - return terminalizeReservedSuccess(snapshot, termination.output) - case "Failure": - return terminalizeReservedFailure(snapshot, termination.cause) - } - } - - const forkRuntime = (effect: Effect.Effect) => - detached === true - ? Effect.forkDetach(effect) - : Effect.forkChild(effect) - - const pendingTermination = yield* Deferred.poll(termination) - const worker = Option.isNone(pendingTermination) - ? yield* Effect.uninterruptibleMask((restore) => - restore(Effect.suspend(() => logic.run(context))).pipe( - Effect.exit, - Effect.flatMap((exit) => - Deferred.succeed( - termination, - Exit.isFailure(exit) - ? { _tag: "Failure", cause: exit.cause } - : { _tag: "Done", output: exit.value } - ) - ) - ) - ).pipe(forkRuntime) - : undefined - - // One Deferred arbitrates all terminal causes. The supervisor reserves the - // terminal snapshot before interrupting the worker, so worker finalizers - // cannot mutate the frozen state. It then waits for those finalizers before - // publishing and completing `join` / `stop`. - const runFiber: Effect.Effect = Effect.uninterruptibleMask((restore) => - Effect.gen(function*() { - const requested = Option.isSome(pendingTermination) - ? yield* pendingTermination.value - : yield* restore(Deferred.await(termination)) - - const snapshot = yield* reserveTermination(requested) - if (worker !== undefined) { - yield* Fiber.interrupt(worker) - } - if (snapshot === undefined) { - return yield* awaitCompletion - } - return yield* completeTermination(requested, snapshot) - }) - ) - - yield* forkRuntime(runFiber) - yield* Effect.yieldNow - - return ref -}) - -type CompiledTermination = - | { readonly _tag: "Stopped" } - | { readonly _tag: "Done"; readonly output: unknown } - | { readonly _tag: "Failure"; readonly cause: Cause.Cause } - -type CompiledInitialized = CompiledProcessInitial - -// Stopping is commonly used only for resource cleanup. Keep that path free of -// Error stack capture and materialize the typed join failure only if observed. -const CompiledStoppedCompletion: unique symbol = Symbol("effect/Machine/CompiledStoppedCompletion") - -type CompiledCompletion = - | Effect.Effect - | typeof CompiledStoppedCompletion - -type CompiledLifecycle = "Active" | "TerminationRequested" | "Completed" -type CompiledRunState = "Initializing" | "Idle" | "Draining" - -/** - * Compact runtime for compiled statecharts. - * - * All long-lived state is stored directly on this object. Operations are - * implemented by shared prototype methods, and public Effect / Stream values - * are materialized only when accessed. Arbitrary process logic continues to - * use the general runtime above. - */ -class CompiledProcess implements MachineRef { - readonly id: string - readonly sessionId: string - readonly inspectionSubject?: Inspection.Subject - readonly send: (event: unknown) => Effect.Effect - sendInspected( - event: unknown, - source: Inspection.Subject | undefined, - causedBy: Inspection.Causation | undefined - ): Effect.Effect { - return this.offerEvent(event, source, causedBy) - } - [acknowledgedSend]( - event: unknown - ): Effect.Effect, unknown | StoppedError> { - return this.sendAcknowledgedEffect(event) - } - - private readonly mailbox: CompactProcessMailbox = { - items: undefined, - index: 0, - closed: false - } - private readonly address: ProcessAddress - private childRuntime: ChildRuntime = childlessRuntime - private processScope!: ProcessScope - private processContext: ProcessContext | undefined - private compiledContext: CompiledProcessContext | undefined - private current!: VersionedSnapshot - /** - * `Active` has no terminal payload, `TerminationRequested` owns - * `termination` and its optional reserved snapshot, and `Completed` owns - * `completion`. `runState` independently tracks worker activity. - */ - private lifecycle: CompiledLifecycle = "Active" - private runState: CompiledRunState = "Initializing" - private completion: CompiledCompletion | undefined - private waiter: Deferred.Deferred | undefined - private termination: CompiledTermination | undefined - private terminationSnapshot: RuntimeSnapshot | undefined - private worker: Fiber.Fiber | undefined - private interruptRequested = false - private offerRevision = 0 - private inFlightMessage: ProcessMessage | undefined - private readonly externalEmissions: EmissionRuntime | undefined - private emissionsPubSub: LazyEmissions - private readonly activity?: Inspection.Activity - private initialEntryPaths?: ReadonlyArray - private initialMicrosteps?: ReadonlyArray - private initializing?: boolean - - constructor( - private readonly logic: ProcessLogic, - private readonly options: StartInternalOptions, - private readonly services: Context.Context, - sessionId: string - ) { - this.sessionId = sessionId - this.id = options.id ?? sessionId - const inspector = options.runtime.inspection - if (inspector !== undefined) { - this.inspectionSubject = inspectionSubject(logic, this.id, sessionId) - this.initializing = true - if (options.activity !== undefined) this.activity = { ...options.activity, sessionId } - } - this.externalEmissions = options.emissions - this.send = inspector === undefined - ? (event) => this.offerMessage(event) - : (event) => this.offerEvent(event, undefined, undefined) - this.address = { - id: this.id, - sessionId, - stop: Effect.suspend(() => this.stopFromProcess()), - send: this.send, - ...(inspector === undefined - ? undefined - : { - inspectionSubject: this.inspectionSubject!, - sendInspected: ( - event: unknown, - source: Inspection.Subject | undefined, - causedBy: Inspection.Causation | undefined - ) => this.offerEvent(event, source, causedBy) - }) - } - } - - private get inspector(): InspectionRuntime.Runtime | undefined { - return this.options.runtime.inspection - } - - private causation(): Inspection.Causation | undefined { - return messageCausation(this.inFlightMessage, this.initializing === true) - } - - private publishCreated(): void { - const inspector = this.inspector - const subject = this.inspectionSubject - if (inspector === undefined || subject === undefined) return - inspector.publishUnsafe( - this.activity === undefined - ? { - _tag: "Created", - subject, - parent: this.options.parent?.inspectionSubject, - origin: this.options.origin ?? { _tag: "Root" }, - definition: this.logic.inspection?.definition - } - : { _tag: "ActivityStarted", subject: this.activity.owner, activity: this.activity } - ) - } - - private publishInitialized(state: unknown): void { - this.initializing = false - if (this.activity !== undefined) return - const inspector = this.inspector - const subject = this.inspectionSubject - if (inspector === undefined || subject === undefined) return - inspector.publishUnsafe({ - _tag: "Initialized", - subject, - snapshot: { status: "active", state }, - initialEntryPaths: this.initialEntryPaths ?? [], - microsteps: InspectionRuntime.microsteps({ microsteps: this.initialMicrosteps ?? [] }) - }) - } - - private publishStartFailed(cause: Cause.Cause): void { - this.initializing = false - const inspector = this.inspector - const subject = this.inspectionSubject - if (inspector === undefined || subject === undefined) return - inspector.publishUnsafe( - this.activity === undefined - ? { _tag: "StartFailed", subject, cause } - : { - _tag: "ActivityStopped", - subject: this.activity.owner, - activity: this.activity, - exit: Exit.failCause(cause) - } - ) - } - - private get execution(): CompiledProcessExecution { - return this.logic.execution as CompiledProcessExecution - } - - initializeCompiledSync(): Effect.Effect, unknown> { - this.publishCreated() - if (!this.execution.childless) { - this.childRuntime = makeChildRuntimeSync( - this.address, - this.options.runtime, - this.services, - (event) => this.sendAcknowledgedEffect(event) - ) - } - const parent = this.options.parent - const sendParent = this.options.sendParent ?? (parent === undefined - ? noParentSend - : this.inspector === undefined - ? parent.send - : (event: unknown) => sendMachineTarget(parent, event, this.inspectionSubject, this.causation())) - this.processScope = { - self: this.address, - parent, - spawn: this.childRuntime.spawn, - sendParent, - emit: (event) => this.emitEvent(event), - sendTo: ((target: unknown, event: unknown) => - isMachineTarget(target) - ? this.inspector === undefined - ? target.send(event) - : sendMachineTarget(target, event, this.inspectionSubject, this.causation()) - : this.childRuntime.sendTo( - target as ChildSelector, - event, - this.inspectionSubject, - this.causation() - )) as ProcessScope["sendTo"], - stopChild: this.childRuntime.stop, - failCause: (cause: Cause.Cause) => this.failCause(cause), - inspectInitial: this.inspector === undefined - ? noInspectInitial - : (paths, microsteps = []) => { - this.initialEntryPaths = paths - this.initialMicrosteps = microsteps - } - } - const compiledInitial = this.execution.initialSync! - let initialized: CompiledInitialized - try { - initialized = compiledInitial(this.processScope) - } catch (error) { - this.runState = "Idle" - this.publishStartFailed(Cause.fail(error)) - return Effect.fail(error) - } - this.runState = "Idle" - this.current = { - revision: 0, - terminalizing: false, - changes: undefined, - snapshot: { status: "active", state: initialized.state } - } - this.publishInitialized(initialized.state) - this.compiledContext = new CompiledProcessContextImpl(this.processScope, this.childRuntime.owned, this) - if ("executionState" in initialized) { - this.compiledContext.executionState = initialized.executionState - } - if (this.options.onReadySync !== undefined && !this.options.onReadySync(this)) { - this.requestTerminationSync({ _tag: "Stopped" }) - } - if (initialized.done === true && this.lifecycle === "Active") { - this.requestTerminationSync({ _tag: "Done", output: initialized.output }) - } - if ( - initialized.done === false && this.execution.childless && - this.lifecycle === "Active" && - this.mailbox.items === undefined - ) { - return Effect.succeed(this) - } - this.runState = "Draining" - return Effect.provideContext(this.drainRuntime(), this.services).pipe(Effect.as(this)) - } - - initialize(): Effect.Effect, unknown, any> { - const self = this - return Effect.gen(function*() { - self.publishCreated() - if (!self.execution.childless) { - self.childRuntime = yield* makeChildRuntime( - self.address, - self.options.runtime, - self.services, - (event) => self.sendAcknowledgedEffect(event) - ) - } - const parent = self.options.parent - const sendParent = self.options.sendParent ?? (parent === undefined - ? noParentSend - : self.inspector === undefined - ? parent.send - : (event: unknown) => sendMachineTarget(parent, event, self.inspectionSubject, self.causation())) - self.processScope = { - self: self.address, - parent, - spawn: self.childRuntime.spawn, - sendParent, - emit: (event) => self.emitEvent(event), - sendTo: ((target: unknown, event: unknown) => - isMachineTarget(target) - ? self.inspector === undefined - ? target.send(event) - : sendMachineTarget(target, event, self.inspectionSubject, self.causation()) - : self.childRuntime.sendTo( - target as ChildSelector, - event, - self.inspectionSubject, - self.causation() - )) as ProcessScope["sendTo"], - stopChild: self.childRuntime.stop, - failCause: (cause: Cause.Cause) => self.failCause(cause), - inspectInitial: self.inspector === undefined - ? noInspectInitial - : (paths, microsteps = []) => { - self.initialEntryPaths = paths - self.initialMicrosteps = microsteps - } - } - - const cleanupStartupFailure = (exit: Exit.Exit): Effect.Effect => - Exit.isFailure(exit) ? self.childRuntime.close(exit) : Effect.void - const compiledInitial = self.execution.initial - const initializeEffect: Effect.Effect< - { - readonly state: unknown - readonly done: boolean | undefined - readonly output: unknown - }, - unknown, - any - > = compiledInitial === undefined - ? self.logic.initial(self.processScope).pipe( - Effect.map((state) => ({ state, done: undefined, output: undefined } as const)) - ) - : compiledInitial(self.processScope) - const initialized = yield* initializeEffect.pipe( - Effect.onExit((exit) => { - if (Exit.isFailure(exit)) self.publishStartFailed(exit.cause) - return cleanupStartupFailure(exit) - }), - Effect.ensuring(Effect.sync(() => { - self.runState = "Idle" - })) - ) - const initial = initialized.state - self.current = { - revision: 0, - terminalizing: false, - changes: undefined, - snapshot: { status: "active", state: initial } - } - self.publishInitialized(initial) - if (self.execution.drain._tag === "Process") { - self.processContext = { - ...self.processScope, - receive: Effect.never, - poll: Effect.sync(() => Option.map(pollCompactMailbox(self.mailbox), messageEvent)), - receiveMessage: Effect.never, - pollMessage: Effect.sync(() => self.pollCompiledMessage()), - completeMessage: (delivery) => self.completeCompiledMessage(delivery), - state: Effect.sync(() => self.current.snapshot.state), - setState: (state: unknown) => self.setActiveState(state), - updateState: (f) => self.updateState(f) - } - } else { - self.compiledContext = new CompiledProcessContextImpl(self.processScope, self.childRuntime.owned, self) - if ("executionState" in initialized) { - self.compiledContext.executionState = initialized.executionState - } - } - - if (self.options.onReadySync !== undefined && !self.options.onReadySync(self)) { - yield* self.requestTermination({ _tag: "Stopped" }) - } else if (self.options.onReady !== undefined) { - yield* self.options.onReady(self, self.requestTermination({ _tag: "Stopped" }).pipe(Effect.asVoid)) - } - if (self.options.onSnapshot !== undefined) { - yield* notifyActiveSnapshot(self.options.onSnapshot, { status: "active", state: initial }) - } - if (initialized.done === true && self.lifecycle === "Active") { - yield* self.requestTermination({ _tag: "Done", output: initialized.output }) - } - - // A compiled machine startup plan has already settled entry actions, - // raised events, and eventless transitions. If it is known active and - // neither startup hooks nor emitted work queued an event, there is no - // first drain to perform. Future sends observe an idle run state and - // schedule the ordinary compiled worker. - if ( - initialized.done === false && self.execution.childless && - self.lifecycle === "Active" && self.mailbox.items === undefined - ) { - return self - } - - self.runState = "Draining" - yield* self.drainRuntime() - return self - }) - } - - get state(): Effect.Effect { - return Effect.sync(() => this.current.snapshot.state) - } - - get snapshot(): Effect.Effect> { - return Effect.sync(() => this.current.snapshot) - } - - get changes(): Stream.Stream> { - return this.changesStream() - } - - get emissions(): Stream.Stream { - return (this.externalEmissions?.stream ?? this.emissionsStream()) as Stream.Stream - } - - get join(): Effect.Effect { - return Effect.suspend(() => { - if (this.lifecycle === "Completed") { - return this.resolveCompletion(this.completion!) - } - this.waiter ??= Deferred.makeUnsafe() - return Deferred.await(this.waiter) - }) - } - - get stop(): Effect.Effect { - return Effect.uninterruptible(this.stopEffect()) - } - - child(child: ChildSelector): Effect.Effect> { - return this.childRuntime.get(child) - } - - childChanges(child: ChildSelector): Stream.Stream> { - return this.childRuntime.changes(child) - } - - private requestTermination(requested: CompiledTermination): Effect.Effect { - return Effect.sync(() => this.requestTerminationSync(requested)) - } - - private hasTerminationRequest(): boolean { - return this.lifecycle === "TerminationRequested" - } - - private requestTerminationSync(requested: CompiledTermination): boolean { - if (this.lifecycle !== "Active") { - return false - } - this.lifecycle = "TerminationRequested" - this.termination = requested - this.terminationSnapshot = this.reserveTermination(requested) - return true - } - - private reserveTermination( - requested: CompiledTermination - ): RuntimeSnapshot | undefined { - const latest = this.current - if (latest === undefined || latest.terminalizing || latest.snapshot.status !== "active") { - return undefined - } - const snapshot: RuntimeSnapshot = requested._tag === "Stopped" - ? { status: "stopped", state: latest.snapshot.state } - : requested._tag === "Done" - ? { status: "done", state: latest.snapshot.state, output: requested.output } - : { status: "error", state: latest.snapshot.state, cause: requested.cause } - this.current = { ...latest, terminalizing: true } - return snapshot - } - - private stopFromProcess(): Effect.Effect { - const request = this.requestTermination({ _tag: "Stopped" }).pipe(Effect.asVoid) - return this.runState === "Initializing" ? request : request.pipe(Effect.andThen(Effect.interrupt)) - } - - private failCause(cause: Cause.Cause): Effect.Effect { - const requested = { _tag: "Failure", cause } as const - return this.requestTermination(requested).pipe( - Effect.flatMap((accepted) => - accepted - ? Effect.forkDetach(this.settleRequestedTermination()).pipe(Effect.asVoid) - : Effect.void - ) - ) - } - - private sendAcknowledgedEffect( - event: unknown - ): Effect.Effect, unknown | StoppedError> { - return Effect.uninterruptibleMask((restore) => - Deferred.make, unknown>().pipe( - Effect.flatMap((deferred) => - this.offerEvent(event, undefined, undefined, deferred).pipe( - Effect.andThen(restore(Deferred.await(deferred))) - ) - ) - ) - ) - } - - private offerEvent( - event: unknown, - source: Inspection.Subject | undefined, - causedBy: Inspection.Causation | undefined, - deferred?: Deferred.Deferred, unknown> - ): Effect.Effect { - return Effect.suspend(() => { - const inspector = this.inspector - const subject = this.inspectionSubject - const message: ProcessMessage = inspector?.isActive() === true && subject !== undefined - ? makeInspectedMessage( - inspector, - subject, - event, - source, - causedBy, - deferred - ) - : deferred === undefined - ? event - : { [AcknowledgedMessageTypeId]: true as const, event, deferred } - return this.offerMessage(message) - }) - } - - private offerMessage(message: ProcessMessage): Effect.Effect { - return Effect.uninterruptible( - Effect.suspend(() => { - if (this.mailbox.closed || this.lifecycle !== "Active") { - return Effect.fail(new StoppedError()) - } - offerCompactMailbox(this.mailbox, message) - const inspector = this.inspector - const subject = this.inspectionSubject - if (inspector !== undefined && subject !== undefined) publishInspectedSent(inspector, subject, message) - this.offerRevision += 1 - if (this.runState === "Draining") { - return Effect.void - } - this.runState = "Draining" - const scheduled = Effect.yieldNow.pipe( - Effect.andThen(Effect.provideContext(this.drainRuntime(), this.services)) - ) - const fork = this.options.detached === true - ? Effect.forkDetach(scheduled, { startImmediately: true }) - : Effect.forkChild(scheduled, { startImmediately: true }) - return fork.pipe( - Effect.flatMap((fiber) => - Effect.sync(() => { - this.worker = fiber - if (!this.interruptRequested) { - return false - } - this.interruptRequested = false - return true - }).pipe( - Effect.flatMap((interrupt) => interrupt ? this.interruptAndFinish(fiber) : Effect.void) - ) - ), - Effect.asVoid - ) - }) - ) - } - - private stopEffect(): Effect.Effect { - return Effect.suspend(() => { - if (this.lifecycle === "Completed") { - return Effect.void - } - if (this.finishIdleChildlessStop()) { - return Effect.void - } - const requested = { _tag: "Stopped" } as const - return this.requestTermination(requested).pipe( - Effect.flatMap((accepted) => - accepted - ? this.settleRequestedTermination() - : this.awaitCompletion() - ) - ) - }) - } - - private finishIdleChildlessStop(): boolean { - if ( - !this.execution.childless || this.runState !== "Idle" || this.worker !== undefined || - this.lifecycle !== "Active" || - (this.options.onOutcome !== undefined && this.options.skipStoppedOutcome !== true) || - this.options.onStop !== undefined || - this.inspector !== undefined || - this.current.changes !== undefined || - this.current.terminalizing || this.current.snapshot.status !== "active" - ) { - return false - } - const snapshot = { status: "stopped" as const, state: this.current.snapshot.state } - this.lifecycle = "TerminationRequested" - this.termination = { _tag: "Stopped" } - this.terminationSnapshot = snapshot - closeCompactMailbox(this.mailbox) - this.current = { - revision: this.current.revision + 1, - terminalizing: true, - changes: undefined, - snapshot - } - stopAcknowledgedMessage(this.inFlightMessage) - this.inFlightMessage = undefined - this.interruptRequested = false - if (this.compiledContext !== undefined) { - this.compiledContext.executionState = undefined - } - this.options.onStopSync?.() - this.completion = CompiledStoppedCompletion - this.lifecycle = "Completed" - if (this.waiter !== undefined) { - Deferred.doneUnsafe(this.waiter, this.resolveCompletion(CompiledStoppedCompletion)) - this.waiter = undefined - } - return true - } - - private settleRequestedTermination(): Effect.Effect { - return Effect.suspend(() => { - if (this.runState !== "Draining") { - return this.finishRequestedTermination() - } - if (this.worker === undefined) { - this.interruptRequested = true - return this.awaitCompletion() - } - return this.interruptAndFinish(this.worker).pipe( - Effect.andThen(this.awaitCompletion()) - ) - }) - } - - private interruptAndFinish(worker: Fiber.Fiber): Effect.Effect { - return Fiber.interrupt(worker).pipe( - Effect.andThen( - Effect.suspend(() => - this.lifecycle !== "Completed" - ? this.finishRequestedTermination() - : Effect.void - ) - ) - ) - } - - private awaitCompletion(): Effect.Effect { - return this.lifecycle !== "Completed" - ? this.join.pipe(Effect.exit, Effect.asVoid) - : Effect.void - } - - private resolveCompletion(completion: CompiledCompletion): Effect.Effect { - if (completion !== CompiledStoppedCompletion) { - return completion - } - const stopped = Effect.fail(new StoppedError()) - this.completion = stopped - return stopped - } - - private drainRuntime(): Effect.Effect { - const self = this - return Effect.uninterruptibleMask((restore) => - Effect.gen(function*() { - let observedRevision = self.offerRevision - while (true) { - if (self.hasTerminationRequest()) { - return yield* self.finishRequestedTermination() - } - - const exit = yield* restore( - Effect.suspend(() => { - const drain = self.execution.drain - return drain._tag === "Process" - ? drain.run(self.processContext!) - : drain.run(self.compiledContext!) - }) - ).pipe(Effect.exit) - self.flushPendingChanges() - if (Exit.isFailure(exit)) { - if (self.lifecycle === "Active") { - yield* self.requestTermination({ _tag: "Failure", cause: exit.cause }) - } - return yield* self.finishRequestedTermination() - } - if (Option.isSome(exit.value)) { - yield* self.requestTermination({ _tag: "Done", output: exit.value.value }) - return yield* self.finishRequestedTermination() - } - if (self.hasTerminationRequest()) { - return yield* self.finishRequestedTermination() - } - - if (self.offerRevision !== observedRevision) { - observedRevision = self.offerRevision - continue - } - self.runState = "Idle" - self.worker = undefined - return - } - }) - ) - } - - private finishRequestedTermination(): Effect.Effect { - return Effect.suspend(() => { - if (this.lifecycle !== "TerminationRequested") { - return Effect.void - } - const requested = this.termination - if (requested === undefined) { - return Effect.void - } - const snapshot = this.terminationSnapshot ?? this.reserveTermination(requested) - if (snapshot === undefined) { - return this.awaitCompletion() - } - const exit = requested._tag === "Stopped" - ? Exit.void - : requested._tag === "Done" - ? Exit.succeed(requested.output) - : Exit.failCause(requested.cause) - const completion: CompiledCompletion = requested._tag === "Stopped" - ? CompiledStoppedCompletion - : requested._tag === "Done" - ? Effect.succeed(requested.output) - : Effect.failCause(requested.cause) - const notifyOutcome = this.options.onOutcome === undefined || - (requested._tag === "Stopped" && this.options.skipStoppedOutcome === true) - ? Effect.void - : Effect.suspend(() => this.options.onOutcome!(classifyOutcome(snapshot)!)).pipe( - Effect.exit, - Effect.asVoid - ) - const inspector = this.inspector - const subject = this.inspectionSubject - const closeEmissionsAndInspect = inspector === undefined || subject === undefined - ? this.closeEmissions() - : this.closeEmissions().pipe( - Effect.andThen(Effect.sync(() => - inspector.publishUnsafe( - this.activity === undefined - ? { _tag: "Terminated", subject, snapshot } - : { - _tag: "ActivityStopped", - subject: this.activity.owner, - activity: this.activity, - exit: snapshot.status === "stopped" ? Exit.interrupt() : exit - } - ) - )) - ) - return Effect.uninterruptible( - Effect.sync(() => { - closeCompactMailbox(this.mailbox) - }).pipe( - Effect.andThen(this.childRuntime.close(exit)), - Effect.andThen(this.setAndPublishSnapshot(snapshot)), - Effect.andThen(closeEmissionsAndInspect), - Effect.andThen(Effect.sync(() => { - if (requested._tag === "Failure") { - failAcknowledgedMessage(this.inFlightMessage, requested.cause) - } else { - stopAcknowledgedMessage(this.inFlightMessage) - } - this.inFlightMessage = undefined - })), - Effect.andThen(notifyOutcome), - Effect.andThen(this.options.onStop ?? Effect.void), - Effect.andThen( - this.options.inspectionRoot === true && this.inspector !== undefined - ? this.inspector.close - : Effect.void - ), - Effect.andThen(Effect.sync(() => { - this.options.onStopSync?.() - this.runState = "Idle" - this.worker = undefined - this.interruptRequested = false - if (this.compiledContext !== undefined) { - this.compiledContext.executionState = undefined - } - this.completion = completion - this.lifecycle = "Completed" - if (this.waiter !== undefined) { - Deferred.doneUnsafe(this.waiter, this.resolveCompletion(completion)) - this.waiter = undefined - } - })) - ) - ) - }) - } - - private publishSnapshot( - snapshot: VersionedSnapshot - ): Effect.Effect> { - const publish = snapshot.changes === undefined - ? Effect.succeed(snapshot) - : PubSub.publish(snapshot.changes, [snapshot] as const).pipe(Effect.as(snapshot)) - const current = snapshot.snapshot - return this.options.onSnapshot === undefined || current.status !== "active" - ? publish - : publish.pipe(Effect.tap(() => notifyActiveSnapshot(this.options.onSnapshot!, current))) - } - - private completeChanges(snapshot: VersionedSnapshot): Effect.Effect { - return snapshot.changes === undefined - ? Effect.void - : PubSub.publish(snapshot.changes, Exit.succeed(undefined)).pipe(Effect.asVoid) - } - - private emitEvent(event: unknown): Effect.Effect { - const publish = this.externalEmissions !== undefined ? - this.externalEmissions.emit(event) : - Effect.suspend(() => - this.emissionsPubSub === undefined || this.emissionsPubSub === EmissionsClosed - ? Effect.void - : PubSub.publish(this.emissionsPubSub, event).pipe(Effect.asVoid) - ) - const inspector = this.inspector - const subject = this.inspectionSubject - if (inspector === undefined || subject === undefined) return publish - return publish.pipe(Effect.tap(() => - Effect.sync(() => - inspector.publishUnsafe({ - _tag: "Emitted", - subject, - emission: event, - causedBy: this.causation() - }) - ) - )) - } - - shutdownEmissions(): Effect.Effect { - return this.closeEmissions() - } - - private closeEmissions(): Effect.Effect { - if (this.externalEmissions !== undefined) return this.externalEmissions.close() - const observed = this.emissionsPubSub - this.emissionsPubSub = EmissionsClosed - return observed === undefined || observed === EmissionsClosed ? Effect.void : PubSub.shutdown(observed) - } - - private getOrCreateEmissions(): Effect.Effect | undefined> { - return Effect.suspend(() => { - const observed = this.emissionsPubSub - if (observed === EmissionsClosed) return Effect.succeed(undefined) - if (observed !== undefined) return Effect.succeed(observed) - return PubSub.unbounded().pipe( - Effect.flatMap((candidate) => - Effect.sync(() => { - const latest = this.emissionsPubSub - if (latest === EmissionsClosed) return [undefined, true] as const - if (latest !== undefined) return [latest, true] as const - this.emissionsPubSub = candidate - return [candidate, false] as const - }).pipe( - Effect.flatMap(([selected, discard]) => - discard ? PubSub.shutdown(candidate).pipe(Effect.as(selected)) : Effect.succeed(selected) - ) - ) - ) - ) - }) - } - - private emissionsStream(): Stream.Stream { - return Stream.unwrap( - this.getOrCreateEmissions().pipe( - Effect.map((emissions) => emissions === undefined ? Stream.empty : Stream.fromPubSub(emissions)) - ) - ) - } - - private setAndPublishSnapshot(snapshot: RuntimeSnapshot): Effect.Effect { - return Effect.suspend(() => { - this.flushPendingChanges() - const versioned = { - revision: this.current.revision + 1, - snapshot, - terminalizing: true, - changes: this.current.changes - } - this.current = versioned - return this.publishSnapshot(versioned).pipe( - Effect.flatMap((published) => this.completeChanges(published)), - Effect.asVoid - ) - }) - } - - private setActiveState(state: unknown): Effect.Effect { - return Effect.suspend(() => this.commitActiveState(state) ?? Effect.void) - } - - pollCompiledMessage(): Option.Option> { - const message = pollCompactMailbox(this.mailbox) - if (Option.isSome(message)) { - this.inFlightMessage = isAcknowledgedMessage(message.value) ? message.value : undefined - } - return message - } - - completeCompiledMessage(delivery: AcknowledgedDelivery): void { - succeedAcknowledgedMessage(this.inFlightMessage, delivery) - this.inFlightMessage = undefined - } - - compiledState(): unknown { - return this.current.snapshot.state - } - - commitCompiledState(state: unknown): Effect.Effect | undefined { - return this.commitActiveState(state, true) - } - - private commitActiveState(state: unknown, batchChanges = false): Effect.Effect | undefined { - const latest = this.current - if (latest.terminalizing || latest.snapshot.status !== "active") { - return undefined - } - const pendingChanges = latest.pendingChanges - if (pendingChanges !== undefined) { - latest.pendingChanges = undefined - } - const activeSnapshot = { status: "active" as const, state } - const versioned = { - revision: latest.revision + 1, - snapshot: activeSnapshot, - terminalizing: false, - changes: latest.changes - } as VersionedSnapshot - this.current = versioned - if (versioned.changes !== undefined) { - if (pendingChanges === undefined) { - if (batchChanges) { - versioned.pendingChanges = [versioned] - } else { - PubSub.publishUnsafe(versioned.changes, [versioned] as const) - } - } else { - pendingChanges.push(versioned) - if (batchChanges) { - versioned.pendingChanges = pendingChanges - } else { - PubSub.publishUnsafe(versioned.changes, pendingChanges) - } - } - } - return this.options.onSnapshot === undefined - ? undefined - : notifyActiveSnapshot(this.options.onSnapshot, activeSnapshot) - } - - flushPendingChanges(): void { - const current = this.current - const pendingChanges = current?.pendingChanges - if (pendingChanges === undefined || current.changes === undefined) { - return - } - current.pendingChanges = undefined - PubSub.publishUnsafe(current.changes, pendingChanges) - } - - private updateState( - f: (state: unknown) => Effect.Effect - ): Effect.Effect { - return Effect.suspend(() => { - const observed = this.current - if (observed.terminalizing || observed.snapshot.status !== "active") { - return Effect.void - } - return f(observed.snapshot.state).pipe( - Effect.flatMap((state) => { - const latest = this.current - return latest.terminalizing || latest.revision !== observed.revision - ? Effect.void - : this.setActiveState(state) - }) - ) - }) - } - - private getOrCreateChanges(): Effect.Effect< - PubSub.PubSub>> | undefined - > { - return Effect.suspend(() => { - const observed = this.current - if (observed.snapshot.status !== "active") { - return Effect.succeed(undefined) - } - if (observed.changes !== undefined) { - return Effect.succeed(observed.changes) - } - return PubSub.unbounded>>({ replay: 1 }).pipe( - Effect.flatMap((candidate) => - Effect.sync(() => { - const latest = this.current - if (latest.snapshot.status !== "active") { - return [undefined, true] as const - } - if (latest.changes !== undefined) { - return [latest.changes, true] as const - } - this.current = { ...latest, changes: candidate } - return [candidate, false] as const - }).pipe( - Effect.flatMap(([changes, discard]) => - discard ? PubSub.shutdown(candidate).pipe(Effect.as(changes)) : Effect.succeed(changes) - ) - ) - ) - ) - }) - } - - private changesStream(): Stream.Stream> { - const self = this - return Stream.unwrap( - Effect.gen(function*() { - const changes = yield* self.getOrCreateChanges() - if (changes === undefined) { - return Stream.succeed(self.current.snapshot) - } - const subscription = yield* PubSub.subscribe(changes) - const captured = self.current - if (captured.snapshot.status !== "active") { - return Stream.succeed(captured.snapshot) - } - return Stream.succeed(captured.snapshot).pipe( - Stream.concat( - Stream.fromChannel(Channel.fromEffectTake(PubSub.take(subscription))).pipe( - Stream.filter((next) => next.revision > captured.revision), - Stream.map((next) => next.snapshot) - ) - ) - ) - }) - ) - } -} - -class CompiledProcessContextImpl implements CompiledProcessContext { - executionState: unknown - - constructor( - readonly scope: ProcessScope, - readonly ownedChildren: OwnedChildRuntime, - private readonly process: CompiledProcess - ) {} - - poll(): Option.Option { - return Option.map(this.process.pollCompiledMessage(), messageEvent) - } - - pollMessage(): Option.Option> { - return this.process.pollCompiledMessage() - } - - state(): unknown { - return this.process.compiledState() - } - - completeMessage(delivery: AcknowledgedDelivery): void { - this.process.completeCompiledMessage(delivery) - } - - commit(state: unknown): Effect.Effect | undefined { - return this.process.commitCompiledState(state) - } - - runAfterChanges(effect: Effect.Effect): Effect.Effect { - this.process.flushPendingChanges() - return effect - } -} - -const startCompactCompiledInternal: typeof startGenericInternal = Effect.fnUntraced(function*( - logic: ProcessLogic, - options: StartInternalOptions -) { - const sessionId = options.sessionId ?? (yield* options.runtime.nextSessionId) - const services = yield* Effect.context() - const execution = logic.execution as CompiledProcessExecution - const process = new CompiledProcess(logic, options, services, sessionId) - // A compiled initializer is synchronous by construction. Only startup - // callbacks that themselves return Effects need the generic initialization - // program; the compiled drain is still provided the complete service context. - const initialize = execution.initialSync !== undefined && - options.onReady === undefined && options.onSnapshot === undefined - ? process.initializeCompiledSync() - : process.initialize() - return yield* initialize.pipe( - Effect.onExit((exit) => - Exit.isFailure(exit) - ? process.shutdownEmissions().pipe( - Effect.andThen( - options.inspectionRoot === true && options.runtime.inspection !== undefined - ? options.runtime.inspection.close - : Effect.void - ) - ) - : Effect.void - ) - ) -}) as typeof startGenericInternal - -const startLogicInternal: typeof startGenericInternal = (( +import { startCompactCompiledInternal, startCompiledSync } from "./runtimeCompiled.js" +import { startGenericInternal } from "./runtimeGeneric.js" +import { + type MachineRef, + makeEmissionRuntime, + makeProcessRuntime, + type PreparedProcess, + type ProcessLogic, + type StartInternalOptions, + type StartProcess +} from "./runtimeProtocol.js" + +const startLogicInternal: StartProcess = (( logic: ProcessLogic, options: StartInternalOptions ) => logic.execution?._tag === "Compiled" ? startCompactCompiledInternal(logic, options) - : startGenericInternal(logic, options)) as typeof startGenericInternal + : startGenericInternal(logic, options)) as StartProcess export type ProcessRuntimeStrategy = "generic" | "compiled" | "auto" @@ -3052,7 +33,7 @@ const startProcessWithStrategy = Effect.fnUntraced(function*( readonly id?: string } ) { - const runtime = yield* makeProcessRuntime + const runtime = yield* makeProcessRuntime(startLogicInternal, startCompiledSync) const internalOptions: StartInternalOptions = options === undefined ? { detached: true, @@ -3117,7 +98,7 @@ export const startProcess: < readonly id?: string } ) { - const runtime = yield* makeProcessRuntime + const runtime = yield* makeProcessRuntime(startLogicInternal, startCompiledSync) return yield* startLogicInternal( logic, options === undefined @@ -3148,7 +129,7 @@ const prepareProcessWithStrategy = Effect.fnUntraced(function*< readonly id?: string } ) { - const runtime = yield* makeProcessRuntime + const runtime = yield* makeProcessRuntime(startLogicInternal, startCompiledSync) const sessionId = yield* runtime.nextSessionId const inspection = yield* InspectionRuntime.make(sessionId) runtime.inspection = inspection diff --git a/packages/effect-machine/src/internal/machine/runtimeCompiled.ts b/packages/effect-machine/src/internal/machine/runtimeCompiled.ts new file mode 100644 index 0000000..327e5f3 --- /dev/null +++ b/packages/effect-machine/src/internal/machine/runtimeCompiled.ts @@ -0,0 +1,1166 @@ +/** Compiled statechart execution strategy. */ +import * as Cause from "effect/Cause" +import * as Channel from "effect/Channel" +import * as Context from "effect/Context" +import * as Deferred from "effect/Deferred" +import * as Effect from "effect/Effect" +import * as Exit from "effect/Exit" +import * as Fiber from "effect/Fiber" +import * as Option from "effect/Option" +import * as PubSub from "effect/PubSub" +import * as Stream from "effect/Stream" +import type * as Take from "effect/Take" +import type { Inspection } from "../../Machine.js" +import { type ChildSelector } from "./childRegistry.js" +import { StoppedError } from "./errors.js" +import * as InspectionRuntime from "./inspectionRuntime.js" +import { + type AcknowledgedDelivery, + AcknowledgedMessageTypeId, + acknowledgedSend, + childlessRuntime, + type ChildRuntime, + classifyOutcome, + closeCompactMailbox, + type CompactProcessMailbox, + type CompiledProcessContext, + type CompiledProcessExecution, + type CompiledProcessInitial, + type EmissionRuntime, + EmissionsClosed, + failAcknowledgedMessage, + inspectionSubject, + isAcknowledgedMessage, + isMachineTarget, + type LazyEmissions, + type MachineRef, + makeChildRuntime, + makeChildRuntimeSync, + makeInspectedMessage, + messageCausation, + messageEvent, + noInspectInitial, + noParentSend, + notifyActiveSnapshot, + offerCompactMailbox, + type OwnedChildRuntime, + pollCompactMailbox, + type ProcessAddress, + type ProcessContext, + type ProcessLogic, + type ProcessMessage, + type ProcessRuntime, + type ProcessScope, + publishInspectedSent, + type RuntimeSnapshot, + sendMachineTarget, + type StartInternalOptions, + type StartProcess, + stopAcknowledgedMessage, + succeedAcknowledgedMessage, + type VersionedSnapshot +} from "./runtimeProtocol.js" + +const CompiledStoppedCompletion: unique symbol = Symbol("effect/Machine/CompiledStoppedCompletion") + +type CompiledCompletion = + | Effect.Effect + | typeof CompiledStoppedCompletion + +type CompiledLifecycle = "Active" | "TerminationRequested" | "Completed" +type CompiledRunState = "Initializing" | "Idle" | "Draining" + +/** + * Compact runtime for compiled statecharts. + * + * All long-lived state is stored directly on this object. Operations are + * implemented by shared prototype methods, and public Effect / Stream values + * are materialized only when accessed. Arbitrary process logic continues to + * use the general runtime above. + */ +class CompiledProcess implements MachineRef { + readonly id: string + readonly sessionId: string + readonly inspectionSubject?: Inspection.Subject + readonly send: (event: unknown) => Effect.Effect + sendInspected( + event: unknown, + source: Inspection.Subject | undefined, + causedBy: Inspection.Causation | undefined + ): Effect.Effect { + return this.offerEvent(event, source, causedBy) + } + [acknowledgedSend]( + event: unknown + ): Effect.Effect, unknown | StoppedError> { + return this.sendAcknowledgedEffect(event) + } + + private readonly mailbox: CompactProcessMailbox = { + items: undefined, + index: 0, + closed: false + } + private readonly address: ProcessAddress + private childRuntime: ChildRuntime = childlessRuntime + private processScope!: ProcessScope + private processContext: ProcessContext | undefined + private compiledContext: CompiledProcessContext | undefined + private current!: VersionedSnapshot + /** + * `Active` has no terminal payload, `TerminationRequested` owns + * `termination` and its optional reserved snapshot, and `Completed` owns + * `completion`. `runState` independently tracks worker activity. + */ + private lifecycle: CompiledLifecycle = "Active" + private runState: CompiledRunState = "Initializing" + private completion: CompiledCompletion | undefined + private waiter: Deferred.Deferred | undefined + private termination: CompiledTermination | undefined + private terminationSnapshot: RuntimeSnapshot | undefined + private worker: Fiber.Fiber | undefined + private interruptRequested = false + private offerRevision = 0 + private inFlightMessage: ProcessMessage | undefined + private readonly externalEmissions: EmissionRuntime | undefined + private emissionsPubSub: LazyEmissions + private readonly activity?: Inspection.Activity + private initialEntryPaths?: ReadonlyArray + private initialMicrosteps?: ReadonlyArray + private initializing?: boolean + + constructor( + private readonly logic: ProcessLogic, + private readonly options: StartInternalOptions, + private readonly services: Context.Context, + sessionId: string + ) { + this.sessionId = sessionId + this.id = options.id ?? sessionId + const inspector = options.runtime.inspection + if (inspector !== undefined) { + this.inspectionSubject = inspectionSubject(logic, this.id, sessionId) + this.initializing = true + if (options.activity !== undefined) this.activity = { ...options.activity, sessionId } + } + this.externalEmissions = options.emissions + this.send = inspector === undefined + ? (event) => this.offerMessage(event) + : (event) => this.offerEvent(event, undefined, undefined) + this.address = { + id: this.id, + sessionId, + stop: Effect.suspend(() => this.stopFromProcess()), + send: this.send, + ...(inspector === undefined + ? undefined + : { + inspectionSubject: this.inspectionSubject!, + sendInspected: ( + event: unknown, + source: Inspection.Subject | undefined, + causedBy: Inspection.Causation | undefined + ) => this.offerEvent(event, source, causedBy) + }) + } + } + + private get inspector(): InspectionRuntime.Runtime | undefined { + return this.options.runtime.inspection + } + + private causation(): Inspection.Causation | undefined { + return messageCausation(this.inFlightMessage, this.initializing === true) + } + + private publishCreated(): void { + const inspector = this.inspector + const subject = this.inspectionSubject + if (inspector === undefined || subject === undefined) return + inspector.publishUnsafe( + this.activity === undefined + ? { + _tag: "Created", + subject, + parent: this.options.parent?.inspectionSubject, + origin: this.options.origin ?? { _tag: "Root" }, + definition: this.logic.inspection?.definition + } + : { _tag: "ActivityStarted", subject: this.activity.owner, activity: this.activity } + ) + } + + private publishInitialized(state: unknown): void { + this.initializing = false + if (this.activity !== undefined) return + const inspector = this.inspector + const subject = this.inspectionSubject + if (inspector === undefined || subject === undefined) return + inspector.publishUnsafe({ + _tag: "Initialized", + subject, + snapshot: { status: "active", state }, + initialEntryPaths: this.initialEntryPaths ?? [], + microsteps: InspectionRuntime.microsteps({ microsteps: this.initialMicrosteps ?? [] }) + }) + } + + private publishStartFailed(cause: Cause.Cause): void { + this.initializing = false + const inspector = this.inspector + const subject = this.inspectionSubject + if (inspector === undefined || subject === undefined) return + inspector.publishUnsafe( + this.activity === undefined + ? { _tag: "StartFailed", subject, cause } + : { + _tag: "ActivityStopped", + subject: this.activity.owner, + activity: this.activity, + exit: Exit.failCause(cause) + } + ) + } + + private get execution(): CompiledProcessExecution { + return this.logic.execution as CompiledProcessExecution + } + + initializeCompiledSync(): Effect.Effect, unknown> { + this.publishCreated() + if (!this.execution.childless) { + this.childRuntime = makeChildRuntimeSync( + this.address, + this.options.runtime, + this.services, + (event) => this.sendAcknowledgedEffect(event) + ) + } + const parent = this.options.parent + const sendParent = this.options.sendParent ?? (parent === undefined + ? noParentSend + : this.inspector === undefined + ? parent.send + : (event: unknown) => sendMachineTarget(parent, event, this.inspectionSubject, this.causation())) + this.processScope = { + self: this.address, + parent, + spawn: this.childRuntime.spawn, + sendParent, + emit: (event) => this.emitEvent(event), + sendTo: ((target: unknown, event: unknown) => + isMachineTarget(target) + ? this.inspector === undefined + ? target.send(event) + : sendMachineTarget(target, event, this.inspectionSubject, this.causation()) + : this.childRuntime.sendTo( + target as ChildSelector, + event, + this.inspectionSubject, + this.causation() + )) as ProcessScope["sendTo"], + stopChild: this.childRuntime.stop, + failCause: (cause: Cause.Cause) => this.failCause(cause), + inspectInitial: this.inspector === undefined + ? noInspectInitial + : (paths, microsteps = []) => { + this.initialEntryPaths = paths + this.initialMicrosteps = microsteps + } + } + const compiledInitial = this.execution.initialSync! + let initialized: CompiledInitialized + try { + initialized = compiledInitial(this.processScope) + } catch (error) { + this.runState = "Idle" + this.publishStartFailed(Cause.fail(error)) + return Effect.fail(error) + } + this.runState = "Idle" + this.current = { + revision: 0, + terminalizing: false, + changes: undefined, + snapshot: { status: "active", state: initialized.state } + } + this.publishInitialized(initialized.state) + this.compiledContext = new CompiledProcessContextImpl(this.processScope, this.childRuntime.owned, this) + if ("executionState" in initialized) { + this.compiledContext.executionState = initialized.executionState + } + if (this.options.onReadySync !== undefined && !this.options.onReadySync(this)) { + this.requestTerminationSync({ _tag: "Stopped" }) + } + if (initialized.done === true && this.lifecycle === "Active") { + this.requestTerminationSync({ _tag: "Done", output: initialized.output }) + } + if ( + initialized.done === false && this.execution.childless && + this.lifecycle === "Active" && + this.mailbox.items === undefined + ) { + return Effect.succeed(this) + } + this.runState = "Draining" + return Effect.provideContext(this.drainRuntime(), this.services).pipe(Effect.as(this)) + } + + initialize(): Effect.Effect, unknown, any> { + const self = this + return Effect.gen(function*() { + self.publishCreated() + if (!self.execution.childless) { + self.childRuntime = yield* makeChildRuntime( + self.address, + self.options.runtime, + self.services, + (event) => self.sendAcknowledgedEffect(event) + ) + } + const parent = self.options.parent + const sendParent = self.options.sendParent ?? (parent === undefined + ? noParentSend + : self.inspector === undefined + ? parent.send + : (event: unknown) => sendMachineTarget(parent, event, self.inspectionSubject, self.causation())) + self.processScope = { + self: self.address, + parent, + spawn: self.childRuntime.spawn, + sendParent, + emit: (event) => self.emitEvent(event), + sendTo: ((target: unknown, event: unknown) => + isMachineTarget(target) + ? self.inspector === undefined + ? target.send(event) + : sendMachineTarget(target, event, self.inspectionSubject, self.causation()) + : self.childRuntime.sendTo( + target as ChildSelector, + event, + self.inspectionSubject, + self.causation() + )) as ProcessScope["sendTo"], + stopChild: self.childRuntime.stop, + failCause: (cause: Cause.Cause) => self.failCause(cause), + inspectInitial: self.inspector === undefined + ? noInspectInitial + : (paths, microsteps = []) => { + self.initialEntryPaths = paths + self.initialMicrosteps = microsteps + } + } + + const cleanupStartupFailure = (exit: Exit.Exit): Effect.Effect => + Exit.isFailure(exit) ? self.childRuntime.close(exit) : Effect.void + const compiledInitial = self.execution.initial + const initializeEffect: Effect.Effect< + { + readonly state: unknown + readonly done: boolean | undefined + readonly output: unknown + }, + unknown, + any + > = compiledInitial === undefined + ? self.logic.initial(self.processScope).pipe( + Effect.map((state) => ({ state, done: undefined, output: undefined } as const)) + ) + : compiledInitial(self.processScope) + const initialized = yield* initializeEffect.pipe( + Effect.onExit((exit) => { + if (Exit.isFailure(exit)) self.publishStartFailed(exit.cause) + return cleanupStartupFailure(exit) + }), + Effect.ensuring(Effect.sync(() => { + self.runState = "Idle" + })) + ) + const initial = initialized.state + self.current = { + revision: 0, + terminalizing: false, + changes: undefined, + snapshot: { status: "active", state: initial } + } + self.publishInitialized(initial) + if (self.execution.drain._tag === "Process") { + self.processContext = { + ...self.processScope, + receive: Effect.never, + poll: Effect.sync(() => Option.map(pollCompactMailbox(self.mailbox), messageEvent)), + receiveMessage: Effect.never, + pollMessage: Effect.sync(() => self.pollCompiledMessage()), + completeMessage: (delivery) => self.completeCompiledMessage(delivery), + state: Effect.sync(() => self.current.snapshot.state), + setState: (state: unknown) => self.setActiveState(state), + updateState: (f) => self.updateState(f) + } + } else { + self.compiledContext = new CompiledProcessContextImpl(self.processScope, self.childRuntime.owned, self) + if ("executionState" in initialized) { + self.compiledContext.executionState = initialized.executionState + } + } + + if (self.options.onReadySync !== undefined && !self.options.onReadySync(self)) { + yield* self.requestTermination({ _tag: "Stopped" }) + } else if (self.options.onReady !== undefined) { + yield* self.options.onReady(self, self.requestTermination({ _tag: "Stopped" }).pipe(Effect.asVoid)) + } + if (self.options.onSnapshot !== undefined) { + yield* notifyActiveSnapshot(self.options.onSnapshot, { status: "active", state: initial }) + } + if (initialized.done === true && self.lifecycle === "Active") { + yield* self.requestTermination({ _tag: "Done", output: initialized.output }) + } + + // A compiled machine startup plan has already settled entry actions, + // raised events, and eventless transitions. If it is known active and + // neither startup hooks nor emitted work queued an event, there is no + // first drain to perform. Future sends observe an idle run state and + // schedule the ordinary compiled worker. + if ( + initialized.done === false && self.execution.childless && + self.lifecycle === "Active" && self.mailbox.items === undefined + ) { + return self + } + + self.runState = "Draining" + yield* self.drainRuntime() + return self + }) + } + + get state(): Effect.Effect { + return Effect.sync(() => this.current.snapshot.state) + } + + get snapshot(): Effect.Effect> { + return Effect.sync(() => this.current.snapshot) + } + + get changes(): Stream.Stream> { + return this.changesStream() + } + + get emissions(): Stream.Stream { + return (this.externalEmissions?.stream ?? this.emissionsStream()) as Stream.Stream + } + + get join(): Effect.Effect { + return Effect.suspend(() => { + if (this.lifecycle === "Completed") { + return this.resolveCompletion(this.completion!) + } + this.waiter ??= Deferred.makeUnsafe() + return Deferred.await(this.waiter) + }) + } + + get stop(): Effect.Effect { + return Effect.uninterruptible(this.stopEffect()) + } + + child(child: ChildSelector): Effect.Effect> { + return this.childRuntime.get(child) + } + + childChanges(child: ChildSelector): Stream.Stream> { + return this.childRuntime.changes(child) + } + + private requestTermination(requested: CompiledTermination): Effect.Effect { + return Effect.sync(() => this.requestTerminationSync(requested)) + } + + private hasTerminationRequest(): boolean { + return this.lifecycle === "TerminationRequested" + } + + private requestTerminationSync(requested: CompiledTermination): boolean { + if (this.lifecycle !== "Active") { + return false + } + this.lifecycle = "TerminationRequested" + this.termination = requested + this.terminationSnapshot = this.reserveTermination(requested) + return true + } + + private reserveTermination( + requested: CompiledTermination + ): RuntimeSnapshot | undefined { + const latest = this.current + if (latest === undefined || latest.terminalizing || latest.snapshot.status !== "active") { + return undefined + } + const snapshot: RuntimeSnapshot = requested._tag === "Stopped" + ? { status: "stopped", state: latest.snapshot.state } + : requested._tag === "Done" + ? { status: "done", state: latest.snapshot.state, output: requested.output } + : { status: "error", state: latest.snapshot.state, cause: requested.cause } + this.current = { ...latest, terminalizing: true } + return snapshot + } + + private stopFromProcess(): Effect.Effect { + const request = this.requestTermination({ _tag: "Stopped" }).pipe(Effect.asVoid) + return this.runState === "Initializing" ? request : request.pipe(Effect.andThen(Effect.interrupt)) + } + + private failCause(cause: Cause.Cause): Effect.Effect { + const requested = { _tag: "Failure", cause } as const + return this.requestTermination(requested).pipe( + Effect.flatMap((accepted) => + accepted + ? Effect.forkDetach(this.settleRequestedTermination()).pipe(Effect.asVoid) + : Effect.void + ) + ) + } + + private sendAcknowledgedEffect( + event: unknown + ): Effect.Effect, unknown | StoppedError> { + return Effect.uninterruptibleMask((restore) => + Deferred.make, unknown>().pipe( + Effect.flatMap((deferred) => + this.offerEvent(event, undefined, undefined, deferred).pipe( + Effect.andThen(restore(Deferred.await(deferred))) + ) + ) + ) + ) + } + + private offerEvent( + event: unknown, + source: Inspection.Subject | undefined, + causedBy: Inspection.Causation | undefined, + deferred?: Deferred.Deferred, unknown> + ): Effect.Effect { + return Effect.suspend(() => { + const inspector = this.inspector + const subject = this.inspectionSubject + const message: ProcessMessage = inspector?.isActive() === true && subject !== undefined + ? makeInspectedMessage( + inspector, + subject, + event, + source, + causedBy, + deferred + ) + : deferred === undefined + ? event + : { [AcknowledgedMessageTypeId]: true as const, event, deferred } + return this.offerMessage(message) + }) + } + + private offerMessage(message: ProcessMessage): Effect.Effect { + return Effect.uninterruptible( + Effect.suspend(() => { + if (this.mailbox.closed || this.lifecycle !== "Active") { + return Effect.fail(new StoppedError()) + } + offerCompactMailbox(this.mailbox, message) + const inspector = this.inspector + const subject = this.inspectionSubject + if (inspector !== undefined && subject !== undefined) publishInspectedSent(inspector, subject, message) + this.offerRevision += 1 + if (this.runState === "Draining") { + return Effect.void + } + this.runState = "Draining" + const scheduled = Effect.yieldNow.pipe( + Effect.andThen(Effect.provideContext(this.drainRuntime(), this.services)) + ) + const fork = this.options.detached === true + ? Effect.forkDetach(scheduled, { startImmediately: true }) + : Effect.forkChild(scheduled, { startImmediately: true }) + return fork.pipe( + Effect.flatMap((fiber) => + Effect.sync(() => { + this.worker = fiber + if (!this.interruptRequested) { + return false + } + this.interruptRequested = false + return true + }).pipe( + Effect.flatMap((interrupt) => interrupt ? this.interruptAndFinish(fiber) : Effect.void) + ) + ), + Effect.asVoid + ) + }) + ) + } + + private stopEffect(): Effect.Effect { + return Effect.suspend(() => { + if (this.lifecycle === "Completed") { + return Effect.void + } + if (this.finishIdleChildlessStop()) { + return Effect.void + } + const requested = { _tag: "Stopped" } as const + return this.requestTermination(requested).pipe( + Effect.flatMap((accepted) => + accepted + ? this.settleRequestedTermination() + : this.awaitCompletion() + ) + ) + }) + } + + private finishIdleChildlessStop(): boolean { + if ( + !this.execution.childless || this.runState !== "Idle" || this.worker !== undefined || + this.lifecycle !== "Active" || + (this.options.onOutcome !== undefined && this.options.skipStoppedOutcome !== true) || + this.options.onStop !== undefined || + this.inspector !== undefined || + this.current.changes !== undefined || + this.current.terminalizing || this.current.snapshot.status !== "active" + ) { + return false + } + const snapshot = { status: "stopped" as const, state: this.current.snapshot.state } + this.lifecycle = "TerminationRequested" + this.termination = { _tag: "Stopped" } + this.terminationSnapshot = snapshot + closeCompactMailbox(this.mailbox) + this.current = { + revision: this.current.revision + 1, + terminalizing: true, + changes: undefined, + snapshot + } + stopAcknowledgedMessage(this.inFlightMessage) + this.inFlightMessage = undefined + this.interruptRequested = false + if (this.compiledContext !== undefined) { + this.compiledContext.executionState = undefined + } + this.options.onStopSync?.() + this.completion = CompiledStoppedCompletion + this.lifecycle = "Completed" + if (this.waiter !== undefined) { + Deferred.doneUnsafe(this.waiter, this.resolveCompletion(CompiledStoppedCompletion)) + this.waiter = undefined + } + return true + } + + private settleRequestedTermination(): Effect.Effect { + return Effect.suspend(() => { + if (this.runState !== "Draining") { + return this.finishRequestedTermination() + } + if (this.worker === undefined) { + this.interruptRequested = true + return this.awaitCompletion() + } + return this.interruptAndFinish(this.worker).pipe( + Effect.andThen(this.awaitCompletion()) + ) + }) + } + + private interruptAndFinish(worker: Fiber.Fiber): Effect.Effect { + return Fiber.interrupt(worker).pipe( + Effect.andThen( + Effect.suspend(() => + this.lifecycle !== "Completed" + ? this.finishRequestedTermination() + : Effect.void + ) + ) + ) + } + + private awaitCompletion(): Effect.Effect { + return this.lifecycle !== "Completed" + ? this.join.pipe(Effect.exit, Effect.asVoid) + : Effect.void + } + + private resolveCompletion(completion: CompiledCompletion): Effect.Effect { + if (completion !== CompiledStoppedCompletion) { + return completion + } + const stopped = Effect.fail(new StoppedError()) + this.completion = stopped + return stopped + } + + private drainRuntime(): Effect.Effect { + const self = this + return Effect.uninterruptibleMask((restore) => + Effect.gen(function*() { + let observedRevision = self.offerRevision + while (true) { + if (self.hasTerminationRequest()) { + return yield* self.finishRequestedTermination() + } + + const exit = yield* restore( + Effect.suspend(() => { + const drain = self.execution.drain + return drain._tag === "Process" + ? drain.run(self.processContext!) + : drain.run(self.compiledContext!) + }) + ).pipe(Effect.exit) + self.flushPendingChanges() + if (Exit.isFailure(exit)) { + if (self.lifecycle === "Active") { + yield* self.requestTermination({ _tag: "Failure", cause: exit.cause }) + } + return yield* self.finishRequestedTermination() + } + if (Option.isSome(exit.value)) { + yield* self.requestTermination({ _tag: "Done", output: exit.value.value }) + return yield* self.finishRequestedTermination() + } + if (self.hasTerminationRequest()) { + return yield* self.finishRequestedTermination() + } + + if (self.offerRevision !== observedRevision) { + observedRevision = self.offerRevision + continue + } + self.runState = "Idle" + self.worker = undefined + return + } + }) + ) + } + + private finishRequestedTermination(): Effect.Effect { + return Effect.suspend(() => { + if (this.lifecycle !== "TerminationRequested") { + return Effect.void + } + const requested = this.termination + if (requested === undefined) { + return Effect.void + } + const snapshot = this.terminationSnapshot ?? this.reserveTermination(requested) + if (snapshot === undefined) { + return this.awaitCompletion() + } + const exit = requested._tag === "Stopped" + ? Exit.void + : requested._tag === "Done" + ? Exit.succeed(requested.output) + : Exit.failCause(requested.cause) + const completion: CompiledCompletion = requested._tag === "Stopped" + ? CompiledStoppedCompletion + : requested._tag === "Done" + ? Effect.succeed(requested.output) + : Effect.failCause(requested.cause) + const notifyOutcome = this.options.onOutcome === undefined || + (requested._tag === "Stopped" && this.options.skipStoppedOutcome === true) + ? Effect.void + : Effect.suspend(() => this.options.onOutcome!(classifyOutcome(snapshot)!)).pipe( + Effect.exit, + Effect.asVoid + ) + const inspector = this.inspector + const subject = this.inspectionSubject + const closeEmissionsAndInspect = inspector === undefined || subject === undefined + ? this.closeEmissions() + : this.closeEmissions().pipe( + Effect.andThen(Effect.sync(() => + inspector.publishUnsafe( + this.activity === undefined + ? { _tag: "Terminated", subject, snapshot } + : { + _tag: "ActivityStopped", + subject: this.activity.owner, + activity: this.activity, + exit: snapshot.status === "stopped" ? Exit.interrupt() : exit + } + ) + )) + ) + return Effect.uninterruptible( + Effect.sync(() => { + closeCompactMailbox(this.mailbox) + }).pipe( + Effect.andThen(this.childRuntime.close(exit)), + Effect.andThen(this.setAndPublishSnapshot(snapshot)), + Effect.andThen(closeEmissionsAndInspect), + Effect.andThen(Effect.sync(() => { + if (requested._tag === "Failure") { + failAcknowledgedMessage(this.inFlightMessage, requested.cause) + } else { + stopAcknowledgedMessage(this.inFlightMessage) + } + this.inFlightMessage = undefined + })), + Effect.andThen(notifyOutcome), + Effect.andThen(this.options.onStop ?? Effect.void), + Effect.andThen( + this.options.inspectionRoot === true && this.inspector !== undefined + ? this.inspector.close + : Effect.void + ), + Effect.andThen(Effect.sync(() => { + this.options.onStopSync?.() + this.runState = "Idle" + this.worker = undefined + this.interruptRequested = false + if (this.compiledContext !== undefined) { + this.compiledContext.executionState = undefined + } + this.completion = completion + this.lifecycle = "Completed" + if (this.waiter !== undefined) { + Deferred.doneUnsafe(this.waiter, this.resolveCompletion(completion)) + this.waiter = undefined + } + })) + ) + ) + }) + } + + private publishSnapshot( + snapshot: VersionedSnapshot + ): Effect.Effect> { + const publish = snapshot.changes === undefined + ? Effect.succeed(snapshot) + : PubSub.publish(snapshot.changes, [snapshot] as const).pipe(Effect.as(snapshot)) + const current = snapshot.snapshot + return this.options.onSnapshot === undefined || current.status !== "active" + ? publish + : publish.pipe(Effect.tap(() => notifyActiveSnapshot(this.options.onSnapshot!, current))) + } + + private completeChanges(snapshot: VersionedSnapshot): Effect.Effect { + return snapshot.changes === undefined + ? Effect.void + : PubSub.publish(snapshot.changes, Exit.succeed(undefined)).pipe(Effect.asVoid) + } + + private emitEvent(event: unknown): Effect.Effect { + const publish = this.externalEmissions !== undefined ? + this.externalEmissions.emit(event) : + Effect.suspend(() => + this.emissionsPubSub === undefined || this.emissionsPubSub === EmissionsClosed + ? Effect.void + : PubSub.publish(this.emissionsPubSub, event).pipe(Effect.asVoid) + ) + const inspector = this.inspector + const subject = this.inspectionSubject + if (inspector === undefined || subject === undefined) return publish + return publish.pipe(Effect.tap(() => + Effect.sync(() => + inspector.publishUnsafe({ + _tag: "Emitted", + subject, + emission: event, + causedBy: this.causation() + }) + ) + )) + } + + shutdownEmissions(): Effect.Effect { + return this.closeEmissions() + } + + private closeEmissions(): Effect.Effect { + if (this.externalEmissions !== undefined) return this.externalEmissions.close() + const observed = this.emissionsPubSub + this.emissionsPubSub = EmissionsClosed + return observed === undefined || observed === EmissionsClosed ? Effect.void : PubSub.shutdown(observed) + } + + private getOrCreateEmissions(): Effect.Effect | undefined> { + return Effect.suspend(() => { + const observed = this.emissionsPubSub + if (observed === EmissionsClosed) return Effect.succeed(undefined) + if (observed !== undefined) return Effect.succeed(observed) + return PubSub.unbounded().pipe( + Effect.flatMap((candidate) => + Effect.sync(() => { + const latest = this.emissionsPubSub + if (latest === EmissionsClosed) return [undefined, true] as const + if (latest !== undefined) return [latest, true] as const + this.emissionsPubSub = candidate + return [candidate, false] as const + }).pipe( + Effect.flatMap(([selected, discard]) => + discard ? PubSub.shutdown(candidate).pipe(Effect.as(selected)) : Effect.succeed(selected) + ) + ) + ) + ) + }) + } + + private emissionsStream(): Stream.Stream { + return Stream.unwrap( + this.getOrCreateEmissions().pipe( + Effect.map((emissions) => emissions === undefined ? Stream.empty : Stream.fromPubSub(emissions)) + ) + ) + } + + private setAndPublishSnapshot(snapshot: RuntimeSnapshot): Effect.Effect { + return Effect.suspend(() => { + this.flushPendingChanges() + const versioned = { + revision: this.current.revision + 1, + snapshot, + terminalizing: true, + changes: this.current.changes + } + this.current = versioned + return this.publishSnapshot(versioned).pipe( + Effect.flatMap((published) => this.completeChanges(published)), + Effect.asVoid + ) + }) + } + + private setActiveState(state: unknown): Effect.Effect { + return Effect.suspend(() => this.commitActiveState(state) ?? Effect.void) + } + + pollCompiledMessage(): Option.Option> { + const message = pollCompactMailbox(this.mailbox) + if (Option.isSome(message)) { + this.inFlightMessage = isAcknowledgedMessage(message.value) ? message.value : undefined + } + return message + } + + completeCompiledMessage(delivery: AcknowledgedDelivery): void { + succeedAcknowledgedMessage(this.inFlightMessage, delivery) + this.inFlightMessage = undefined + } + + compiledState(): unknown { + return this.current.snapshot.state + } + + commitCompiledState(state: unknown): Effect.Effect | undefined { + return this.commitActiveState(state, true) + } + + private commitActiveState(state: unknown, batchChanges = false): Effect.Effect | undefined { + const latest = this.current + if (latest.terminalizing || latest.snapshot.status !== "active") { + return undefined + } + const pendingChanges = latest.pendingChanges + if (pendingChanges !== undefined) { + latest.pendingChanges = undefined + } + const activeSnapshot = { status: "active" as const, state } + const versioned = { + revision: latest.revision + 1, + snapshot: activeSnapshot, + terminalizing: false, + changes: latest.changes + } as VersionedSnapshot + this.current = versioned + if (versioned.changes !== undefined) { + if (pendingChanges === undefined) { + if (batchChanges) { + versioned.pendingChanges = [versioned] + } else { + PubSub.publishUnsafe(versioned.changes, [versioned] as const) + } + } else { + pendingChanges.push(versioned) + if (batchChanges) { + versioned.pendingChanges = pendingChanges + } else { + PubSub.publishUnsafe(versioned.changes, pendingChanges) + } + } + } + return this.options.onSnapshot === undefined + ? undefined + : notifyActiveSnapshot(this.options.onSnapshot, activeSnapshot) + } + + flushPendingChanges(): void { + const current = this.current + const pendingChanges = current?.pendingChanges + if (pendingChanges === undefined || current.changes === undefined) { + return + } + current.pendingChanges = undefined + PubSub.publishUnsafe(current.changes, pendingChanges) + } + + private updateState( + f: (state: unknown) => Effect.Effect + ): Effect.Effect { + return Effect.suspend(() => { + const observed = this.current + if (observed.terminalizing || observed.snapshot.status !== "active") { + return Effect.void + } + return f(observed.snapshot.state).pipe( + Effect.flatMap((state) => { + const latest = this.current + return latest.terminalizing || latest.revision !== observed.revision + ? Effect.void + : this.setActiveState(state) + }) + ) + }) + } + + private getOrCreateChanges(): Effect.Effect< + PubSub.PubSub>> | undefined + > { + return Effect.suspend(() => { + const observed = this.current + if (observed.snapshot.status !== "active") { + return Effect.succeed(undefined) + } + if (observed.changes !== undefined) { + return Effect.succeed(observed.changes) + } + return PubSub.unbounded>>({ replay: 1 }).pipe( + Effect.flatMap((candidate) => + Effect.sync(() => { + const latest = this.current + if (latest.snapshot.status !== "active") { + return [undefined, true] as const + } + if (latest.changes !== undefined) { + return [latest.changes, true] as const + } + this.current = { ...latest, changes: candidate } + return [candidate, false] as const + }).pipe( + Effect.flatMap(([changes, discard]) => + discard ? PubSub.shutdown(candidate).pipe(Effect.as(changes)) : Effect.succeed(changes) + ) + ) + ) + ) + }) + } + + private changesStream(): Stream.Stream> { + const self = this + return Stream.unwrap( + Effect.gen(function*() { + const changes = yield* self.getOrCreateChanges() + if (changes === undefined) { + return Stream.succeed(self.current.snapshot) + } + const subscription = yield* PubSub.subscribe(changes) + const captured = self.current + if (captured.snapshot.status !== "active") { + return Stream.succeed(captured.snapshot) + } + return Stream.succeed(captured.snapshot).pipe( + Stream.concat( + Stream.fromChannel(Channel.fromEffectTake(PubSub.take(subscription))).pipe( + Stream.filter((next) => next.revision > captured.revision), + Stream.map((next) => next.snapshot) + ) + ) + ) + }) + ) + } +} + +class CompiledProcessContextImpl implements CompiledProcessContext { + executionState: unknown + + constructor( + readonly scope: ProcessScope, + readonly ownedChildren: OwnedChildRuntime, + private readonly process: CompiledProcess + ) {} + + poll(): Option.Option { + return Option.map(this.process.pollCompiledMessage(), messageEvent) + } + + pollMessage(): Option.Option> { + return this.process.pollCompiledMessage() + } + + state(): unknown { + return this.process.compiledState() + } + + completeMessage(delivery: AcknowledgedDelivery): void { + this.process.completeCompiledMessage(delivery) + } + + commit(state: unknown): Effect.Effect | undefined { + return this.process.commitCompiledState(state) + } + + runAfterChanges(effect: Effect.Effect): Effect.Effect { + this.process.flushPendingChanges() + return effect + } +} + +export const startCompactCompiledInternal: StartProcess = Effect.fnUntraced(function*( + logic: ProcessLogic, + options: StartInternalOptions +) { + const sessionId = options.sessionId ?? (yield* options.runtime.nextSessionId) + const services = yield* Effect.context() + const execution = logic.execution as CompiledProcessExecution + const process = new CompiledProcess(logic, options, services, sessionId) + // A compiled initializer is synchronous by construction. Only startup + // callbacks that themselves return Effects need the generic initialization + // program; the compiled drain is still provided the complete service context. + const initialize = execution.initialSync !== undefined && + options.onReady === undefined && options.onSnapshot === undefined + ? process.initializeCompiledSync() + : process.initialize() + return yield* initialize.pipe( + Effect.onExit((exit) => + Exit.isFailure(exit) + ? process.shutdownEmissions().pipe( + Effect.andThen( + options.inspectionRoot === true && options.runtime.inspection !== undefined + ? options.runtime.inspection.close + : Effect.void + ) + ) + : Effect.void + ) + ) +}) as StartProcess + +/** Preserves synchronous startup for owned compiled children. */ +export const startCompiledSync: ProcessRuntime["startCompiledSync"] = (logic, options, services, sessionId) => + new CompiledProcess(logic, options, services, sessionId).initializeCompiledSync() + +type CompiledTermination = + | { readonly _tag: "Stopped" } + | { readonly _tag: "Done"; readonly output: unknown } + | { readonly _tag: "Failure"; readonly cause: Cause.Cause } + +type CompiledInitialized = CompiledProcessInitial + +// Stopping is commonly used only for resource cleanup. Keep that path free of +// Error stack capture and materialize the typed join failure only if observed. diff --git a/packages/effect-machine/src/internal/machine/runtimeGeneric.ts b/packages/effect-machine/src/internal/machine/runtimeGeneric.ts new file mode 100644 index 0000000..b5a5c55 --- /dev/null +++ b/packages/effect-machine/src/internal/machine/runtimeGeneric.ts @@ -0,0 +1,799 @@ +/** General Effect process worker and supervisor strategy. */ +import * as Cause from "effect/Cause" +import * as Channel from "effect/Channel" +import * as Deferred from "effect/Deferred" +import * as Effect from "effect/Effect" +import * as Exit from "effect/Exit" +import * as Fiber from "effect/Fiber" +import * as Option from "effect/Option" +import * as PubSub from "effect/PubSub" +import * as Queue from "effect/Queue" +import * as Stream from "effect/Stream" +import * as SynchronizedRef from "effect/SynchronizedRef" +import type * as Take from "effect/Take" +import type { Inspection } from "../../Machine.js" +import { type ChildSelector } from "./childRegistry.js" +import { StoppedError } from "./errors.js" +import * as InspectionRuntime from "./inspectionRuntime.js" +import { + type AcknowledgedDelivery, + AcknowledgedMessageTypeId, + acknowledgedSend, + childlessRuntime, + classifyOutcome, + executionIsChildless, + failAcknowledgedMessage, + type InspectedOffer, + inspectionSubject, + isAcknowledgedMessage, + isMachineTarget, + type MachineRef, + makeChildRuntime, + makeEmissionRuntime, + makeInspectedMessage, + messageCausation, + messageEvent, + noCausation, + noInspectInitial, + noParentSend, + notifyActiveSnapshot, + type ProcessAddress, + type ProcessContext, + type ProcessLogic, + type ProcessMessage, + type ProcessScope, + publishInspectedSent, + type RuntimeSnapshot, + sendMachineTarget, + type StartInternalOptions, + type StartProcess, + stopAcknowledgedMessage, + succeedAcknowledgedMessage, + type VersionedSnapshot +} from "./runtimeProtocol.js" + +// `Machine.logic` permits an arbitrary Effect program, including programs that +// suspend or supervise their own fibers. Keep its two-fiber worker/supervisor +// protocol as the general contract rather than weakening it for statecharts. +export const startGenericInternal: StartProcess = Effect.fnUntraced( + function*( + logic: ProcessLogic, + options: StartInternalOptions + ) { + const { + detached, + id: requestedId, + onOutcome, + onReady, + onReadySync, + onSnapshot, + onStop, + onStopSync, + parent, + runtime, + sendParent: overrideSendParent + } = options + type ProcessTermination = + | { readonly _tag: "Stopped" } + | { readonly _tag: "Done"; readonly output: Output } + | { readonly _tag: "Failure"; readonly cause: Cause.Cause } + + const sessionId = options.sessionId ?? (yield* runtime.nextSessionId) + const id = requestedId ?? sessionId + const inspector = runtime.inspection + const subject = inspector === undefined ? undefined : inspectionSubject(logic, id, sessionId) + const activity: Inspection.Activity | undefined = inspector === undefined || options.activity === undefined + ? undefined + : { ...options.activity, sessionId } + let initialEntryPaths: ReadonlyArray | undefined + let initialMicrosteps: ReadonlyArray | undefined + const queue = yield* Queue.unbounded>() + const emissions = options.emissions ?? makeEmissionRuntime() + const termination = yield* Deferred.make() + const done = yield* Deferred.make() + const awaitCompletion = Deferred.await(done).pipe(Effect.exit, Effect.asVoid) + let initializing = true + let inFlightMessage: ProcessMessage | undefined + const requestStop = Deferred.succeed(termination, { _tag: "Stopped" }).pipe(Effect.asVoid) + const offerDirect = (message: ProcessMessage): Effect.Effect => + Queue.offer(queue, message).pipe( + Effect.flatMap((accepted) => accepted ? Effect.void : Effect.fail(new StoppedError())) + ) + const offerInspected: InspectedOffer | undefined = inspector === undefined ? undefined : ( + event, + source, + causedBy, + deferred?: Deferred.Deferred, unknown> + ) => + Effect.suspend(() => { + const message: ProcessMessage = inspector.isActive() + ? makeInspectedMessage(inspector, subject!, event, source, causedBy, deferred) + : deferred === undefined + ? event + : { [AcknowledgedMessageTypeId]: true as const, event, deferred } + return offerDirect(message).pipe( + Effect.tap(() => Effect.sync(() => publishInspectedSent(inspector, subject!, message))) + ) + }) + const sendAcknowledged: + | ((event: Event) => Effect.Effect, Error | StoppedError>) + | undefined = logic.execution?._tag !== "Compiled" + ? undefined + : (event) => + Effect.uninterruptibleMask((restore) => + Deferred.make, unknown>().pipe( + Effect.flatMap((deferred) => { + const offered = inspector === undefined + ? offerDirect({ [AcknowledgedMessageTypeId]: true as const, event, deferred }) + : offerInspected!(event, undefined, undefined, deferred) + return offered.pipe(Effect.andThen(restore(Deferred.await(deferred)))) + }), + Effect.map((delivery) => delivery as AcknowledgedDelivery) + ) + ) as Effect.Effect, Error | StoppedError> + + const self: ProcessAddress = { + id, + sessionId, + // Initialization must finish constructing a state before a stopped + // snapshot can be published. A stop requested there is therefore recorded + // and returns so initialization can finish. Once running, the requesting + // process waits forever and is interrupted by the supervisor after the + // stop request wins, so execution never continues after `self.stop`. + stop: Effect.suspend(() => + initializing + ? requestStop + : requestStop.pipe(Effect.andThen(Effect.never)) + ), + send: inspector === undefined + ? (event) => offerDirect(event) + : (event) => offerInspected!(event, undefined, undefined), + ...(inspector === undefined + ? undefined + : { inspectionSubject: subject!, sendInspected: offerInspected! }) + } + + let { + changes: childChanges, + close: closeChildren, + get: getChild, + owned: ownedChildren, + sendTo, + spawn, + stop: stopChild + } = childlessRuntime + if (!executionIsChildless(logic.execution)) { + ;({ + changes: childChanges, + close: closeChildren, + get: getChild, + owned: ownedChildren, + sendTo, + spawn, + stop: stopChild + } = yield* makeChildRuntime( + self, + runtime, + undefined, + sendAcknowledged === undefined ? undefined : (event) => sendAcknowledged(event as Event) + )) + } + const cleanupStartupFailure = (exit: Exit.Exit): Effect.Effect => { + if (Exit.isSuccess(exit)) return Effect.void + if (inspector !== undefined) { + inspector.publishUnsafe( + activity === undefined + ? { _tag: "StartFailed", subject: subject!, cause: exit.cause } + : { _tag: "ActivityStopped", subject: activity.owner, activity, exit } + ) + } + return closeChildren(exit).pipe( + Effect.andThen(emissions.close()), + Effect.andThen(options.inspectionRoot === true && inspector !== undefined ? inspector.close : Effect.void) + ) + } + const cleanup = onStopSync === undefined ? onStop ?? Effect.void : Effect.sync(onStopSync) + const currentCausation = inspector === undefined + ? noCausation + : (): Inspection.Causation | undefined => messageCausation(inFlightMessage, initializing) + const sendParent = overrideSendParent ?? (parent === undefined + ? noParentSend + : inspector === undefined + ? parent.send + : (event) => sendMachineTarget(parent, event, subject, currentCausation())) + const emit = inspector === undefined + ? emissions.emit + : (event: unknown) => + emissions.emit(event).pipe( + Effect.tap(() => + Effect.sync(() => + inspector.publishUnsafe({ + _tag: "Emitted", + subject: subject!, + emission: event, + causedBy: currentCausation() + }) + ) + ) + ) + const sendToTarget: ProcessScope["sendTo"] = inspector === undefined + ? ((target: unknown, event: unknown) => + isMachineTarget(target) ? target.send(event) : sendTo(target as ChildSelector, event)) as ProcessScope< + Event + >["sendTo"] + : ((target: unknown, event: unknown) => + isMachineTarget(target) + ? sendMachineTarget(target, event, subject, currentCausation()) + : sendTo(target as ChildSelector, event, subject, currentCausation())) as ProcessScope["sendTo"] + + const scope: ProcessScope = { + self, + parent, + spawn, + sendParent, + emit, + sendTo: sendToTarget, + stopChild, + failCause: (cause) => + Deferred.succeed(termination, { + _tag: "Failure", + cause: cause as Cause.Cause + }).pipe(Effect.asVoid), + inspectInitial: inspector === undefined + ? noInspectInitial + : (paths, microsteps = []) => { + initialEntryPaths = paths + initialMicrosteps = microsteps + } + } + + if (inspector !== undefined) { + inspector.publishUnsafe( + activity === undefined + ? { + _tag: "Created", + subject: subject!, + parent: parent?.inspectionSubject, + origin: options.origin ?? { _tag: "Root" }, + definition: logic.inspection?.definition + } + : { _tag: "ActivityStarted", subject: activity.owner, activity } + ) + } + + const initial = yield* logic.initial(scope).pipe( + Effect.onExit(cleanupStartupFailure), + Effect.ensuring(Effect.sync(() => { + initializing = false + })) + ) + const current = yield* SynchronizedRef.make>({ + revision: 0, + terminalizing: false, + changes: undefined, + snapshot: { + status: "active", + state: initial + } + }) + if (activity === undefined) { + inspector?.publishUnsafe({ + _tag: "Initialized", + subject: subject!, + snapshot: { status: "active", state: initial }, + initialEntryPaths: initialEntryPaths ?? [], + microsteps: InspectionRuntime.microsteps({ microsteps: initialMicrosteps ?? [] }) + }) + } + const publishSnapshot: ( + snapshot: VersionedSnapshot + ) => Effect.Effect> = onSnapshot === undefined + ? (snapshot) => + snapshot.changes === undefined + ? Effect.succeed(snapshot) + : PubSub.publish(snapshot.changes, [snapshot] as const).pipe(Effect.as(snapshot)) + : (snapshot) => { + const publish = snapshot.changes === undefined + ? Effect.succeed(snapshot) + : PubSub.publish(snapshot.changes, [snapshot] as const).pipe(Effect.as(snapshot)) + const runtimeSnapshot = snapshot.snapshot + return runtimeSnapshot.status !== "active" + ? publish + : publish.pipe(Effect.tap(() => notifyActiveSnapshot(onSnapshot, runtimeSnapshot))) + } + + const completeChanges = ( + snapshot: VersionedSnapshot + ): Effect.Effect => + snapshot.changes === undefined + ? Effect.void + : PubSub.publish(snapshot.changes, Exit.succeed(undefined)).pipe(Effect.asVoid) + + const completeIfTerminal = ( + snapshot: VersionedSnapshot + ): Effect.Effect> => { + if (snapshot.snapshot.status === "active") { + return Effect.succeed(snapshot) + } + return completeChanges(snapshot).pipe(Effect.as(snapshot)) + } + + const publishIfCurrent = ( + snapshot: VersionedSnapshot + ): Effect.Effect | undefined> => + SynchronizedRef.get(current).pipe( + Effect.flatMap(( + currentSnapshot + ): Effect.Effect | undefined> => + currentSnapshot.revision === snapshot.revision + ? publishSnapshot(snapshot).pipe(Effect.flatMap(completeIfTerminal)) + : Effect.succeed(undefined) + ) + ) + + type SnapshotModification = readonly [ + VersionedSnapshot | undefined, + VersionedSnapshot + ] + + const updateSnapshot = ( + f: ( + snapshot: RuntimeSnapshot + ) => Effect.Effect | undefined, E2, R2> + ): Effect.Effect | undefined, E2, R2> => + SynchronizedRef.modifyEffect( + current, + (current) => + current.terminalizing + ? Effect.succeed([undefined, current] as const) + : Effect.map( + f(current.snapshot), + (next) => { + if (next === undefined) { + return [undefined, current] as const + } + const versioned = { + revision: current.revision + 1, + snapshot: next, + terminalizing: false, + changes: current.changes + } + return [versioned, versioned] as const + } + ) + ).pipe( + Effect.flatMap((versioned) => + versioned === undefined ? Effect.succeed(undefined) : publishIfCurrent(versioned) + ), + Effect.map((published) => published?.snapshot) + ) + + const reserveTerminalSnapshot = ( + f: ( + snapshot: Extract, { readonly status: "active" }> + ) => RuntimeSnapshot + ): Effect.Effect | undefined> => + SynchronizedRef.modify( + current, + (current): SnapshotModification => { + if (current.terminalizing || current.snapshot.status !== "active") { + return [undefined, current] + } + return [ + { + revision: current.revision + 1, + snapshot: f(current.snapshot), + terminalizing: true, + changes: current.changes + }, + { ...current, terminalizing: true } + ] + } + ).pipe(Effect.map((versioned) => versioned?.snapshot)) + + const setAndPublishSnapshot = ( + snapshot: RuntimeSnapshot + ): Effect.Effect => + SynchronizedRef.updateAndGet(current, (current) => ({ + revision: current.revision + 1, + snapshot, + terminalizing: true, + changes: current.changes + })).pipe( + Effect.flatMap(publishSnapshot), + Effect.flatMap(completeIfTerminal), + Effect.asVoid + ) + + const setActiveStateDirect = (state: State) => + updateSnapshot((snapshot) => + Effect.succeed( + snapshot.status === "active" + ? { + status: "active", + state + } + : undefined + ) + ).pipe(Effect.asVoid) + + const setActiveState = inspector === undefined ? + setActiveStateDirect : + (state: State) => + SynchronizedRef.get(current).pipe( + Effect.flatMap((before) => + updateSnapshot((snapshot) => + Effect.succeed( + snapshot.status === "active" + ? { + status: "active", + state + } + : undefined + ) + ).pipe( + Effect.tap((after) => + Effect.sync(() => { + if (logic.inspection?.kind === "Machine" || after === undefined) return + inspector.publishUnsafe({ + _tag: "StateChanged", + subject: subject!, + before: before.snapshot.state, + after: state, + causedByDeliveryId: inFlightMessage !== undefined && isAcknowledgedMessage(inFlightMessage) + ? inFlightMessage.inspection?.deliveryId + : undefined + }) + }) + ) + ) + ), + Effect.asVoid + ) + + const terminalizeWith = ( + snapshot: RuntimeSnapshot, + exit: Exit.Exit, + completeDone: Effect.Effect + ): Effect.Effect => { + const notifyOutcome = + onOutcome === undefined || (snapshot.status === "stopped" && options.skipStoppedOutcome === true) + ? Effect.void + : Effect.suspend(() => onOutcome(classifyOutcome(snapshot)!)).pipe( + Effect.exit, + Effect.asVoid + ) + const closeEmissionsAndInspect = inspector === undefined + ? emissions.close() + : emissions.close().pipe( + Effect.andThen(Effect.sync(() => + inspector.publishUnsafe( + activity === undefined + ? { _tag: "Terminated", subject: subject!, snapshot } + : { + _tag: "ActivityStopped", + subject: activity.owner, + activity, + exit: snapshot.status === "stopped" ? Exit.interrupt() : exit + } + ) + )) + ) + return Effect.uninterruptible( + Effect.sync(() => { + while (true) { + const pending = Queue.takeUnsafe(queue) + if (pending === undefined || Exit.isFailure(pending)) break + stopAcknowledgedMessage(pending.value) + } + }).pipe( + Effect.andThen(Queue.shutdown(queue)), + Effect.andThen(closeChildren(exit)), + Effect.andThen(setAndPublishSnapshot(snapshot)), + Effect.andThen(closeEmissionsAndInspect), + Effect.andThen(Effect.sync(() => { + if (Exit.isFailure(exit)) { + failAcknowledgedMessage(inFlightMessage, exit.cause) + } else { + stopAcknowledgedMessage(inFlightMessage) + } + inFlightMessage = undefined + })), + Effect.andThen(notifyOutcome), + Effect.andThen(cleanup), + Effect.andThen(options.inspectionRoot === true && inspector !== undefined ? inspector.close : Effect.void), + Effect.andThen(completeDone) + ) + ) + } + + const reserveStoppedSnapshot = reserveTerminalSnapshot((snapshot) => ({ + status: "stopped", + state: snapshot.state + })) + + const reserveFailureSnapshot = (cause: Cause.Cause) => + reserveTerminalSnapshot((snapshot) => ({ + status: "error", + state: snapshot.state, + cause + })) + + const reserveSuccessSnapshot = (output: Output) => + reserveTerminalSnapshot((snapshot) => ({ + status: "done", + state: snapshot.state, + output + })) + + const terminalizeReservedStop = ( + snapshot: RuntimeSnapshot + ): Effect.Effect => { + const exit = Exit.void + return terminalizeWith( + snapshot, + exit, + Deferred.fail(done, new StoppedError()) + ) + } + + const terminalizeReservedFailure = ( + snapshot: RuntimeSnapshot, + cause: Cause.Cause + ): Effect.Effect => { + const exit = Exit.failCause(cause) + return terminalizeWith(snapshot, exit, Deferred.failCause(done, cause)) + } + + const terminalizeReservedSuccess = ( + snapshot: RuntimeSnapshot, + output: Output + ): Effect.Effect => { + const exit = Exit.succeed(output) + return terminalizeWith(snapshot, exit, Deferred.succeed(done, output)) + } + + const stop: Effect.Effect = Effect.uninterruptible( + requestStop.pipe(Effect.andThen(awaitCompletion)) + ) + + const acknowledgedContext: + | Pick< + ProcessContext, + "receiveMessage" | "pollMessage" | "completeMessage" + > + | undefined = logic.execution?._tag !== "Compiled" ? undefined : { + receiveMessage: Queue.take(queue).pipe( + Effect.tap((message) => + Effect.sync(() => { + inFlightMessage = isAcknowledgedMessage(message) ? message : undefined + }) + ) + ), + pollMessage: Queue.poll(queue).pipe( + Effect.tap((message) => + Effect.sync(() => { + if (Option.isSome(message)) { + inFlightMessage = isAcknowledgedMessage(message.value) ? message.value : undefined + } + }) + ) + ), + completeMessage: (delivery) => { + succeedAcknowledgedMessage(inFlightMessage, delivery) + inFlightMessage = undefined + } + } + const receive = inspector === undefined || logic.execution?._tag === "Compiled" + ? Queue.take(queue).pipe(Effect.map(messageEvent)) + : Queue.take(queue).pipe( + Effect.tap((message) => + Effect.sync(() => { + inFlightMessage = message + }) + ), + Effect.map(messageEvent) + ) + const poll = inspector === undefined || logic.execution?._tag === "Compiled" + ? Queue.poll(queue).pipe(Effect.map(Option.map(messageEvent))) + : Queue.poll(queue).pipe( + Effect.tap((message) => + Effect.sync(() => { + if (Option.isSome(message)) inFlightMessage = message.value + }) + ), + Effect.map(Option.map(messageEvent)) + ) + const updateStateDirect = (f: (state: State) => Effect.Effect) => + updateSnapshot((snapshot) => + snapshot.status === "active" + ? f(snapshot.state).pipe( + Effect.map((state) => ({ + status: "active" as const, + state + })) + ) + : Effect.succeed(undefined) + ).pipe(Effect.asVoid) + const updateState: ProcessContext["updateState"] = inspector === undefined + ? updateStateDirect + : (f) => + SynchronizedRef.get(current).pipe( + Effect.flatMap((before) => + updateSnapshot((snapshot) => + snapshot.status === "active" + ? f(snapshot.state).pipe( + Effect.map((state) => ({ + status: "active" as const, + state + })) + ) + : Effect.succeed(undefined) + ).pipe( + Effect.tap((after) => + Effect.sync(() => { + if (logic.inspection?.kind === "Machine" || after?.status !== "active") return + inspector.publishUnsafe({ + _tag: "StateChanged", + subject: subject!, + before: before.snapshot.state, + after: after.state, + causedByDeliveryId: inFlightMessage !== undefined && isAcknowledgedMessage(inFlightMessage) + ? inFlightMessage.inspection?.deliveryId + : undefined + }) + }) + ) + ) + ), + Effect.asVoid + ) + const context: ProcessContext = { + ...scope, + ...(logic.execution?._tag === "Compiled" && !logic.execution.childless ? { ownedChildren } : undefined), + ...acknowledgedContext, + receive, + poll, + state: SynchronizedRef.get(current).pipe(Effect.map((current) => current.snapshot.state)), + setState: setActiveState, + updateState + } + + const getOrCreateChanges = SynchronizedRef.modifyEffect( + current, + (current) => { + if (current.snapshot.status !== "active") { + return Effect.succeed([undefined, current] as const) + } + if (current.changes !== undefined) { + return Effect.succeed([current.changes, current] as const) + } + return PubSub.unbounded>>({ replay: 1 }).pipe( + Effect.map((changes) => [changes, { ...current, changes }] as const) + ) + } + ) + + const changesStream: Stream.Stream> = Stream.unwrap( + Effect.gen(function*() { + const changes = yield* getOrCreateChanges + if (changes === undefined) { + return Stream.succeed((yield* SynchronizedRef.get(current)).snapshot) + } + const subscription = yield* PubSub.subscribe(changes) + const captured = yield* SynchronizedRef.get(current) + if (captured.snapshot.status !== "active") { + return Stream.succeed(captured.snapshot) + } + return Stream.succeed(captured.snapshot).pipe( + Stream.concat( + Stream.fromChannel(Channel.fromEffectTake(PubSub.take(subscription))).pipe( + Stream.filter((next) => next.revision > captured.revision), + Stream.map((next) => next.snapshot) + ) + ) + ) + }) + ) + + const ref: MachineRef = { + id, + sessionId, + ...(inspector === undefined + ? undefined + : { inspectionSubject: subject!, sendInspected: offerInspected! }), + state: SynchronizedRef.get(current).pipe(Effect.map((current) => current.snapshot.state)), + snapshot: SynchronizedRef.get(current).pipe(Effect.map((current) => current.snapshot)), + changes: changesStream, + emissions: emissions.stream as Stream.Stream, + join: Deferred.await(done), + stop, + send: self.send, + ...(sendAcknowledged === undefined ? undefined : { [acknowledgedSend]: sendAcknowledged }), + child: getChild, + childChanges + } + + if (onReadySync !== undefined && !onReadySync(ref)) { + yield* requestStop + } else if (onReady !== undefined) { + yield* onReady(ref, requestStop) + } + if (onSnapshot !== undefined) { + yield* notifyActiveSnapshot(onSnapshot, { status: "active", state: initial }) + } + + const reserveTermination = (termination: ProcessTermination) => { + switch (termination._tag) { + case "Stopped": + return reserveStoppedSnapshot + case "Done": + return reserveSuccessSnapshot(termination.output) + case "Failure": + return reserveFailureSnapshot(termination.cause) + } + } + + const completeTermination = ( + termination: ProcessTermination, + snapshot: RuntimeSnapshot + ) => { + switch (termination._tag) { + case "Stopped": + return terminalizeReservedStop(snapshot) + case "Done": + return terminalizeReservedSuccess(snapshot, termination.output) + case "Failure": + return terminalizeReservedFailure(snapshot, termination.cause) + } + } + + const forkRuntime = (effect: Effect.Effect) => + detached === true + ? Effect.forkDetach(effect) + : Effect.forkChild(effect) + + const pendingTermination = yield* Deferred.poll(termination) + const worker = Option.isNone(pendingTermination) + ? yield* Effect.uninterruptibleMask((restore) => + restore(Effect.suspend(() => logic.run(context))).pipe( + Effect.exit, + Effect.flatMap((exit) => + Deferred.succeed( + termination, + Exit.isFailure(exit) + ? { _tag: "Failure", cause: exit.cause } + : { _tag: "Done", output: exit.value } + ) + ) + ) + ).pipe(forkRuntime) + : undefined + + // One Deferred arbitrates all terminal causes. The supervisor reserves the + // terminal snapshot before interrupting the worker, so worker finalizers + // cannot mutate the frozen state. It then waits for those finalizers before + // publishing and completing `join` / `stop`. + const runFiber: Effect.Effect = Effect.uninterruptibleMask((restore) => + Effect.gen(function*() { + const requested = Option.isSome(pendingTermination) + ? yield* pendingTermination.value + : yield* restore(Deferred.await(termination)) + + const snapshot = yield* reserveTermination(requested) + if (worker !== undefined) { + yield* Fiber.interrupt(worker) + } + if (snapshot === undefined) { + return yield* awaitCompletion + } + return yield* completeTermination(requested, snapshot) + }) + ) + + yield* forkRuntime(runFiber) + yield* Effect.yieldNow + + return ref + } +) diff --git a/packages/effect-machine/src/internal/machine/runtimeProtocol.ts b/packages/effect-machine/src/internal/machine/runtimeProtocol.ts new file mode 100644 index 0000000..3a294dc --- /dev/null +++ b/packages/effect-machine/src/internal/machine/runtimeProtocol.ts @@ -0,0 +1,1200 @@ +/** Shared local process contracts, ownership, and observation. */ +import * as Cause from "effect/Cause" +import * as Channel from "effect/Channel" +import * as Context from "effect/Context" +import * as Deferred from "effect/Deferred" +import * as Effect from "effect/Effect" +import * as Exit from "effect/Exit" +import * as Option from "effect/Option" +import * as PubSub from "effect/PubSub" +import * as Scope from "effect/Scope" +import * as Stream from "effect/Stream" +import type * as Take from "effect/Take" +import type { + MachineRef as PublicMachineRef, + Prepared as PublicPrepared, + RuntimeOutcome as PublicRuntimeOutcome, + RuntimeSnapshot as PublicRuntimeSnapshot +} from "../../Machine.js" +import type { ChildMachine, Inspection, Machine as MachineDefinition, MachineTarget } from "../../Machine.js" +import { + type ChildDescriptor, + type ChildEntry, + type ChildKey, + type ChildObserver, + type ChildRegistry, + type ChildSelector, + matchesChild, + offerChildObservation, + registerChild, + selectRegistryChild, + takeChildObservations, + unregisterChild +} from "./childRegistry.js" +import { ChildAlreadyExistsError, StoppedError } from "./errors.js" +import * as InspectionRuntime from "./inspectionRuntime.js" +import { ChildMachineLogicTypeId } from "./symbols.js" + +/** @internal */ +export const activeSnapshotObserver: unique symbol = Symbol.for("effect/Machine/activeSnapshotObserver") + +/** @internal */ +const sendParentOverride: unique symbol = Symbol.for("effect/Machine/sendParentOverride") + +/** @internal */ +export const acknowledgedSend: unique symbol = Symbol.for("effect/Machine/acknowledgedSend") + +/** @internal */ +export interface AcknowledgedDelivery { + readonly before: State + readonly plan: unknown + readonly after: State +} + +export const AcknowledgedMessageTypeId: unique symbol = Symbol("effect/Machine/AcknowledgedMessage") + +/** @internal */ +interface AcknowledgedMessage { + readonly [AcknowledgedMessageTypeId]: true + readonly event: Event + readonly deferred?: Deferred.Deferred, unknown> + readonly inspection?: InspectedDelivery +} + +/** @internal */ +export type ProcessMessage = Event | AcknowledgedMessage + +/** @internal */ +export const isAcknowledgedMessage = ( + message: ProcessMessage +): message is AcknowledgedMessage => + typeof message === "object" && message !== null && AcknowledgedMessageTypeId in message + +/** @internal */ +export const messageEvent = (message: ProcessMessage): Event => + isAcknowledgedMessage(message) ? message.event : message + +export const succeedAcknowledgedMessage = ( + message: ProcessMessage | undefined, + delivery: AcknowledgedDelivery +): void => { + if (message !== undefined && isAcknowledgedMessage(message)) { + if (message.deferred !== undefined) { + Deferred.doneUnsafe(message.deferred, Effect.succeed(delivery as AcknowledgedDelivery)) + } + message.inspection?.complete(delivery as AcknowledgedDelivery) + } +} + +export const failAcknowledgedMessage = ( + message: ProcessMessage | undefined, + cause: Cause.Cause +): void => { + if (message !== undefined && isAcknowledgedMessage(message)) { + if (message.deferred !== undefined) Deferred.doneUnsafe(message.deferred, Effect.failCause(cause)) + } +} + +export const stopAcknowledgedMessage = (message: ProcessMessage | undefined): void => { + if (message !== undefined && isAcknowledgedMessage(message)) { + if (message.deferred !== undefined) Deferred.doneUnsafe(message.deferred, Effect.fail(new StoppedError())) + } +} + +interface InspectedDelivery { + readonly deliveryId: number + readonly macrostepId: number + readonly source: Inspection.Subject | undefined + readonly event: unknown + readonly causedBy: Inspection.Causation | undefined + readonly complete: (delivery: AcknowledgedDelivery) => void +} + +export type InspectedOffer = ( + event: Event, + source: Inspection.Subject | undefined, + causedBy: Inspection.Causation | undefined, + deferred?: Deferred.Deferred, unknown> +) => Effect.Effect + +export type RuntimeSnapshot = PublicRuntimeSnapshot + +export interface VersionedSnapshot { + readonly revision: number + readonly snapshot: RuntimeSnapshot + readonly terminalizing: boolean + readonly changes: PubSub.PubSub>> | undefined + /** Compiled drains retain one non-empty publication chunk until their next Effect boundary. */ + pendingChanges?: VersionedSnapshotBatch | undefined +} + +type VersionedSnapshotBatch = [ + VersionedSnapshot, + ...Array> +] + +export type RuntimeOutcome = PublicRuntimeOutcome + +export interface MachineRef + extends Omit, "child" | "childChanges"> +{ + /** @internal */ + readonly inspectionSubject?: Inspection.Subject + /** @internal */ + readonly sendInspected?: ProcessAddress["sendInspected"] + readonly [acknowledgedSend]?: ( + event: Event + ) => Effect.Effect, Error | StoppedError> + readonly child: (child: any) => Effect.Effect> + readonly childChanges: (child: any) => Stream.Stream> +} + +export interface PreparedProcess< + out State, + in Event, + out Error, + out Output, + out Emitted, + out StartError, + StartRequirements +> extends Omit, "start"> { + readonly start: Effect.Effect, StartError, StartRequirements> +} + +export interface ProcessAddress { + readonly id: string + readonly sessionId: string + readonly inspectionSubject?: Inspection.Subject + readonly stop: Effect.Effect + readonly send: (event: Event) => Effect.Effect + readonly sendInspected?: ( + event: Event, + source: Inspection.Subject | undefined, + causedBy: Inspection.Causation | undefined + ) => Effect.Effect + readonly [acknowledgedSend]?: ( + event: Event + ) => Effect.Effect, unknown | StoppedError> +} + +export const isMachineTarget = (value: unknown): value is MachineTarget => + typeof value === "object" && value !== null && "send" in value && typeof value.send === "function" + +export const sendMachineTarget = ( + target: MachineTarget, + event: unknown, + source: Inspection.Subject | undefined, + causedBy: Inspection.Causation | undefined +): Effect.Effect => + "inspectionSubject" in target && "sendInspected" in target && typeof target.sendInspected === "function" + ? target.sendInspected(event, source, causedBy) + : target.send(event) + +export interface ProcessScope { + readonly self: ProcessAddress + readonly parent: ProcessAddress | undefined + readonly spawn: ProcessSpawn + readonly sendParent: (event: unknown) => Effect.Effect + readonly emit: (event: unknown) => Effect.Effect + readonly sendTo: { + (target: MachineTarget, event: TargetEvent): Effect.Effect + (child: ChildSelector, event: unknown): Effect.Effect + } + readonly stopChild: (child: ChildSelector) => Effect.Effect + /** @internal */ + readonly failCause: (cause: Cause.Cause) => Effect.Effect + /** @internal */ + readonly inspectInitial: ( + initialEntryPaths: ReadonlyArray, + microsteps?: ReadonlyArray + ) => void +} + +export interface ProcessContext extends ProcessScope { + readonly receive: Effect.Effect + /** @internal */ + readonly poll?: Effect.Effect> + /** @internal */ + readonly receiveMessage?: Effect.Effect> + /** @internal */ + readonly pollMessage?: Effect.Effect>> + /** @internal */ + readonly completeMessage?: (delivery: AcknowledgedDelivery) => void + readonly state: Effect.Effect + readonly setState: (state: State) => Effect.Effect + readonly updateState: ( + f: (state: State) => Effect.Effect + ) => Effect.Effect + /** Present only when a compiled statechart is forced through the generic runtime. @internal */ + readonly ownedChildren?: OwnedChildRuntime +} + +/** + * Owner-local execution context for compiled statecharts. + * + * Unlike `ProcessContext`, synchronous mailbox and state operations do not + * introduce an Effect boundary. The compiled drain still returns an Effect so + * machine commands, invokes, observation callbacks, interruption, and the Effect + * scheduler remain explicit at their actual boundaries. + * + * @internal + */ +export interface CompiledProcessContext { + readonly scope: ProcessScope + readonly ownedChildren: OwnedChildRuntime + readonly poll: () => Option.Option + readonly pollMessage: () => Option.Option> + readonly state: () => State + readonly completeMessage: (delivery: AcknowledgedDelivery) => void + readonly commit: (state: State) => Effect.Effect | undefined + /** + * Publishes the current synchronous segment before continuing with work that + * may suspend, run user effects, or make the committed state observable. + */ + readonly runAfterChanges: (effect: Effect.Effect) => Effect.Effect + executionState: unknown +} + +export type CompiledProcessInitial = + | { readonly state: State; readonly done: false; readonly output: undefined } + | { readonly state: State; readonly done: true; readonly output: Output } + | { + readonly state: State + readonly done: boolean + readonly output: Output | undefined + readonly executionState: unknown + } + +type CompiledProcessDrain = + | { + readonly _tag: "Process" + readonly run: ( + context: ProcessContext + ) => Effect.Effect, Error, Requirements> + } + | { + readonly _tag: "Owned" + readonly run: ( + context: CompiledProcessContext + ) => Effect.Effect, Error, Requirements> + } + +/** + * The complete capability descriptor consumed by the compact process runtime. + * Generic process logic omits this field entirely. + * + * @internal + */ +export interface CompiledProcessExecution< + State, + Event, + Error, + Requirements, + Output, + InitialError +> { + readonly _tag: "Compiled" + readonly childless: boolean + readonly initial?: ( + scope: ProcessScope + ) => Effect.Effect, InitialError, Requirements> + readonly initialSync?: ( + scope: ProcessScope + ) => CompiledProcessInitial + readonly drain: CompiledProcessDrain +} + +type ProcessExecution = + | { + readonly _tag: "Childless" + } + | CompiledProcessExecution + +export const executionIsChildless = ( + execution: ProcessExecution | undefined +): boolean => execution?._tag === "Childless" || execution?.childless === true + +export interface CompactProcessMailbox { + items: Array> | undefined + index: number + closed: boolean +} + +export const offerCompactMailbox = ( + mailbox: CompactProcessMailbox, + event: ProcessMessage +): void => { + const items = mailbox.items ?? [] + mailbox.items = items + items.push(event) +} + +export const pollCompactMailbox = ( + mailbox: CompactProcessMailbox +): Option.Option> => { + if (mailbox.items === undefined) { + return Option.none() + } + const event = mailbox.items[mailbox.index]! + mailbox.index += 1 + if (mailbox.index === mailbox.items.length) { + mailbox.items = undefined + mailbox.index = 0 + } + return Option.some(event) +} + +export const closeCompactMailbox = (mailbox: CompactProcessMailbox): void => { + if (mailbox.items !== undefined) { + for (let index = mailbox.index; index < mailbox.items.length; index += 1) { + stopAcknowledgedMessage(mailbox.items[index]) + } + } + mailbox.closed = true + mailbox.items = undefined + mailbox.index = 0 +} + +export interface ProcessLogic< + State, + Event, + out Error = never, + out Requirements = never, + out Output = never, + out InitialError = never +> { + /** @internal */ + readonly execution?: ProcessExecution + /** @internal */ + readonly inspection?: { + readonly kind: Inspection.Subject["kind"] + readonly definition?: MachineDefinition.Any + } + initial(scope: ProcessScope): Effect.Effect + run(context: ProcessContext): Effect.Effect +} + +interface ProcessSpawn { + ( + child: Child & ChildMachine.Executable & ChildMachine.ParentCompatibility, + ...options: ChildMachine.SpawnArgs + ): Effect.Effect< + ChildMachine.Ref, + ChildAlreadyExistsError | ChildMachine.StartError, + ChildMachine.StartRequirements + > + ( + logic: ProcessLogic + ): Effect.Effect< + MachineRef, + ChildInitialError, + Exclude + > + ( + logic: ProcessLogic, + options: { + readonly id: string + readonly descriptor?: ChildDescriptor + readonly onOutcome?: ( + outcome: RuntimeOutcome + ) => Effect.Effect + readonly [activeSnapshotObserver]?: ( + snapshot: Extract, { readonly status: "active" }> + ) => Effect.Effect + readonly [sendParentOverride]?: (event: unknown) => Effect.Effect + } + ): Effect.Effect< + MachineRef, + ChildAlreadyExistsError | ChildInitialError, + Exclude + > +} + +export class MachineRuntime extends Context.Service>()( + "effect/Machine/MachineRuntime" +) {} + +export const provideMachineRuntime = ( + effect: Effect.Effect, + scope: ProcessScope +): Effect.Effect> => + Effect.provideService(effect, MachineRuntime, scope as ProcessScope) + +export const classifyOutcome = ( + snapshot: RuntimeSnapshot +): RuntimeOutcome | undefined => { + switch (snapshot.status) { + case "active": { + return undefined + } + case "done": { + return { + _tag: "Done", + output: snapshot.output, + snapshot + } + } + case "error": { + const failure = snapshot.cause.reasons.find(Cause.isFailReason) + if (failure !== undefined) { + return { + _tag: "Failure", + error: failure.error, + cause: snapshot.cause, + snapshot + } + } + const defect = snapshot.cause.reasons.find(Cause.isDieReason) + if (defect !== undefined) { + return { + _tag: "Defect", + defect: defect.defect, + cause: snapshot.cause, + snapshot + } + } + const interrupted = snapshot.cause.reasons.find(Cause.isInterruptReason) + if (interrupted !== undefined) { + return { + _tag: "Interrupted", + cause: snapshot.cause, + snapshot + } + } + return { + _tag: "Cause", + cause: snapshot.cause, + snapshot + } + } + case "stopped": { + return { + _tag: "Stopped", + snapshot + } + } + } +} + +export const notifyActiveSnapshot = ( + onSnapshot: ( + snapshot: Extract, { readonly status: "active" }> + ) => Effect.Effect, + snapshot: Extract, { readonly status: "active" }> +): Effect.Effect => + Effect.suspend(() => onSnapshot(snapshot)).pipe( + Effect.exit, + Effect.asVoid + ) + +export const watch = ( + ref: MachineRef +): Stream.Stream> => + ref.changes.pipe( + Stream.filter((snapshot) => snapshot.status !== "active"), + Stream.map((snapshot) => classifyOutcome(snapshot)!), + Stream.take(1) + ) + +export interface ProcessRuntime { + readonly start: StartProcess + readonly startCompiledSync: ( + logic: ProcessLogic, + options: StartInternalOptions, + services: Context.Context, + sessionId: string + ) => Effect.Effect, any, any> + readonly nextSessionId: Effect.Effect + inspection?: InspectionRuntime.Runtime +} + +export const makeProcessRuntime = ( + start: StartProcess, + startCompiledSync: ProcessRuntime["startCompiledSync"] +): Effect.Effect => + Effect.sync(() => { + let sessionIdCounter = 0 + return { + start, + startCompiledSync, + nextSessionId: Effect.sync(() => `machine:${sessionIdCounter++}`) + } + }) + +export const inspectionSubject = ( + logic: ProcessLogic, + id: string, + sessionId: string +): Inspection.Subject => ({ + id, + sessionId, + kind: logic.inspection?.kind ?? "Logic" +}) + +export const messageCausation = ( + message: ProcessMessage | undefined, + initializing: boolean +): Inspection.Causation | undefined => + initializing + ? { _tag: "Initialization" } + : message !== undefined && isAcknowledgedMessage(message) && message.inspection !== undefined + ? { _tag: "Macrostep", macrostepId: message.inspection.macrostepId } + : undefined + +export const makeInspectedMessage = ( + inspection: InspectionRuntime.Runtime, + subject: Inspection.Subject, + event: Event, + source: Inspection.Subject | undefined, + causedBy: Inspection.Causation | undefined, + deferred?: Deferred.Deferred, unknown> +): AcknowledgedMessage => { + const deliveryId = inspection.nextDeliveryId() + const macrostepId = inspection.nextMacrostepId() + const inspected: InspectedDelivery = { + deliveryId, + macrostepId, + source, + event, + causedBy, + complete: (delivery) => { + const microsteps = InspectionRuntime.microsteps(delivery.plan) + inspection.publishUnsafe({ + _tag: "EventProcessed", + subject, + macrostepId, + deliveryId, + source, + event, + before: { status: "active", state: delivery.before }, + after: { status: "active", state: delivery.after }, + handled: microsteps.some((microstep) => microstep.transitions.length > 0), + configurationChanged: microsteps.some((microstep) => microstep.changed), + microsteps + }) + } + } + return { + [AcknowledgedMessageTypeId]: true, + event, + ...(deferred === undefined ? undefined : { deferred }), + inspection: inspected + } +} + +export const publishInspectedSent = ( + inspection: InspectionRuntime.Runtime, + subject: Inspection.Subject, + message: ProcessMessage +): void => { + if (!isAcknowledgedMessage(message) || message.inspection === undefined) return + const delivery = message.inspection + inspection.publishUnsafe({ + _tag: "EventSent", + subject, + deliveryId: delivery.deliveryId, + source: delivery.source, + target: InspectionRuntime.endpoint(subject), + event: delivery.event, + causedBy: delivery.causedBy + }) +} + +export interface StartInternalOptions { + readonly detached?: boolean + readonly id?: string + readonly sessionId?: string + readonly emissions?: EmissionRuntime + readonly onOutcome?: (outcome: RuntimeOutcome) => Effect.Effect + readonly onSnapshot?: ( + snapshot: Extract, { readonly status: "active" }> + ) => Effect.Effect + readonly onReady?: ( + ref: MachineRef, + requestStop: Effect.Effect + ) => Effect.Effect + readonly onReadySync?: (ref: MachineRef) => boolean + readonly onStop?: Effect.Effect + readonly onStopSync?: () => void + readonly skipStoppedOutcome?: boolean + readonly parent?: ProcessAddress + readonly runtime: ProcessRuntime + readonly sendParent?: (event: unknown) => Effect.Effect + readonly origin?: Inspection.Origin + readonly activity?: { + readonly id: string + readonly owner: Inspection.Subject + readonly ownerPath: string + readonly kind: Inspection.Activity["kind"] + } + readonly inspectionRoot?: boolean +} + +/** @internal */ +interface OwnedChildSpawnOptions { + readonly key: string + readonly path: string + readonly id: string + readonly duplicateId: string + readonly descriptor?: ChildDescriptor + readonly onOutcome: ( + isCurrent: () => boolean, + outcome: RuntimeOutcome, + activitySessionId: string | undefined + ) => Effect.Effect + readonly onSnapshot?: ( + isCurrent: () => boolean, + snapshot: Extract, { readonly status: "active" }> + ) => Effect.Effect + readonly sendParent: ( + isCurrent: () => boolean, + event: unknown + ) => Effect.Effect + readonly activityKind?: Inspection.Activity["kind"] +} + +/** @internal */ +export interface OwnedChildRuntime { + readonly spawn: ( + makeLogic: () => ProcessLogic, + options: OwnedChildSpawnOptions + ) => Effect.Effect + readonly stopAll: () => Effect.Effect + readonly stopPaths: (paths: ReadonlyArray) => Effect.Effect | undefined +} + +export interface ChildRuntime { + readonly close: (exit: Exit.Exit) => Effect.Effect + readonly spawn: ProcessSpawn + readonly get: ( + child: ChildSelector + ) => Effect.Effect>> + readonly changes: ( + child: ChildSelector + ) => Stream.Stream>> + readonly sendTo: ( + child: ChildSelector, + event: unknown, + source?: Inspection.Subject, + causedBy?: Inspection.Causation + ) => Effect.Effect + readonly stop: (child: ChildSelector) => Effect.Effect + readonly owned: OwnedChildRuntime +} + +class OwnedChildRuntimeImpl implements OwnedChildRuntime { + private scopedServices: Context.Context | undefined + + constructor( + private readonly registry: ChildRegistry, + private readonly self: ProcessAddress, + private readonly runtime: ProcessRuntime, + private readonly services?: Context.Context, + private readonly sendAcknowledged?: ( + event: unknown + ) => Effect.Effect, unknown | StoppedError> + ) {} + + private has(key: string): boolean { + for (const entry of this.registry.children.values()) { + if (entry.ownerActive && entry.ownerKey === key) return true + } + return false + } + + private stopEntry(entry: ChildEntry): Effect.Effect { + entry.ownerActive = false + return entry._tag === "Started" ? entry.ref.stop : Effect.void + } + + spawn( + makeLogic: () => ProcessLogic, + options: OwnedChildSpawnOptions + ): Effect.Effect { + const token = Symbol() + let startedChild: MachineRef | undefined + const isCurrent = (): boolean => { + const entry = this.registry.children.get(options.id) + return entry?.token === token && entry.ownerKey === options.key && entry.ownerActive === true + } + return Effect.suspend(() => { + if (this.registry.closed) return Effect.interrupt + if (this.has(options.key) || this.registry.children.has(options.id)) { + return Effect.fail(new ChildAlreadyExistsError({ id: options.duplicateId })) + } + const logic = makeLogic() + const scope = this.registry.scope ??= Scope.makeUnsafe("parallel") + this.registry.children.set(options.id, { + _tag: "Starting", + token, + ownerKey: options.key, + ownerPath: options.path, + ownerActive: true + }) + const parent: ProcessAddress = { + ...this.self, + send: (event) => options.sendParent(isCurrent, event), + sendInspected: (event, source, causedBy) => + isCurrent() ? sendMachineTarget(this.self, event, source, causedBy) : Effect.void, + ...(this.sendAcknowledged === undefined + ? undefined + : { + [acknowledgedSend]: (event: unknown) => + isCurrent() + ? this.sendAcknowledged!(event) + : Effect.interrupt + }) + } + const startOptions: StartInternalOptions = { + detached: true, + id: options.id, + sendParent: (event) => options.sendParent(isCurrent, event), + onOutcome: (outcome) => options.onOutcome(isCurrent, outcome, startedChild?.sessionId), + ...(options.onSnapshot === undefined + ? undefined + : { onSnapshot: (snapshot) => options.onSnapshot!(isCurrent, snapshot) }), + onReadySync: (child) => { + startedChild = child + return registerChild(this.registry, options.id, token, child, options.descriptor) + }, + onStopSync: () => unregisterChild(this.registry, options.id, token), + skipStoppedOutcome: true, + parent, + runtime: this.runtime, + origin: { _tag: "Invoke", ownerPath: options.path, invokeId: options.duplicateId }, + ...(options.activityKind === undefined || this.runtime.inspection === undefined || + this.self.inspectionSubject === undefined + ? undefined + : { + activity: { + id: options.duplicateId, + owner: this.self.inspectionSubject, + ownerPath: options.path, + kind: options.activityKind + } + }) + } + const execution = logic.execution + const synchronous = this.services !== undefined && options.onSnapshot === undefined && + execution?._tag === "Compiled" && execution.childless && execution.drain._tag === "Owned" && + execution.initialSync !== undefined + const start = synchronous + ? Effect.flatMap( + this.runtime.nextSessionId, + (sessionId) => + this.runtime.startCompiledSync( + logic, + startOptions, + this.scopedServices ??= Context.add(this.services!, Scope.Scope, scope), + sessionId + ) + ) + : this.runtime.start(logic, startOptions) + const guarded = start.pipe( + Effect.onExit((exit) => { + if (Exit.isSuccess(exit)) return Effect.void + unregisterChild(this.registry, options.id, token) + return startedChild === undefined ? Effect.void : startedChild.stop + }) + ) + return (synchronous ? guarded : Scope.provide(guarded, scope)).pipe(Effect.asVoid) + }) + } + + stopAll(): Effect.Effect { + return Effect.suspend(() => { + const effects: Array> = [] + for (const entry of this.registry.children.values()) { + if (entry.ownerActive) effects.push(this.stopEntry(entry)) + } + return effects.length === 0 + ? Effect.void + : effects.length === 1 + ? effects[0]! + : Effect.all(effects, { concurrency: "unbounded", discard: true }) + }) + } + + stopPaths(paths: ReadonlyArray): Effect.Effect | undefined { + if (paths.length === 0) return undefined + const pathSet = new Set(paths) + const effects: Array> = [] + for (const entry of this.registry.children.values()) { + if (entry.ownerActive && entry.ownerPath !== undefined && pathSet.has(entry.ownerPath)) { + effects.push(this.stopEntry(entry)) + } + } + return effects.length === 0 + ? undefined + : effects.length === 1 + ? effects[0]! + : Effect.all(effects, { concurrency: "unbounded", discard: true }) + } +} + +const noChildChanges = Stream.succeed(Option.none()).pipe(Stream.concat(Stream.never)) +export const noParentSend = (_event: unknown): Effect.Effect => Effect.void +export const noInspectInitial = (_paths: ReadonlyArray, _microsteps?: ReadonlyArray): void => {} +export const noCausation = (): Inspection.Causation | undefined => undefined +export const EmissionsClosed: unique symbol = Symbol("effect/Machine/EmissionsClosed") + +export type LazyEmissions = PubSub.PubSub | typeof EmissionsClosed | undefined + +export interface EmissionRuntime { + readonly emit: (event: unknown) => Effect.Effect + readonly close: () => Effect.Effect + readonly stream: Stream.Stream +} + +export const makeEmissionRuntime = (): EmissionRuntime => { + let emissions: LazyEmissions + const getOrCreate: Effect.Effect | undefined> = Effect.suspend(() => { + const observed = emissions + if (observed === EmissionsClosed) return Effect.succeed(undefined) + if (observed !== undefined) return Effect.succeed(observed) + return PubSub.unbounded().pipe( + Effect.flatMap((candidate) => + Effect.sync(() => { + const latest = emissions + if (latest === EmissionsClosed) return [undefined, true] as const + if (latest !== undefined) return [latest, true] as const + emissions = candidate + return [candidate, false] as const + }).pipe( + Effect.flatMap(([selected, discard]) => + discard ? PubSub.shutdown(candidate).pipe(Effect.as(selected)) : Effect.succeed(selected) + ) + ) + ) + ) + }) + return { + emit: (event) => + Effect.suspend(() => + emissions === undefined || emissions === EmissionsClosed + ? Effect.void + : PubSub.publish(emissions, event).pipe(Effect.asVoid) + ), + close: () => { + const observed = emissions + emissions = EmissionsClosed + return observed === undefined || observed === EmissionsClosed ? Effect.void : PubSub.shutdown(observed) + }, + stream: Stream.unwrap( + getOrCreate.pipe( + Effect.map((emissions) => emissions === undefined ? Stream.empty : Stream.fromPubSub(emissions)) + ) + ) + } +} + +export const childlessRuntime: ChildRuntime = { + close: () => Effect.void, + spawn: (() => Effect.die(new Error("Childless machine logic cannot spawn a process"))) as ProcessSpawn, + get: () => Effect.succeed(Option.none()), + changes: () => noChildChanges, + sendTo: () => Effect.void, + stop: () => Effect.void, + owned: { + spawn: () => Effect.die(new Error("Childless machine logic cannot spawn an owned process")), + stopAll: () => Effect.void, + stopPaths: () => undefined + } +} + +export const makeChildRuntimeSync = ( + self: ProcessAddress, + runtime: ProcessRuntime, + services?: Context.Context, + sendAcknowledged?: ( + event: unknown + ) => Effect.Effect, unknown | StoppedError> +): ChildRuntime => { + // Child-registry decisions are synchronous and every access below runs in + // one Effect.sync / Effect.suspend step. Keep the unobserved representation + // compact; selector-specific handoffs are installed only while + // childChanges streams are running. + const registry: ChildRegistry = { + closed: false, + children: new Map(), + observers: undefined, + scope: undefined + } + + const close = (exit: Exit.Exit): Effect.Effect => + Effect.suspend(() => { + if (registry.closed) { + return Effect.void + } + registry.closed = true + if (registry.scope === undefined) { + return Effect.void + } + const finalizers = Scope.closeUnsafe(registry.scope, exit) + let first: Effect.Effect | undefined + let rest: Array> | undefined + for (const entry of registry.children.values()) { + if (entry._tag !== "Started") { + continue + } + if (first === undefined) { + first = entry.ref.stop + } else { + rest ??= [first] + rest.push(entry.ref.stop) + } + } + if (finalizers !== undefined) { + if (first === undefined) { + first = finalizers + } else { + rest ??= [first] + rest.push(finalizers) + } + } + const cleanup = rest ?? first + return cleanup === undefined + ? Effect.void + : Array.isArray(cleanup) + ? Effect.all(cleanup, { concurrency: "unbounded", discard: true }) + : cleanup + }) + + const unregister = ( + key: ChildKey, + token: symbol + ): Effect.Effect => Effect.sync(() => unregisterChild(registry, key, token)) + + const register = ( + key: ChildKey, + token: symbol, + ref: MachineRef, + descriptor: ChildDescriptor | undefined + ): Effect.Effect => Effect.sync(() => registerChild(registry, key, token, ref, descriptor)) + + const get: ChildRuntime["get"] = (child) => { + const id = typeof child === "string" ? child : child.id + return Effect.sync(() => { + if (registry.closed) { + return Option.none() + } + const entry = registry.children.get(id) + return entry !== undefined && matchesChild(entry, child) + ? Option.some(entry.ref) + : Option.none() + }) + } + + const changes: ChildRuntime["changes"] = (child) => { + const id = typeof child === "string" ? child : child.id + return Stream.fromChannel( + Channel.fromTransform((_, streamScope) => + Effect.sync((): ChildObserver => ({ child, id, values: undefined, waiter: undefined })).pipe( + Effect.flatMap((observer) => { + const removeObserver = Effect.sync(() => { + if (registry.observers !== undefined) { + registry.observers.delete(observer) + if (registry.observers.size === 0) { + registry.observers = undefined + } + } + observer.values = undefined + observer.waiter = undefined + }) + return Scope.addFinalizer(streamScope, removeObserver).pipe( + Effect.andThen( + Effect.sync(() => { + if (!registry.closed && streamScope.state._tag !== "Closed") { + registry.observers ??= new Set() + registry.observers.add(observer) + } + offerChildObservation(observer, selectRegistryChild(registry, id, child)) + }) + ), + Effect.as(takeChildObservations(observer)) + ) + }) + ) + ) + ) + } + + const sendTo = ( + child: ChildSelector, + event: unknown, + source?: Inspection.Subject, + causedBy?: Inspection.Causation + ): Effect.Effect => { + const id = typeof child === "string" ? child : child.id + return Effect.suspend(() => { + if (registry.closed) { + return Effect.void + } + const entry = registry.children.get(id) + return entry !== undefined && matchesChild(entry, child) + ? sendMachineTarget(entry.ref, event, source, causedBy) + : Effect.void + }) + } + + const stop = (child: ChildSelector): Effect.Effect => { + const id = typeof child === "string" ? child : child.id + return Effect.suspend(() => { + if (registry.closed) { + return Effect.void + } + const entry = registry.children.get(id) + return entry !== undefined && matchesChild(entry, child) + ? entry.ref.stop + : Effect.void + }) + } + + function spawn( + child: Child & ChildMachine.Executable & ChildMachine.ParentCompatibility, + ...options: ChildMachine.SpawnArgs + ): Effect.Effect< + ChildMachine.Ref, + ChildAlreadyExistsError | ChildMachine.StartError, + ChildMachine.StartRequirements + > + function spawn( + logic: ProcessLogic + ): Effect.Effect< + MachineRef, + ChildInitialError, + Exclude + > + function spawn( + logic: ProcessLogic, + spawnOptions: { + readonly id: string + readonly descriptor?: ChildDescriptor + readonly onOutcome?: ( + outcome: RuntimeOutcome + ) => Effect.Effect + readonly [activeSnapshotObserver]?: ( + snapshot: Extract, { readonly status: "active" }> + ) => Effect.Effect + readonly [sendParentOverride]?: (event: unknown) => Effect.Effect + } + ): Effect.Effect< + MachineRef, + ChildAlreadyExistsError | ChildInitialError, + Exclude + > + function spawn( + logicOrChild: ProcessLogic | ChildMachine.Any, + options?: { + readonly id: string + readonly descriptor?: ChildDescriptor + readonly onOutcome?: ( + outcome: RuntimeOutcome + ) => Effect.Effect + readonly [activeSnapshotObserver]?: ( + snapshot: Extract, { readonly status: "active" }> + ) => Effect.Effect + readonly [sendParentOverride]?: (event: unknown) => Effect.Effect + } | { readonly input?: unknown } + ): Effect.Effect, any, any> { + const descriptor = typeof logicOrChild === "object" && logicOrChild !== null && + ChildMachineLogicTypeId in logicOrChild + ? logicOrChild as ChildMachine.Any + : undefined + const logic = descriptor === undefined + ? logicOrChild as ProcessLogic + : descriptor[ChildMachineLogicTypeId]( + (options as { readonly input?: unknown } | undefined)?.input + ) as unknown as ProcessLogic + const spawnOptions = descriptor === undefined + ? options as { + readonly id: string + readonly descriptor?: ChildDescriptor + readonly onOutcome?: (outcome: RuntimeOutcome) => Effect.Effect + readonly [activeSnapshotObserver]?: ( + snapshot: Extract, { readonly status: "active" }> + ) => Effect.Effect + readonly [sendParentOverride]?: (event: unknown) => Effect.Effect + } | undefined + : { id: descriptor.id, descriptor } + const token = Symbol() + const key = spawnOptions?.id ?? token + let startedChild: MachineRef | undefined + return Effect.suspend(() => { + if (registry.closed) { + return Effect.interrupt + } + if (typeof key === "string" && registry.children.has(key)) { + return Effect.fail(new ChildAlreadyExistsError({ id: key })) + } + registry.scope ??= Scope.makeUnsafe("parallel") + registry.children.set(key, { _tag: "Starting", token }) + return runtime.start(logic, { + detached: true, + ...(spawnOptions?.id === undefined ? undefined : { id: spawnOptions.id }), + ...(spawnOptions?.onOutcome === undefined ? undefined : { onOutcome: spawnOptions.onOutcome }), + ...(spawnOptions?.[activeSnapshotObserver] === undefined + ? undefined + : { onSnapshot: spawnOptions[activeSnapshotObserver] }), + ...(spawnOptions?.[sendParentOverride] === undefined + ? undefined + : { sendParent: spawnOptions[sendParentOverride] }), + onReady: (child, requestChildStop) => + Effect.sync(() => { + startedChild = child + }).pipe( + Effect.andThen(register(key, token, child, spawnOptions?.descriptor)), + Effect.flatMap((registered) => registered ? Effect.void : requestChildStop) + ), + onStop: unregister(key, token), + parent: self, + runtime, + origin: { _tag: "Spawn", address: spawnOptions?.id } + }).pipe( + Effect.onExit((exit) => + Exit.isFailure(exit) + ? unregister(key, token).pipe( + Effect.andThen(startedChild === undefined ? Effect.void : startedChild.stop) + ) + : Effect.void + ), + Scope.provide(registry.scope) + ) + }) + } + + return { + close, + spawn, + get, + changes, + sendTo, + stop, + owned: new OwnedChildRuntimeImpl(registry, self, runtime, services, sendAcknowledged) + } +} + +export const makeChildRuntime = ( + self: ProcessAddress, + runtime: ProcessRuntime, + services?: Context.Context, + sendAcknowledged?: ( + event: unknown + ) => Effect.Effect, unknown | StoppedError> +): Effect.Effect => Effect.sync(() => makeChildRuntimeSync(self, runtime, services, sendAcknowledged)) + +export type StartProcess = < + State, + Event, + Error = never, + Requirements = never, + Output = never, + InitialError = never +>( + logic: ProcessLogic, + options: StartInternalOptions +) => Effect.Effect< + MachineRef, + InitialError, + Requirements +> diff --git a/packages/effect-machine/src/internal/machine/topology.ts b/packages/effect-machine/src/internal/machine/topology.ts index 6a18005..a569fbb 100644 --- a/packages/effect-machine/src/internal/machine/topology.ts +++ b/packages/effect-machine/src/internal/machine/topology.ts @@ -8,6 +8,7 @@ import * as Option from "effect/Option" import { hasProperty } from "effect/Predicate" import * as Schema from "effect/Schema" import type { Machine } from "../../Machine.js" +import { type CapturedStateConfig, toImpl } from "./implementation.js" export const TargetTypeId = "~effect/Machine/Target" @@ -506,7 +507,7 @@ export const transitionDefinitions = ( ): ReadonlyArray => { const definitions: Array = [] for (const node of machine.stateNodes.byPath.values()) { - const config = machine.handlers[node.path] as Machine.AnyStateConfig | undefined + const config = toImpl(machine).handlers[node.path] as CapturedStateConfig | undefined if (config === undefined) { continue } diff --git a/packages/effect-machine/src/internal/testing/machine/probe.ts b/packages/effect-machine/src/internal/testing/machine/probe.ts index ab5448c..b8106ee 100644 --- a/packages/effect-machine/src/internal/testing/machine/probe.ts +++ b/packages/effect-machine/src/internal/testing/machine/probe.ts @@ -8,7 +8,7 @@ import * as Data from "effect/Data" import * as Effect from "effect/Effect" import type * as Machine from "../../../Machine.js" import type { Probe, ProbePlan } from "../../../testing/MachineTest.js" -import * as Runtime from "../../machine/runtime.js" +import * as Runtime from "../../machine/runtimeProtocol.js" type AnyMachine = Machine.Machine.Any diff --git a/packages/effect-machine/src/unstable/cluster/ClusterMachine.ts b/packages/effect-machine/src/unstable/cluster/ClusterMachine.ts index 90d1fb1..cfeac2f 100644 --- a/packages/effect-machine/src/unstable/cluster/ClusterMachine.ts +++ b/packages/effect-machine/src/unstable/cluster/ClusterMachine.ts @@ -6,11 +6,12 @@ import type * as Effect from "effect/Effect" import type * as Layer from "effect/Layer" import type * as Option from "effect/Option" -import * as Schema from "effect/Schema" +import type * as Schema from "effect/Schema" import type { Entity, MessageStorage, Sharding, Snowflake } from "effect/unstable/cluster" import type { Rpc } from "effect/unstable/rpc" import * as internal from "../../internal/machine/cluster.js" -import { Accepted, Rejected, Storage } from "../../internal/machine/cluster.js" +import * as Protocol from "../../internal/machine/clusterProtocol.js" +import { Accepted, Rejected, Storage } from "../../internal/machine/clusterProtocol.js" import type { EnsureExecutable } from "../../internal/machine/readiness.js" import type { ExcludeCompatibleRuntime } from "../../internal/machine/requirements.js" import type * as Machine from "../../Machine.js" @@ -77,10 +78,10 @@ export type CommitResult = CommitResult.Committed | CommitResult.Duplicate * @category models * @since 0.4.0 */ -export const CommitResult = { - Committed: (): CommitResult.Committed => ({ _tag: "Committed" }), - Duplicate: (): CommitResult.Duplicate => ({ _tag: "Duplicate" }) -} +export const CommitResult: { + Committed: () => CommitResult.Committed + Duplicate: () => CommitResult.Duplicate +} = Protocol.CommitResult /** * Types for Cluster machine commit results. @@ -148,16 +149,7 @@ export { Accepted } * @category models * @since 0.4.0 */ -export const RejectionReason = Schema.Literals([ - "MachineIdMismatch", - "VersionMismatch", - "InvalidCheckpoint", - "UnsupportedProcessLocal", - "TransitionFailure", - "SnapshotEncodeFailure", - "PersistenceFailure", - "EmissionFailure" -]) +export const RejectionReason: typeof Protocol.RejectionReason = Protocol.RejectionReason /** * Type of {@link RejectionReason}. @@ -182,7 +174,7 @@ export { Rejected } * @category schemas * @since 0.4.0 */ -export const SendResult = Schema.Union([Accepted, Rejected]) +export const SendResult: typeof Protocol.SendResult = Protocol.SendResult type SendRpc> = Rpc.Rpc< "send", diff --git a/packages/effect-machine/test/examples/guard.ts b/packages/effect-machine/test/examples/guard.ts new file mode 100644 index 0000000..9b48362 --- /dev/null +++ b/packages/effect-machine/test/examples/guard.ts @@ -0,0 +1,16 @@ +import { Machine } from "@typeonce/effect-machine" +import { Schema } from "effect" + +const events = Machine.events({ Add: { by: Schema.Number } }) +export const counter = Machine.make({ + root: Machine.state({ fields: { count: Schema.Number } }), + events, + initial: (root) => root.from(() => ({ count: 0 })) +}).handle({ + on: { + Add: (to) => + to.self.update.guard(({ event }) => event.by > 0).from(({ current, event }) => ({ + count: current.count + event.by + })) + } +}) diff --git a/packages/effect-machine/test/internal/machine/processLifecycle.test.ts b/packages/effect-machine/test/internal/machine/processLifecycle.test.ts index 9e018a5..4120e59 100644 --- a/packages/effect-machine/test/internal/machine/processLifecycle.test.ts +++ b/packages/effect-machine/test/internal/machine/processLifecycle.test.ts @@ -2,6 +2,7 @@ import { assert, describe, it } from "@effect/vitest" import { Cause, Deferred, Effect, Exit, Fiber, Option, Ref, Stream } from "effect" import { Machine } from "../../../src/index.js" import * as MachineRuntime from "../../../src/internal/machine/runtime.js" +import * as MachineRuntimeProtocol from "../../../src/internal/machine/runtimeProtocol.js" describe("machine process lifecycle", () => { it.effect("reuses a settled active startup without running an empty compiled drain", () => @@ -222,7 +223,7 @@ describe("machine process lifecycle", () => { it.effect("provides empty child operations for childless process logic", () => Effect.gen(function*() { - const processScope = yield* Deferred.make>() + const processScope = yield* Deferred.make>() const ref = yield* MachineRuntime.startProcess({ execution: { _tag: "Childless" }, initial: (scope) => Deferred.succeed(processScope, scope).pipe(Effect.as(0)), @@ -506,9 +507,9 @@ describe("machine process lifecycle", () => { it.effect("interrupts the worker before publishing an externally requested failure", () => Effect.gen(function*() { - const runtime = yield* Deferred.make>() + const runtime = yield* Deferred.make>() const cleanupCount = yield* Ref.make(0) - const logic: MachineRuntime.ProcessLogic = { + const logic: MachineRuntimeProtocol.ProcessLogic = { initial: (scope) => Deferred.succeed(runtime, scope).pipe(Effect.as(1)), run: () => Effect.never.pipe( @@ -690,7 +691,7 @@ describe("machine process lifecycle", () => { Array.from({ length: 50 }), () => Effect.gen(function*() { - const parentScope = yield* Deferred.make>() + const parentScope = yield* Deferred.make>() const parent = yield* MachineRuntime.startProcess({ initial: (scope) => Deferred.succeed(parentScope, scope).pipe(Effect.as(undefined)), run: () => Effect.never @@ -723,7 +724,7 @@ describe("machine process lifecycle", () => { it.effect("publishes every named child replacement in order", () => Effect.gen(function*() { - const parentScope = yield* Deferred.make>() + const parentScope = yield* Deferred.make>() const parent = yield* MachineRuntime.startProcess({ initial: (scope) => Deferred.succeed(parentScope, scope).pipe(Effect.as(undefined)), run: () => Effect.never @@ -756,7 +757,7 @@ describe("machine process lifecycle", () => { it.effect("preserves registry-wide childChanges emission ticks", () => Effect.gen(function*() { - const parentScope = yield* Deferred.make>() + const parentScope = yield* Deferred.make>() const parent = yield* MachineRuntime.startProcess({ initial: (scope) => Deferred.succeed(parentScope, scope).pipe(Effect.as(undefined)), run: () => Effect.never @@ -784,7 +785,7 @@ describe("machine process lifecycle", () => { it.effect("buffers ordered childChanges while a subscriber is stalled", () => Effect.gen(function*() { - const parentScope = yield* Deferred.make>() + const parentScope = yield* Deferred.make>() const parent = yield* MachineRuntime.startProcess({ initial: (scope) => Deferred.succeed(parentScope, scope).pipe(Effect.as(undefined)), run: () => Effect.never @@ -868,7 +869,7 @@ describe("machine process lifecycle", () => { it.effect("keeps child interruption parallel with scoped resource finalization", () => Effect.gen(function*() { - const parentScope = yield* Deferred.make>() + const parentScope = yield* Deferred.make>() const childInterrupted = yield* Deferred.make() const resourceFinalized = yield* Deferred.make() const release = yield* Deferred.make() @@ -909,11 +910,11 @@ describe("machine process lifecycle", () => { it.effect("does not orphan a child when parent stop races child initialization", () => Effect.gen(function*() { - const parentScope = yield* Deferred.make>() + const parentScope = yield* Deferred.make>() const childInitializing = yield* Deferred.make() const releaseChild = yield* Deferred.make() const resourceCleanup = yield* Ref.make(0) - const parentLogic: MachineRuntime.ProcessLogic = { + const parentLogic: MachineRuntimeProtocol.ProcessLogic = { initial: (scope) => Deferred.succeed(parentScope, scope).pipe(Effect.as(undefined)), run: () => Effect.never } @@ -954,9 +955,9 @@ describe("machine process lifecycle", () => { Array.from({ length: 50 }), () => Effect.gen(function*() { - const parentScope = yield* Deferred.make>() + const parentScope = yield* Deferred.make>() const race = yield* Deferred.make() - const parentLogic: MachineRuntime.ProcessLogic = { + const parentLogic: MachineRuntimeProtocol.ProcessLogic = { initial: (scope) => Deferred.succeed(parentScope, scope).pipe(Effect.as(undefined)), run: () => Effect.never } @@ -999,16 +1000,17 @@ describe("machine process lifecycle", () => { it.effect("delivers done, failure, and stopped child outcomes exactly once", () => Effect.gen(function*() { - const parentScope = yield* Deferred.make>() + const parentScope = yield* Deferred.make>() const outcomes = yield* Ref.make>([]) - const parentLogic: MachineRuntime.ProcessLogic = { + const parentLogic: MachineRuntimeProtocol.ProcessLogic = { initial: (scope) => Deferred.succeed(parentScope, scope).pipe(Effect.as(undefined)), run: () => Effect.never } const parent = yield* MachineRuntime.startProcess(parentLogic) const scope = yield* Deferred.await(parentScope) - const recordOutcome = (id: string) => (outcome: MachineRuntime.RuntimeOutcome) => - Ref.update(outcomes, (current) => [...current, `${id}:${outcome._tag}`]) + const recordOutcome = + (id: string) => (outcome: MachineRuntimeProtocol.RuntimeOutcome) => + Ref.update(outcomes, (current) => [...current, `${id}:${outcome._tag}`]) const done = yield* scope.spawn( Machine.logic({ initial: 0, run: () => Effect.succeed("output") }), @@ -1038,8 +1040,8 @@ describe("machine process lifecycle", () => { it.effect("isolates child terminalization from outcome callback defects", () => Effect.gen(function*() { - const parentScope = yield* Deferred.make>() - const parentLogic: MachineRuntime.ProcessLogic = { + const parentScope = yield* Deferred.make>() + const parentLogic: MachineRuntimeProtocol.ProcessLogic = { initial: (scope) => Deferred.succeed(parentScope, scope).pipe(Effect.as(undefined)), run: () => Effect.never } @@ -1061,7 +1063,7 @@ describe("machine process lifecycle", () => { it.effect("delivers committed active child snapshots directly and in order", () => Effect.gen(function*() { - const parentScope = yield* Deferred.make>() + const parentScope = yield* Deferred.make>() const snapshots = yield* Ref.make>([]) const parent = yield* MachineRuntime.startProcess({ initial: (scope) => Deferred.succeed(parentScope, scope).pipe(Effect.as(undefined)), @@ -1078,7 +1080,7 @@ describe("machine process lifecycle", () => { }), { id: "child", - [MachineRuntime.activeSnapshotObserver]: (snapshot) => + [MachineRuntimeProtocol.activeSnapshotObserver]: (snapshot) => Ref.update(snapshots, (current) => [...current, snapshot.state]) } ) @@ -1090,7 +1092,7 @@ describe("machine process lifecycle", () => { it.effect("isolates active child state updates from snapshot callback defects", () => Effect.gen(function*() { - const parentScope = yield* Deferred.make>() + const parentScope = yield* Deferred.make>() const parent = yield* MachineRuntime.startProcess({ initial: (scope) => Deferred.succeed(parentScope, scope).pipe(Effect.as(undefined)), run: () => Effect.never @@ -1100,7 +1102,7 @@ describe("machine process lifecycle", () => { initial: 0, run: ({ setState }) => setState(1).pipe(Effect.as("output")) }), - { id: "child", [MachineRuntime.activeSnapshotObserver]: () => Effect.die("callback defect") } + { id: "child", [MachineRuntimeProtocol.activeSnapshotObserver]: () => Effect.die("callback defect") } ) assert.strictEqual(yield* child.join, "output") diff --git a/packages/effect-machine/test/machine/SnapshotStructure.test.ts b/packages/effect-machine/test/machine/SnapshotStructure.test.ts new file mode 100644 index 0000000..dd57bff --- /dev/null +++ b/packages/effect-machine/test/machine/SnapshotStructure.test.ts @@ -0,0 +1,37 @@ +import { assert, it } from "@effect/vitest" +import { Effect } from "effect" +import { Machine } from "../../src/index.js" + +it.effect("planning and encoding reject malformed snapshots through typed failures", () => + Effect.gen(function*() { + const machine = Machine.make({ + root: Machine.state({ + type: "parallel", + states: { + Left: { initial: "Ready", states: { Ready: {} } }, + Right: {} + } + }), + events: Machine.events({ Ping: {} }) + }).handle({ states: { Left: { states: { Ready: {} } }, Right: {} } }) + const initial = yield* Machine.planInitial(machine) + const state = initial.state + const malformed: ReadonlyArray = [ + { ...state, value: 1 }, + { ...state, states: undefined }, + { ...state, states: { Right: state.states.Right } }, + { ...state, states: { ...state.states, Left: state.states.Right } }, + { ...state, states: { ...state.states, Left: { ...state.states.Left, state: undefined } } }, + { ...state, states: { ...state.states, Left: { ...state.states.Left, state: state.states.Right } } } + ] + for (const value of malformed) { + // Exercise untyped interop at both boundaries without manufacturing valid snapshots. + const snapshot = value as Machine.Snapshot + const planning = yield* Effect.flip(Machine.plan(machine, snapshot, machine.events.Ping())) + assert.instanceOf(planning, Machine.MachineSchemaDecodeError) + const encoding = yield* Effect.flip(Machine.encodeSnapshot(machine, snapshot)) + assert.instanceOf(encoding, Machine.MachineSchemaEncodeError) + } + const encoded = yield* Machine.encodeSnapshot(machine, state) + assert.deepStrictEqual(yield* Machine.decodeSnapshot(machine, encoded), state) + })) diff --git a/packages/effect-machine/typetest/unstable/cluster/ClusterMachine.tst.ts b/packages/effect-machine/typetest/unstable/cluster/ClusterMachine.tst.ts index e6af686..8a6f0e4 100644 --- a/packages/effect-machine/typetest/unstable/cluster/ClusterMachine.tst.ts +++ b/packages/effect-machine/typetest/unstable/cluster/ClusterMachine.tst.ts @@ -6,6 +6,24 @@ import { Machine } from "../../../src/index.js" import { ClusterMachine } from "../../../src/unstable/cluster/index.js" describe("ClusterMachine", () => { + it("preserves the public checkpoint and wire-schema contracts", () => { + expect(ClusterMachine.CommitResult.Committed()).type.toBe() + expect(ClusterMachine.CommitResult.Duplicate()).type.toBe() + expect>().type.toBe< + ClusterMachine.Accepted | ClusterMachine.Rejected + >() + expect>().type.toBe< + | "MachineIdMismatch" + | "VersionMismatch" + | "InvalidCheckpoint" + | "UnsupportedProcessLocal" + | "TransitionFailure" + | "SnapshotEncodeFailure" + | "PersistenceFailure" + | "EmissionFailure" + >() + }) + class Count extends Schema.TaggedClass("Count")("Count", { value: Schema.Number }) {} diff --git a/scripts/api-reference/examples.test.mjs b/scripts/api-reference/examples.test.mjs index 718ab51..477277d 100644 --- a/scripts/api-reference/examples.test.mjs +++ b/scripts/api-reference/examples.test.mjs @@ -4,14 +4,17 @@ import { test } from "node:test" // These fixtures also compile under tsconfig.tests.json. Keep documentation // examples identical to the checked consumer code rather than copying by hand. -test("Machine.can documents the compile-checked internal-event example", () => { +for (const [declarationText, fixture] of [ + ["export const can:", "can"], + ["export interface Machine<", "guard"] +]) test(`Machine documents the compile-checked ${fixture} example`, () => { const source = readFileSync(new URL("../../packages/effect-machine/src/Machine.ts", import.meta.url), "utf8") - const declaration = source.indexOf("export const can:") + const declaration = source.indexOf(declarationText) assert.ok(declaration >= 0) const comment = source.slice(source.lastIndexOf("/**", declaration), declaration) const example = comment.match(/```ts\n([\s\S]*?)\n \* ```/) assert.ok(example) const actual = example[1].split("\n").map((line) => line.replace(/^ \* ?/, "")).join("\n") - const expected = readFileSync(new URL("../../packages/effect-machine/test/examples/can.ts", import.meta.url), "utf8") + const expected = readFileSync(new URL(`../../packages/effect-machine/test/examples/${fixture}.ts`, import.meta.url), "utf8") assert.equal(actual.trim(), expected.trim()) }) diff --git a/scripts/check-architecture.mjs b/scripts/check-architecture.mjs index ce8859d..4137cfa 100644 --- a/scripts/check-architecture.mjs +++ b/scripts/check-architecture.mjs @@ -207,6 +207,12 @@ export const checkArchitecture = ({ const program = readProject(root, tsconfigPath) const edges = collectEdges(program, root) const runtimeEdges = edges.filter((edge) => !edge.typeOnly) + const runtimeModules = new Set([ + "src/internal/machine/runtime.ts", + "src/internal/machine/runtimeProtocol.ts", + "src/internal/machine/runtimeGeneric.ts", + "src/internal/machine/runtimeCompiled.ts" + ]) const diagnostics = [] const entrypoints = new Set([ "src/index.ts", @@ -221,7 +227,10 @@ export const checkArchitecture = ({ "runtimeInvariant", "exploration", "probe", "trace", "format" ].map((name) => `src/internal/testing/machine/${name}.ts`))], ["src/unstable/reactivity/AtomMachine.ts", new Set(["src/internal/machine/atom.ts"])], - ["src/unstable/cluster/ClusterMachine.ts", new Set(["src/internal/machine/cluster.ts"])] + ["src/unstable/cluster/ClusterMachine.ts", new Set([ + "src/internal/machine/cluster.ts", + "src/internal/machine/clusterProtocol.ts" + ])] ]) const forbiddenSemanticDependencies = new Map([ ["src/internal/machine/topology.ts", new Set([ @@ -290,6 +299,12 @@ export const checkArchitecture = ({ ])] ]) + for (const forbidden of forbiddenSemanticDependencies.values()) { + if (forbidden.has("src/internal/machine/runtime.ts")) { + for (const module of runtimeModules) forbidden.add(module) + } + } + for (const edge of edges) { if (entrypoints.has(edge.source) && edge.target.includes("/internal/")) { diagnostics.push(diagnostic( @@ -336,7 +351,7 @@ export const checkArchitecture = ({ "src/internal/machine/executionPlan.ts", "src/internal/machine/invocation.ts", "src/internal/machine/process.ts", - "src/internal/machine/runtime.ts" + ...runtimeModules ].includes(edge.target) ) { diagnostics.push(diagnostic( @@ -357,7 +372,7 @@ export const checkArchitecture = ({ )) } if ( - edge.source === "src/internal/machine/runtime.ts" && + runtimeModules.has(edge.source) && [ "src/internal/machine/configuration.ts", "src/internal/machine/executionPlan.ts", diff --git a/scripts/check-architecture.test.mjs b/scripts/check-architecture.test.mjs index 369cc8d..b411979 100644 --- a/scripts/check-architecture.test.mjs +++ b/scripts/check-architecture.test.mjs @@ -110,6 +110,17 @@ test("rejects outward machine semantic dependencies", () => { assert.deepEqual(rules(root), ["ARCH005", "ARCH005"]) }) +test("enforces semantic boundaries for each runtime strategy and shared protocol", () => { + for (const module of ["runtimeProtocol", "runtimeGeneric", "runtimeCompiled"]) { + const root = makeProject({ + "src/internal/machine/planner.ts": `import { run } from "./${module}.js"\nexport const plan = run`, + [`src/internal/machine/${module}.ts`]: 'import type { value } from "./process.js"\nexport const run: typeof value = 1', + "src/internal/machine/process.ts": "export const value = 1" + }) + assert.deepEqual(new Set(rules(root)), new Set(["ARCH004", "ARCH005", "ARCH006"])) + } +}) + test("detects runtime cycles while permitting type-only cycles", () => { const cyclic = makeProject({ "src/a.ts": 'import { b } from "./b.js"\nexport const a = b', From 210ff508cce10e0b7f5a193395a922a6140e2d5b Mon Sep 17 00:00:00 2001 From: SandroMaglione Date: Sun, 6 Sep 2026 20:48:25 +0200 Subject: [PATCH 2/2] Share runtime startup hooks without per-root allocations --- .../src/internal/machine/runtime.ts | 8 +++-- .../src/internal/machine/runtimeProtocol.ts | 29 ++++++++++++++----- .../machine/strategyDifferential.test.ts | 18 ++++++++++++ 3 files changed, 44 insertions(+), 11 deletions(-) diff --git a/packages/effect-machine/src/internal/machine/runtime.ts b/packages/effect-machine/src/internal/machine/runtime.ts index b4b1aa2..ea1f71f 100644 --- a/packages/effect-machine/src/internal/machine/runtime.ts +++ b/packages/effect-machine/src/internal/machine/runtime.ts @@ -24,6 +24,8 @@ const startLogicInternal: StartProcess = (( ? startCompactCompiledInternal(logic, options) : startGenericInternal(logic, options)) as StartProcess +const makeRuntime = makeProcessRuntime(startLogicInternal, startCompiledSync) + export type ProcessRuntimeStrategy = "generic" | "compiled" | "auto" const startProcessWithStrategy = Effect.fnUntraced(function*( @@ -33,7 +35,7 @@ const startProcessWithStrategy = Effect.fnUntraced(function*( readonly id?: string } ) { - const runtime = yield* makeProcessRuntime(startLogicInternal, startCompiledSync) + const runtime = yield* makeRuntime const internalOptions: StartInternalOptions = options === undefined ? { detached: true, @@ -98,7 +100,7 @@ export const startProcess: < readonly id?: string } ) { - const runtime = yield* makeProcessRuntime(startLogicInternal, startCompiledSync) + const runtime = yield* makeRuntime return yield* startLogicInternal( logic, options === undefined @@ -129,7 +131,7 @@ const prepareProcessWithStrategy = Effect.fnUntraced(function*< readonly id?: string } ) { - const runtime = yield* makeProcessRuntime(startLogicInternal, startCompiledSync) + const runtime = yield* makeRuntime const sessionId = yield* runtime.nextSessionId const inspection = yield* InspectionRuntime.make(sessionId) runtime.inspection = inspection diff --git a/packages/effect-machine/src/internal/machine/runtimeProtocol.ts b/packages/effect-machine/src/internal/machine/runtimeProtocol.ts index 3a294dc..6585b4e 100644 --- a/packages/effect-machine/src/internal/machine/runtimeProtocol.ts +++ b/packages/effect-machine/src/internal/machine/runtimeProtocol.ts @@ -511,15 +511,28 @@ export interface ProcessRuntime { export const makeProcessRuntime = ( start: StartProcess, startCompiledSync: ProcessRuntime["startCompiledSync"] -): Effect.Effect => - Effect.sync(() => { - let sessionIdCounter = 0 - return { - start, - startCompiledSync, - nextSessionId: Effect.sync(() => `machine:${sessionIdCounter++}`) +): Effect.Effect => { + // Strategy hooks belong to the shared prototype. Each root owns only its + // session counter and optional inspection runtime, independent of other roots. + class Runtime implements ProcessRuntime { + declare inspection?: InspectionRuntime.Runtime + readonly nextSessionId: Effect.Effect + + constructor() { + let sessionIdCounter = 0 + this.nextSessionId = Effect.sync(() => `machine:${sessionIdCounter++}`) } - }) + + get start(): StartProcess { + return start + } + + get startCompiledSync(): ProcessRuntime["startCompiledSync"] { + return startCompiledSync + } + } + return Effect.sync(() => new Runtime()) +} export const inspectionSubject = ( logic: ProcessLogic, diff --git a/packages/effect-machine/test/internal/machine/strategyDifferential.test.ts b/packages/effect-machine/test/internal/machine/strategyDifferential.test.ts index aaf303e..2604c74 100644 --- a/packages/effect-machine/test/internal/machine/strategyDifferential.test.ts +++ b/packages/effect-machine/test/internal/machine/strategyDifferential.test.ts @@ -58,6 +58,24 @@ const makeFlatMachine = () => { } describe("machine planner and runtime strategies", () => { + for (const strategy of ["generic", "compiled"] as const) { + it.effect(`keeps independent root lifecycles isolated with the ${strategy} runtime`, () => + Effect.gen(function*() { + const machine = makeFlatMachine() + const first = yield* openWithRuntimeStrategy(machine, strategy) + const second = yield* openWithRuntimeStrategy(machine, strategy) + yield* first.send(new Increment({})) + yield* first.stop + yield* second.send(new Increment({})) + yield* second.send(new Finish({})) + assert.strictEqual(yield* second.join, 1) + assert.strictEqual((yield* first.snapshot).status, "stopped") + const third = yield* openWithRuntimeStrategy(machine, strategy) + yield* third.send(new Finish({})) + assert.strictEqual(yield* third.join, 0) + })) + } + it.effect("matches generic and indexed-flat planning including targetless and reentering transitions", () => verifyPlannerStrategies({ machine: makeFlatMachine(),