diff --git a/.changeset/invoke-error-channels-and-diagnostics.md b/.changeset/invoke-error-channels-and-diagnostics.md new file mode 100644 index 0000000..323342b --- /dev/null +++ b/.changeset/invoke-error-channels-and-diagnostics.md @@ -0,0 +1,9 @@ +--- +"@typeonce/effect-machine": minor +--- + +`ChildAlreadyExistsError` now appears in a machine's error type only for invocations whose child id you choose: logic invocations with an `address` and `Machine.child` invocations. Effect, Stream, and timer invocations run under runtime-owned ids and cannot collide, so a machine that only invokes them no longer carries `ChildAlreadyExistsError` in `Machine.Error`. Invocation ids within one state must be distinct; a repeated source without distinct `id`s or a reused `id` is now a type error and is rejected when the machine is constructed. + +A parent that invokes a fixed child through `children` now needs `onFailure` exactly when the child's own error type is reachable. Remove `onFailure: { none: true }` handlers that only swallowed `ChildAlreadyExistsError`; they are now rejected as unreachable, like an unreachable `onDone`. + +Constraint failures now explain themselves instead of reporting `never`. Starting, resuming, preparing, or planning a machine with a required parent reports `Machine declares a required parent and cannot run as a root`, and an unreachable `onDone`, `onFailure`, `onElement`, or `onSnapshot` names the channel and source, for example `Invocation 'forever' never completes, so onDone is unreachable`. diff --git a/packages/devtools/src/internal/browser/parallel-owner-routing-example.ts b/packages/devtools/src/internal/browser/parallel-owner-routing-example.ts index 89513b3..eb452ff 100644 --- a/packages/devtools/src/internal/browser/parallel-owner-routing-example.ts +++ b/packages/devtools/src/internal/browser/parallel-owner-routing-example.ts @@ -105,16 +105,14 @@ export const parallelOwnerRoutingMachine = Machine.make({ states: { Print: { initial: { Options: ({}) => ({}), Operation: ({}) => ({}) }, - invoke: { - src: "source1", - onFailure: { target: "Print.Operation.Active", decoded: true, data: () => (new Active({})) } - }, + invoke: { src: "source1" }, on: { PrintRequested: { target: "Print.Operation.Printing", decoded: true, data: () => (new Printing({})) - } + }, + ChildSignal: { target: "Print.Operation.Active", decoded: true, data: () => (new Active({})) } }, states: { Options: { diff --git a/packages/devtools/src/internal/browser/protocol-events-example.ts b/packages/devtools/src/internal/browser/protocol-events-example.ts index 3a7f5b8..a2ad284 100644 --- a/packages/devtools/src/internal/browser/protocol-events-example.ts +++ b/packages/devtools/src/internal/browser/protocol-events-example.ts @@ -192,11 +192,6 @@ export const parentProtocolMachine = Machine.make({ target: "Complete", decoded: true, data: ({ output }) => (new ParentComplete({ result: output })) - }, - onFailure: { - target: "Failed", - decoded: true, - data: ({ error }) => (new ParentFailed({ message: String(error) })) } }, on: { diff --git a/packages/devtools/test/internal/browser/StaticChart.test.ts b/packages/devtools/test/internal/browser/StaticChart.test.ts index 07dc67d..1c8e0d7 100644 --- a/packages/devtools/test/internal/browser/StaticChart.test.ts +++ b/packages/devtools/test/internal/browser/StaticChart.test.ts @@ -404,7 +404,7 @@ describe("Static chart", () => { transitions.map(({ label, target }) => ({ label, target })), [ { label: "PrintRequested", target: "Print.Operation.Printing" }, - { label: "parallel-owner-child", target: "Print.Operation.Active" } + { label: "ChildSignal", target: "Print.Operation.Active" } ] ) assert.isTrue(transitions.every(isParentChildTransition)) diff --git a/packages/effect-machine/README.md b/packages/effect-machine/README.md index 751f7a2..b33ac2e 100644 --- a/packages/effect-machine/README.md +++ b/packages/effect-machine/README.md @@ -387,9 +387,16 @@ const invocations = [ Include each handler demanded by the concrete program's type. Use an explicit `id` for simultaneous instances of one source; effect, stream, and timer IDs -default to `src`. Logic requires a lifecycle `id` and a typed `Machine.childAddress`. -A child uses its `Machine.child` descriptor's identity and protocol. All -invocations are owned by their state and cancelled when it exits. +default to `src`. Invocation IDs within one state must be distinct. Logic +requires a lifecycle `id` and a typed `Machine.childAddress`. A child uses its +`Machine.child` descriptor's identity and protocol. All invocations are owned by +their state and cancelled when it exits. + +Effect, Stream, and timer invocations run under runtime-owned child IDs, so +they never fail with `ChildAlreadyExistsError`. Logic addresses and child +descriptor IDs are chosen by you: starting one while another active child uses +the same ID fails the owning machine with `ChildAlreadyExistsError`. A parent's +`onFailure` for an invoked child receives only the child's own typed failures. Effect services flow through the used sources and lifecycle effects into the machine's requirements. Unused registrations add no requirements. Supply them diff --git a/packages/effect-machine/src/Machine.ts b/packages/effect-machine/src/Machine.ts index 97fdbc3..c18b05a 100644 --- a/packages/effect-machine/src/Machine.ts +++ b/packages/effect-machine/src/Machine.ts @@ -674,6 +674,20 @@ type StateDefinitionError< readonly details: Details } +type InvocationDefinitionError< + Message extends string, + Source extends string, + Field extends PropertyKey +> = { + readonly "~effect/Machine/InvocationDefinitionError": Message + readonly source: Source + readonly field: Field +} + +type RootCompatibilityError = { + readonly "~effect/Machine/RootCompatibilityError": Message +} + type ActiveStateKey = Machine.ActiveStateKey type HistoryStateKey = Machine.HistoryStateKey @@ -2579,9 +2593,23 @@ export declare namespace Machine { infer Mode ? Mode extends ParentMode ? Parent : undefined : never - /** Constraint applied to APIs that create an independent root runtime. */ + /** + * Constraint applied to APIs that create an independent root runtime. + * + * **Details** + * + * A machine without a parent protocol, or with an optional one, is + * accepted. A machine that declares a required parent can only run as an + * owned child, so the constraint resolves to a diagnostic that names the + * reason instead of an unexplained `never`. + * + * @category utility types + * @since 0.27.0 + */ export type RootCompatible> = ParentModeOf extends "required" - ? never + ? RootCompatibilityError< + "Machine declares a required parent and cannot run as a root; invoke or spawn it from an owning machine" + > : unknown /** @@ -5291,15 +5319,30 @@ export declare namespace Machine { : Effect.Error> type InvokeOutcomeServices = IsAny> extends true ? never : Effect.Services> + // Effect, Stream, and timer invocations run under a runtime-reserved child id + // derived from their state path and distinct lifecycle id. Only caller-chosen + // child ids (logic addresses and child descriptor ids) can collide. + type InvokeCollisionError = Invoke extends { readonly logic: unknown } | { readonly child: unknown } + ? ChildAlreadyExistsError + : never /** * Extracts the parent transition error contribution from invoked children. * + * **Details** + * + * The contribution contains the startup and lifecycle-handler failures of + * every invocation. `ChildAlreadyExistsError` is included only for + * invocations whose child id is chosen by the caller, namely logic + * invocations with an `address` and child machine invocations, which fail to + * start while another child with that id is active. Effect, Stream, and timer + * invocations run under runtime-reserved ids and cannot collide. + * * @category utility types * @since 0.4.0 */ export type InvokeError = [InvokeReturn] extends [never] ? never : - | ChildAlreadyExistsError + | InvokeCollisionError> | InvokeInitialError> | InvokeOutcomeError> /** @@ -7413,17 +7456,49 @@ export declare namespace Machine { : "onElement" : never) | (SourceKind extends "logic" | "children" ? "onSnapshot" : never) - type ValidateObjectInvoke = I extends ReadonlyArray ? - { readonly [K in keyof I]: ValidateObjectInvoke } : - I extends { - /** Name of the source registered in make. */ - readonly src: infer Src extends PropertyKey - } ? { - readonly [K in keyof I]: K extends AllowedInvokeChannels ? ValidateObjectTransition - : K extends "src" | "id" | "address" | "input" ? unknown - : never - } : + type UnreachableInvokeChannelMessage = Channel extends "onDone" + ? `Invocation '${Src}' never completes, so onDone is unreachable` + : Channel extends "onFailure" ? `Invocation '${Src}' cannot fail, so onFailure is unreachable` + : Channel extends "onElement" ? `Invocation '${Src}' emits no Stream elements, so onElement is unreachable` + : `Invocation '${Src}' has no snapshots; onSnapshot applies only to logic and child invocations` + type InvokeFieldError = K extends "onDone" | "onFailure" | "onElement" | "onSnapshot" + ? InvocationDefinitionError, Src, K> + : K extends string ? InvocationDefinitionError<`Invocation '${Src}' does not accept the field '${K}'`, Src, K> + : never + // Explicit ids or the registered source name for programs; descriptor ids for child machines. + type InvokeLifecycleIdOf = I extends { readonly id: infer Id extends string } ? Id + : I extends { readonly src: infer Src extends PropertyKey } ? + SourceValue extends ChildMachine ? Id : Extract + : never + type LiteralInvokeIdAt, K, R> = K extends keyof I + ? InvokeLifecycleIdOf extends infer Id extends string ? string extends Id ? never : Id : never + : never + type DuplicateInvokeId, K, R> = LiteralInvokeIdAt extends infer Id ? + [Id] extends [never] ? never + : Id extends LiteralInvokeIdAt, K>, R> ? Id + : never + : never + type ValidateInvokeEntry = I extends { + /** Name of the source registered in make. */ + readonly src: infer Src extends PropertyKey + } ? { + readonly [K in keyof I]: [Duplicate] extends [never] ? ValidateInvokeField> + : K extends ("id" extends keyof I ? "id" : "src") ? InvocationDefinitionError< + `Invocation id '${Duplicate}' is declared more than once in this state`, + Extract, + K + > + : ValidateInvokeField> + } : never + type ValidateInvokeField = K extends AllowedInvokeChannels + ? ValidateObjectTransition + : K extends "src" | "id" | "address" | "input" ? unknown + : InvokeFieldError + type ValidateObjectInvoke = I extends ReadonlyArray ? + number extends I["length"] ? { readonly [K in keyof I]: ValidateInvokeEntry } + : { readonly [K in keyof I]: ValidateInvokeEntry> } : + ValidateInvokeEntry type ObjectDeclarations = | Extract | (C extends { readonly states: infer Children } diff --git a/packages/effect-machine/src/internal/machine/childRegistry.ts b/packages/effect-machine/src/internal/machine/childRegistry.ts index 38be386..cf31a74 100644 --- a/packages/effect-machine/src/internal/machine/childRegistry.ts +++ b/packages/effect-machine/src/internal/machine/childRegistry.ts @@ -28,6 +28,15 @@ export type ChildEntry = ownerActive?: boolean } +/** + * Prefix of the child ids the runtime assigns to Effect, Stream, and timer + * invocations. Caller-chosen ids in this namespace are always treated as taken, + * so runtime-owned invocation ids can never collide with another child. + */ +export const invocationChildIdPrefix = "Machine.invocation:" + +export const isReservedChildId = (id: string): boolean => id.startsWith(invocationChildIdPrefix) + export type ChildSelector = string | ChildDescriptor export type ChildKey = string | symbol diff --git a/packages/effect-machine/src/internal/machine/invocation.ts b/packages/effect-machine/src/internal/machine/invocation.ts index 6cee5f2..3e80911 100644 --- a/packages/effect-machine/src/internal/machine/invocation.ts +++ b/packages/effect-machine/src/internal/machine/invocation.ts @@ -8,8 +8,9 @@ import * as Cause from "effect/Cause" import * as Effect from "effect/Effect" import * as Stream from "effect/Stream" import type { ChildMachine, ChildOwner, Inspection, Logic, Machine } from "../../Machine.js" +import * as ChildRegistry from "./childRegistry.js" import * as Configuration from "./configuration.js" -import { InfiniteTransitionError, MachineSchemaDecodeError, StoppedError } from "./errors.js" +import { ChildAlreadyExistsError, InfiniteTransitionError, MachineSchemaDecodeError, StoppedError } from "./errors.js" import * as InvocationDefinition from "./invocationDefinition.js" import * as InvocationEvent from "./invocationEvent.js" import * as Planner from "./planner.js" @@ -33,7 +34,8 @@ export interface AnyConfig { export const makeKey = (path: string, id: string): string => `${path.length}:${path}${id}` /** @internal */ -export const makeChildId = (path: string, id: string): string => `Machine.invocation:${makeKey(path, id)}` +export const makeChildId = (path: string, id: string): string => + `${ChildRegistry.invocationChildIdPrefix}${makeKey(path, id)}` const oneShot = (effect: Effect.Effect): Logic => ({ initial: () => Effect.void, @@ -272,6 +274,15 @@ const startResolved = ( }) }) +// Logic addresses and child descriptor ids are caller-chosen. Keep them out of +// the namespace reserved for runtime-owned invocation ids; runtime-owned ids +// skip this check. +const reservedChildIdFailure = ( + childId: string, + invokeId: string +): Effect.Effect | undefined => + ChildRegistry.isReservedChildId(childId) ? Effect.fail(new ChildAlreadyExistsError({ id: invokeId })) : undefined + const start = ( scope: Runtime.ProcessScope, ownedChildren: Runtime.OwnedChildRuntime, @@ -279,6 +290,8 @@ const start = ( config: AnyConfig ): Effect.Effect => { const invokeId = String(config.id) + const reserved = config.address === undefined ? undefined : reservedChildIdFailure(String(config.address), invokeId) + if (reserved !== undefined) return reserved return startResolved( scope, ownedChildren, @@ -305,6 +318,8 @@ const startStaticChild = ( // source function so each running parent does not retain a resolved config // object and an otherwise redundant source closure. const descriptor = raw.child + const reserved = reservedChildIdFailure(descriptor.id, descriptor.id) + if (reserved !== undefined) return reserved return startResolved( scope, ownedChildren, diff --git a/packages/effect-machine/src/internal/machine/invocationDefinition.ts b/packages/effect-machine/src/internal/machine/invocationDefinition.ts index c621cf9..0b6d7d6 100644 --- a/packages/effect-machine/src/internal/machine/invocationDefinition.ts +++ b/packages/effect-machine/src/internal/machine/invocationDefinition.ts @@ -44,6 +44,25 @@ export const capture = (config: Record, path: string): Inv return Object.freeze(config) as unknown as InvocationDefinition } +/** Lifecycle id that routes an invocation's outcomes within its owning state. */ +export const lifecycleId = (definition: InvocationDefinition): string => + "child" in definition ? definition.child.id : definition.id + +/** + * Rejects one state's invocations when two share a lifecycle id. Distinct ids + * keep runtime-owned invocation child ids collision-free. + */ +export const assertDistinctIds = (definitions: ReadonlyArray, path: string): void => { + const seen = new Set() + for (const definition of definitions) { + const id = lifecycleId(definition) + if (seen.has(id)) { + throw new Error(`Machine invocations for state "${path}" must have distinct ids; "${id}" is declared twice`) + } + seen.add(id) + } +} + /** Only captured state handlers cross this erased boundary. */ export const definitions = (invoke: unknown): ReadonlyArray => invoke === undefined diff --git a/packages/effect-machine/src/internal/machine/machine.ts b/packages/effect-machine/src/internal/machine/machine.ts index c984916..429a1b3 100644 --- a/packages/effect-machine/src/internal/machine/machine.ts +++ b/packages/effect-machine/src/internal/machine/machine.ts @@ -632,6 +632,7 @@ const captureInvokeDefinition = ( } return InvocationDefinition.capture(captured, path) }) + InvocationDefinition.assertDistinctIds(capturedDefinitions, path) if (Array.isArray(invoke)) return Object.freeze(capturedDefinitions) return capturedDefinitions[0] } diff --git a/packages/effect-machine/src/internal/machine/runtimeProtocol.ts b/packages/effect-machine/src/internal/machine/runtimeProtocol.ts index 6585b4e..6155252 100644 --- a/packages/effect-machine/src/internal/machine/runtimeProtocol.ts +++ b/packages/effect-machine/src/internal/machine/runtimeProtocol.ts @@ -24,6 +24,7 @@ import { type ChildObserver, type ChildRegistry, type ChildSelector, + isReservedChildId, matchesChild, offerChildObservation, registerChild, @@ -1137,7 +1138,7 @@ export const makeChildRuntimeSync = ( if (registry.closed) { return Effect.interrupt } - if (typeof key === "string" && registry.children.has(key)) { + if (typeof key === "string" && (registry.children.has(key) || isReservedChildId(key))) { return Effect.fail(new ChildAlreadyExistsError({ id: key })) } registry.scope ??= Scope.makeUnsafe("parallel") diff --git a/packages/effect-machine/test/internal/machine/strategyDifferential.test.ts b/packages/effect-machine/test/internal/machine/strategyDifferential.test.ts index 8d16fbe..6a03255 100644 --- a/packages/effect-machine/test/internal/machine/strategyDifferential.test.ts +++ b/packages/effect-machine/test/internal/machine/strategyDifferential.test.ts @@ -1289,7 +1289,7 @@ describe("machine planner and runtime strategies", () => { }, states: { ParentWaiting: { - invoke: { src: "source1", onFailure: { none: true } }, + invoke: { src: "source1" }, on: { ChildReady: { target: "ParentDone", decoded: true, data: () => (new ParentDone({})) } } diff --git a/packages/effect-machine/test/machine/InvocationIds.test.ts b/packages/effect-machine/test/machine/InvocationIds.test.ts new file mode 100644 index 0000000..ca1059b --- /dev/null +++ b/packages/effect-machine/test/machine/InvocationIds.test.ts @@ -0,0 +1,275 @@ +import { assert, describe, it } from "@effect/vitest" +import { Deferred, Effect, Option, Queue, Schema, Stream } from "effect" +import { Machine } from "../../src/index.js" + +class Squatting extends Schema.TaggedClass("InvocationIdsSquatting")("Squatting", {}) {} +class Running extends Schema.TaggedClass("InvocationIdsRunning")("Running", { + rejected: Schema.String +}) {} +class Done extends Schema.TaggedClass("InvocationIdsDone")("Done", {}) {} +class Restart extends Schema.TaggedClass("InvocationIdsRestart")("Restart", {}) {} +class Root extends Schema.TaggedClass("InvocationIdsRoot")("Root", {}) {} +class Left extends Schema.TaggedClass("InvocationIdsLeft")("Left", {}) {} +class Right extends Schema.TaggedClass("InvocationIdsRight")("Right", {}) {} +class Waiting extends Schema.TaggedClass("InvocationIdsWaiting")("Waiting", {}) {} +class Finished extends Schema.TaggedClass("InvocationIdsFinished")("Finished", {}) {} +class ChildIdle extends Schema.TaggedClass("InvocationIdsChildIdle")("ChildIdle", {}) {} + +const childMachine = Machine.make({ + root: Machine.state({ states: { ChildIdle } }), + events: Machine.eventsFromSchemas() +}).handle({ + initial: { target: "ChildIdle" }, + states: { ChildIdle: {} } +}) + +const ParallelRegions = Machine.state({ + states: { + Root: { + schema: Root, + type: "parallel", + states: { + left: { schema: Left, states: { waiting: Waiting, finished: { schema: Finished, type: "final" } } }, + right: { schema: Right, states: { waiting: Waiting, finished: { schema: Finished, type: "final" } } } + } + }, + Done: { schema: Done, type: "final", output: Schema.String } + } +}) + +const parallelRegionsInitial = { + target: "Root", + decoded: true, + data: new Root({}) +} as const + +const regionsInitial = { + left: { decoded: true, data: new Left({}) }, + right: { decoded: true, data: new Right({}) } +} as const + +describe("invocation ids", () => { + it.effect("reserves runtime-owned invocation ids from caller-chosen child ids", () => + Effect.gen(function*() { + const Squatter = Machine.childFamily(childMachine) + const gate = yield* Deferred.make() + // The id the runtime assigns to the "work" Effect invocation of the "Running" state. + const reservedId = "Machine.invocation:7:Runningwork" + const root = Machine.state({ + states: { Squatting, Running, Done: { schema: Done, type: "final", output: Schema.String } } + }) + const machine = Machine.make({ + effects: { + squat: ({ children }: Machine.Machine.InvokeContext< + { readonly "": typeof root.node }, + readonly [], + readonly [], + "Squatting", + readonly [], + readonly [] + >) => children.spawn(Squatter(reservedId)), + work: Deferred.await(gate) + }, + root, + events: Machine.eventsFromSchemas() + }).handle({ + initial: { target: "Squatting", decoded: true, data: new Squatting({}) }, + states: { + Squatting: { + invoke: { + src: "squat", + input: (context) => context, + onDone: { target: "Running", decoded: true, data: () => new Running({ rejected: "none" }) }, + onFailure: { + target: "Running", + decoded: true, + data: ({ error }) => + new Running({ + rejected: error._tag === "ChildAlreadyExistsError" ? `${error._tag}:${error.id}` : error._tag + }) + } + } + }, + Running: { + invoke: { + src: "work", + onDone: { target: "Done" } + } + }, + Done: { output: () => "done" } + } + }) + const actor = yield* Machine.start(machine) + const running = yield* actor.changes.pipe( + Stream.filter((snapshot) => snapshot.status === "active" && snapshot.state.state.path === "Running"), + Stream.runHead + ) + assert.deepStrictEqual( + Option.map(running, (snapshot) => snapshot.status === "active" ? snapshot.state.state.value : undefined), + Option.some(new Running({ rejected: `ChildAlreadyExistsError:${reservedId}` })) + ) + assert.isTrue(Option.isNone(yield* actor.child(Squatter(reservedId)))) + yield* Deferred.succeed(gate, undefined) + assert.strictEqual(yield* actor.join, "done") + })) + + it.effect("keeps runtime-owned ids distinct across parallel regions and reentry", () => + Effect.gen(function*() { + const started = yield* Queue.unbounded() + const gate = yield* Deferred.make() + const machine = Machine.make({ + effects: { + work: Queue.offer(started, "work").pipe(Effect.andThen(Deferred.await(gate))) + }, + root: ParallelRegions, + events: Machine.eventsFromSchemas(Restart) + }).handle({ + initial: parallelRegionsInitial, + states: { + Root: { + initial: regionsInitial, + onDone: { target: "Done" }, + states: { + left: { + initial: { target: "Root.left.waiting", decoded: true, data: new Waiting({}) }, + states: { + waiting: { + invoke: { src: "work", onDone: { target: "Root.left.finished" } }, + on: { + Restart: { + target: "Root.left.waiting", + reenter: true, + decoded: true, + data: () => new Waiting({}) + } + } + }, + finished: {} + } + }, + right: { + initial: { target: "Root.right.waiting", decoded: true, data: new Waiting({}) }, + states: { + waiting: { invoke: { src: "work", onDone: { target: "Root.right.finished" } } }, + finished: {} + } + } + } + }, + Done: { output: () => "done" } + } + }) + const actor = yield* Machine.start(machine) + // Both regions run the same source under the same lifecycle id at once. + assert.deepStrictEqual([yield* Queue.take(started), yield* Queue.take(started)], ["work", "work"]) + // Reentering restarts the invocation under the same runtime-owned id. + for (let i = 0; i < 3; i++) { + yield* actor.send(new Restart({})) + assert.strictEqual(yield* Queue.take(started), "work") + } + yield* Deferred.succeed(gate, undefined) + assert.strictEqual(yield* actor.join, "done") + })) + + it.effect("rejects child machine invocations that collide across parallel regions", () => + Effect.gen(function*() { + const Worker = Machine.child("worker", childMachine) + const machine = Machine.make({ + children: { worker: Worker }, + root: ParallelRegions, + events: Machine.eventsFromSchemas() + }).handle({ + initial: parallelRegionsInitial, + states: { + Root: { + initial: regionsInitial, + onDone: { target: "Done" }, + states: { + left: { + initial: { target: "Root.left.waiting", decoded: true, data: new Waiting({}) }, + states: { waiting: { invoke: { src: "worker" } }, finished: {} } + }, + right: { + initial: { target: "Root.right.waiting", decoded: true, data: new Waiting({}) }, + states: { waiting: { invoke: { src: "worker" } }, finished: {} } + } + } + }, + Done: { output: () => "done" } + } + }) + const actor = yield* Machine.start(machine) + const error = yield* Effect.flip(actor.join) + assert.instanceOf(error, Machine.ChildAlreadyExistsError) + assert.strictEqual(error.id, "worker") + })) + + it.effect("rejects addressed logic invocations that collide across parallel regions", () => + Effect.gen(function*() { + const Shared = Machine.childAddress("shared") + const machine = Machine.make({ + logic: { blocked: Machine.logic({ initial: undefined, run: () => Effect.never }) }, + root: ParallelRegions, + events: Machine.eventsFromSchemas() + }).handle({ + initial: parallelRegionsInitial, + states: { + Root: { + initial: regionsInitial, + onDone: { target: "Done" }, + states: { + left: { + initial: { target: "Root.left.waiting", decoded: true, data: new Waiting({}) }, + states: { + waiting: { invoke: { src: "blocked", id: "left", address: Shared } }, + finished: {} + } + }, + right: { + initial: { target: "Root.right.waiting", decoded: true, data: new Waiting({}) }, + states: { + waiting: { invoke: { src: "blocked", id: "right", address: Shared } }, + finished: {} + } + } + } + }, + Done: { output: () => "done" } + } + }) + const actor = yield* Machine.start(machine) + const error = yield* Effect.flip(actor.join) + assert.instanceOf(error, Machine.ChildAlreadyExistsError) + assert.strictEqual(error.id, "right") + })) + + it("rejects one state's invocations that share a lifecycle id", () => { + const Worker = Machine.child("worker", childMachine) + const root = Machine.state({ states: { Running, Done } }) + const handle = (invoke: ReadonlyArray) => () => + Machine.make({ + effects: { first: Effect.never, second: Effect.never }, + logic: { blocked: Machine.logic({ initial: undefined, run: () => Effect.never }) }, + children: { worker: Worker, alias: Worker }, + root, + events: Machine.eventsFromSchemas() + }).handle({ + initial: { target: "Running", decoded: true, data: new Running({ rejected: "none" }) }, + states: { Running: { invoke: invoke as any }, Done: {} } + }) + assert.throws(handle([{ src: "first" }, { src: "first" }]), /distinct ids; "first" is declared twice/) + assert.throws( + handle([{ src: "first", id: "load" }, { src: "second", id: "load" }]), + /distinct ids; "load" is declared twice/ + ) + assert.throws( + handle([ + { src: "blocked", id: "load", address: Machine.childAddress("a") }, + { src: "first", id: "load" } + ]), + /distinct ids; "load" is declared twice/ + ) + assert.throws(handle([{ src: "worker" }, { src: "alias" }]), /distinct ids; "worker" is declared twice/) + assert.doesNotThrow(handle([{ src: "first" }, { src: "second" }, { src: "worker" }])) + }) +}) diff --git a/packages/effect-machine/test/machine/Machine.test.ts b/packages/effect-machine/test/machine/Machine.test.ts index 0d72599..8c1a000 100644 --- a/packages/effect-machine/test/machine/Machine.test.ts +++ b/packages/effect-machine/test/machine/Machine.test.ts @@ -6106,81 +6106,80 @@ describe("Machine", () => { }) yield* Effect.all([first.stop, second.stop], { concurrency: "unbounded" }) })) - it.effect("child invocation rejects duplicate active child addresses", () => - Effect.gen(function*() { - const childStates = Machine.state({ states: { Idle } }) - const child = Machine.make({ - root: childStates, - events: Machine.eventsFromSchemas() - }).handle({ - initial: { - target: "Idle", - decoded: true, - data: new Idle({ userId: "child" }) - }, - states: { - Idle: {} - } - }) - const Child = Machine.child("child-machine", child) - const parentStates = Machine.state({ states: { Loading } }) - const parent = Machine.make({ - children: { source1: Child, source2: Child }, - root: parentStates, - events: Machine.eventsFromSchemas() - }).handle({ - initial: { - target: "Loading", - decoded: true, - data: new Loading({ requestId: "request-1" }) - }, - states: { - Loading: { - invoke: [{ src: "source1" }, { src: "source2" }] - } - } - }) - const actor = yield* Machine.start(parent) - const error = yield* Effect.flip(actor.join) - assert.instanceOf(error, Machine.ChildAlreadyExistsError) - })) - it.effect("invokes reject duplicate lifecycle ids even when addresses differ", () => - Effect.gen(function*() { - const First = Machine.childAddress("first") - const Second = Machine.childAddress("second") - let sourceEvaluations = 0 - const source = (_input: undefined) => { - sourceEvaluations += 1 - return Machine.logic({ initial: undefined, run: () => Effect.never }) + it("child invocations in one state reject a shared child descriptor id", () => { + const childStates = Machine.state({ states: { Idle } }) + const child = Machine.make({ + root: childStates, + events: Machine.eventsFromSchemas() + }).handle({ + initial: { + target: "Idle", + decoded: true, + data: new Idle({ userId: "child" }) + }, + states: { + Idle: {} } - const parentStates = Machine.state({ states: { Loading } }) - const parent = Machine.make({ - logic: { source1: source, source2: source }, - root: parentStates, - events: Machine.eventsFromSchemas() - }).handle({ - initial: { - target: "Loading", - decoded: true, - data: new Loading({ requestId: "request-1" }) - }, - states: { - Loading: { - invoke: [{ src: "source1", id: "worker", address: First, input: () => undefined }, { - src: "source2", - id: "worker", - address: Second, - input: () => undefined - }] + }) + const Child = Machine.child("child-machine", child) + const parentStates = Machine.state({ states: { Loading } }) + assert.throws( + () => + Machine.make({ + children: { source1: Child, source2: Child }, + root: parentStates, + events: Machine.eventsFromSchemas() + }).handle({ + initial: { + target: "Loading", + decoded: true, + data: new Loading({ requestId: "request-1" }) + }, + states: { + Loading: { + invoke: [{ src: "source1" }, { src: "source2" }] as any + } } - } - }) - const actor = yield* Machine.start(parent) - const error = yield* Effect.flip(actor.join) - assert.instanceOf(error, Machine.ChildAlreadyExistsError) - assert.strictEqual(error.id, "worker") - assert.strictEqual(sourceEvaluations, 1) - })) + }), + /distinct ids; "child-machine" is declared twice/ + ) + }) + it("invokes reject duplicate lifecycle ids even when addresses differ", () => { + const First = Machine.childAddress("first") + const Second = Machine.childAddress("second") + let sourceEvaluations = 0 + const source = (_input: undefined) => { + sourceEvaluations += 1 + return Machine.logic({ initial: undefined, run: () => Effect.never }) + } + const parentStates = Machine.state({ states: { Loading } }) + assert.throws( + () => + Machine.make({ + logic: { source1: source, source2: source }, + root: parentStates, + events: Machine.eventsFromSchemas() + }).handle({ + initial: { + target: "Loading", + decoded: true, + data: new Loading({ requestId: "request-1" }) + }, + states: { + Loading: { + invoke: [{ src: "source1", id: "worker", address: First, input: () => undefined }, { + src: "source2", + id: "worker", + address: Second, + input: () => undefined + }] as any + } + } + }), + /distinct ids; "worker" is declared twice/ + ) + assert.strictEqual(sourceEvaluations, 0) + }) it.effect("start maps invoked child failures to machine events", () => Effect.gen(function*() { const error = new InvokeError({ message: "boom" }) diff --git a/packages/effect-machine/test/machine/MachineReferences.test.ts b/packages/effect-machine/test/machine/MachineReferences.test.ts index 28e268f..0990f9f 100644 --- a/packages/effect-machine/test/machine/MachineReferences.test.ts +++ b/packages/effect-machine/test/machine/MachineReferences.test.ts @@ -404,7 +404,7 @@ describe("machine reference event channels", () => { }, states: { ParentWaiting: { - invoke: { src: "source1", onFailure: { none: true } }, + invoke: { src: "source1" }, on: { ChildReady: { target: "ParentDone", decoded: true, data: () => (new ParentDone({})) } } diff --git a/packages/effect-machine/typetest/machine/InvocationErrors.tst.ts b/packages/effect-machine/typetest/machine/InvocationErrors.tst.ts new file mode 100644 index 0000000..91cc0bc --- /dev/null +++ b/packages/effect-machine/typetest/machine/InvocationErrors.tst.ts @@ -0,0 +1,202 @@ +import { Effect, Schema, Stream } from "effect" +import { describe, expect, it } from "tstyche" +import { Machine } from "../../src/index.js" +import { AtomMachine } from "../../src/unstable/reactivity/index.js" + +class Watching extends Schema.TaggedClass("InvocationErrorsWatching")("Watching", {}) {} +class Loading extends Schema.TaggedClass("InvocationErrorsLoading")("Loading", {}) {} +class Ready extends Schema.TaggedClass("InvocationErrorsReady")("Ready", {}) {} +class Owning extends Schema.TaggedClass("InvocationErrorsOwning")("Owning", {}) {} +class Waiting extends Schema.TaggedClass("InvocationErrorsWaiting")("Waiting", {}) {} +class Next extends Schema.TaggedClass("InvocationErrorsNext")("Next", {}) {} +class Notice extends Schema.TaggedClass("InvocationErrorsNotice")("Notice", {}) {} + +type FrameworkError = Machine.InfiniteTransitionError | Machine.MachineSchemaDecodeError | Machine.StoppedError + +const watcher = Machine.make({ + streams: { ticks: Stream.make(1, 2, 3) }, + root: Machine.state({ states: { Watching } }), + events: Machine.eventsFromSchemas() +}).handle({ + initial: { target: "Watching" }, + states: { + Watching: { invoke: { src: "ticks", onElement: { none: true }, onDone: { none: true } } } + } +}) + +const loader = Machine.make({ + effects: { load: Effect.succeed(1) }, + timers: { delay: "1 second" }, + root: Machine.state({ states: { Loading, Ready } }), + events: Machine.eventsFromSchemas() +}).handle({ + initial: { target: "Loading" }, + states: { + Loading: { invoke: [{ src: "load", onDone: { target: "Ready" } }, { src: "delay", onDone: { none: true } }] }, + Ready: {} + } +}) + +const ChildStates = Machine.state({ states: { Owning } }) +const ParentStates = Machine.state({ states: { Owning, Next } }) + +const owner = Machine.make({ + children: { loader: Machine.child("loader", loader) }, + root: ChildStates, + events: Machine.eventsFromSchemas() +}).handle({ + initial: { target: "Owning" }, + states: { Owning: { invoke: { src: "loader" } } } +}) + +describe("invocation error channels", () => { + it("omits ChildAlreadyExistsError for runtime-owned invocation ids", () => { + expect>().type.toBe() + expect>().type.toBe() + Effect.gen(function*() { + const ref = yield* Machine.start(watcher) + expect>().type.toBe() + }) + }) + + it("keeps ChildAlreadyExistsError for caller-chosen child ids", () => { + expect().type.toBeAssignableTo>() + const addressed = Machine.make({ + logic: { blocked: Machine.logic({ initial: undefined, run: () => Effect.never }) }, + root: ChildStates, + events: Machine.eventsFromSchemas() + }).handle({ + initial: { target: "Owning" }, + states: { Owning: { invoke: { src: "blocked", id: "worker", address: Machine.childAddress("worker") } } } + }) + expect().type.toBeAssignableTo>() + }) + + it("requires onFailure for a fixed child exactly when the child can fail", () => { + const parent = Machine.make({ + children: { loader: Machine.child("loader", loader), owner: Machine.child("owner", owner) }, + root: ParentStates, + events: Machine.eventsFromSchemas() + }) + expect(parent.handle({ + initial: { target: "Owning" }, + states: { Owning: { invoke: { src: "loader" } }, Next: {} } + })).type.not.toRaiseError() + expect(parent.handle({ + initial: { target: "Owning" }, + states: { Owning: { invoke: { src: "loader", onFailure: { target: "Next" } } }, Next: {} } + })).type.toRaiseError("Invocation 'loader' cannot fail, so onFailure is unreachable") + // The owner's own child invocation can collide, so its failure channel is reachable. + expect(parent.handle({ + initial: { target: "Owning" }, + states: { Owning: { invoke: { src: "owner" } }, Next: {} } + })).type.toRaiseError("Property 'onFailure' is missing") + expect(parent.handle({ + initial: { target: "Owning" }, + states: { Owning: { invoke: { src: "owner", onFailure: { target: "Next" } } }, Next: {} } + })).type.not.toRaiseError() + }) +}) + +describe("invocation diagnostics", () => { + const machine = Machine.make({ + effects: { forever: Effect.never, load: Effect.succeed(1) }, + streams: { ticks: Stream.make(1) }, + root: Machine.state({ states: { Waiting, Next } }), + events: Machine.eventsFromSchemas() + }) + + it("names unreachable outcome channels", () => { + expect(machine.handle({ + initial: { target: "Waiting" }, + states: { Waiting: { invoke: { src: "forever", onDone: { target: "Next" } } }, Next: {} } + })).type.toRaiseError("Invocation 'forever' never completes, so onDone is unreachable") + expect(machine.handle({ + initial: { target: "Waiting" }, + states: { Waiting: { invoke: { src: "load", onDone: { target: "Next" }, onFailure: { none: true } } }, Next: {} } + })).type.toRaiseError("Invocation 'load' cannot fail, so onFailure is unreachable") + expect(machine.handle({ + initial: { target: "Waiting" }, + states: { Waiting: { invoke: { src: "load", onDone: { target: "Next" }, onElement: { none: true } } }, Next: {} } + })).type.toRaiseError("Invocation 'load' emits no Stream elements, so onElement is unreachable") + expect(machine.handle({ + initial: { target: "Waiting" }, + states: { + Waiting: { + invoke: { src: "ticks", onElement: { none: true }, onDone: { none: true }, onSnapshot: { none: true } } + }, + Next: {} + } + })).type.toRaiseError("Invocation 'ticks' has no snapshots; onSnapshot applies only to logic and child invocations") + }) + + it("keeps reachable outcome channels accepted", () => { + expect(machine.handle({ + initial: { target: "Waiting" }, + states: { Waiting: { invoke: [{ src: "forever" }, { src: "load", onDone: { target: "Next" } }] }, Next: {} } + })).type.not.toRaiseError() + }) + + it("names duplicate lifecycle ids in one state", () => { + expect(machine.handle({ + initial: { target: "Waiting" }, + states: { Waiting: { invoke: [{ src: "forever" }, { src: "forever" }] }, Next: {} } + })).type.toRaiseError( + "Invocation id 'forever' is declared more than once in this state", + "Invocation id 'forever' is declared more than once in this state" + ) + expect(machine.handle({ + initial: { target: "Waiting" }, + states: { + Waiting: { invoke: [{ src: "forever", id: "work" }, { src: "load", id: "work", onDone: { target: "Next" } }] }, + Next: {} + } + })).type.toRaiseError( + "Invocation id 'work' is declared more than once in this state", + "Invocation id 'work' is declared more than once in this state" + ) + expect(machine.handle({ + initial: { target: "Waiting" }, + states: { + Waiting: { invoke: [{ src: "forever", id: "first" }, { src: "forever", id: "second" }] }, + Next: {} + } + })).type.not.toRaiseError() + }) +}) + +describe("root compatibility diagnostics", () => { + const Notices = Machine.eventsFromSchemas(Notice) + const requiredParent = Machine.make({ + root: Machine.state({ states: { Waiting } }), + events: Machine.eventsFromSchemas(), + parent: Machine.parent(Notices) + }).handle({ + initial: { target: "Waiting" }, + states: { Waiting: {} } + }) + const optionalParent = Machine.make({ + root: Machine.state({ states: { Waiting } }), + events: Machine.eventsFromSchemas(), + parent: Machine.optionalParent(Notices) + }).handle({ + initial: { target: "Waiting" }, + states: { Waiting: {} } + }) + + it("explains why a machine with a required parent cannot run as a root", () => { + expect(Machine.planInitial(requiredParent)).type.toRaiseError( + "Machine declares a required parent and cannot run as a root" + ) + expect(Machine.start(requiredParent)).type.toRaiseError( + "Machine declares a required parent and cannot run as a root" + ) + expect(Machine.prepare(requiredParent)).type.toRaiseError( + "Machine declares a required parent and cannot run as a root" + ) + expect(AtomMachine.make(requiredParent)).type.toRaiseError( + "Machine declares a required parent and cannot run as a root" + ) + expect(Machine.planInitial(optionalParent)).type.not.toRaiseError() + }) +}) diff --git a/packages/effect-machine/typetest/machine/Machine.tst.ts b/packages/effect-machine/typetest/machine/Machine.tst.ts index 822442f..c3fb3c2 100644 --- a/packages/effect-machine/typetest/machine/Machine.tst.ts +++ b/packages/effect-machine/typetest/machine/Machine.tst.ts @@ -1115,7 +1115,7 @@ describe("Machine", () => { } }) expect().type.toBeAssignableTo>() - expect>().type.not.toBe() + expect>().type.toBe() const staticHandled = machine.handle({ initial: { target: "down", @@ -1176,7 +1176,7 @@ describe("Machine", () => { } }) expect().type.toBeAssignableTo>() - expect>().type.not.toBe() + expect>().type.toBe() }) it("requires only reachable Stream handlers", () => { const machine = Machine.make({