diff --git a/.changeset/child-stopped-from-invocation-turn.md b/.changeset/child-stopped-from-invocation-turn.md new file mode 100644 index 00000000..8184f059 --- /dev/null +++ b/.changeset/child-stopped-from-invocation-turn.md @@ -0,0 +1,7 @@ +--- +"@typeonce/effect-machine": patch +--- + +Fix a machine that hung when a child's invocation `onDone` sent an event to its parent and the parent's transition stopped that child. + +The parent now leaves the state and stops the child, and the child's scope closes normally. diff --git a/packages/effect-machine/src/internal/machine/runtimeCompiled.ts b/packages/effect-machine/src/internal/machine/runtimeCompiled.ts index ff7a1952..c40cee85 100644 --- a/packages/effect-machine/src/internal/machine/runtimeCompiled.ts +++ b/packages/effect-machine/src/internal/machine/runtimeCompiled.ts @@ -644,7 +644,11 @@ class CompiledProcess implements MachineRef { this.interruptRequested = false return true }).pipe( - Effect.flatMap((interrupt) => interrupt ? this.interruptAndFinish(fiber) : Effect.void) + // The worker started in the sender's turn and may be waiting for + // the sender to finish, such as an invocation its transition + // interrupts, so the sender must not wait for the interruption. + // Whoever requested the stop already awaits completion. + Effect.flatMap((interrupt) => interrupt ? Effect.forkDetach(this.interruptAndFinish(fiber)) : Effect.void) ) ), Effect.asVoid diff --git a/packages/effect-machine/test/machine/ChildStoppedByParent.test.ts b/packages/effect-machine/test/machine/ChildStoppedByParent.test.ts new file mode 100644 index 00000000..4702e3cf --- /dev/null +++ b/packages/effect-machine/test/machine/ChildStoppedByParent.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "@effect/vitest" +import { Effect, Option, Schema } from "effect" +import { Machine } from "../../src/index.js" +import { openWithRuntimeStrategy } from "../internal/machine/support/strategyDifferential.js" + +const ParentEvents = Machine.events({ Finished: {} }) + +const ChildStates = Machine.state({ + fields: { id: Schema.String }, + states: { Idle: {}, Working: {}, Done: {} } +}) +const ChildEvents = Machine.events({ Go: {} }) + +const makeChild = (work: Effect.Effect) => + Machine.make({ + id: "child", + root: ChildStates, + events: ChildEvents, + input: Schema.Struct({ id: Schema.String }), + parent: Machine.parent(ParentEvents), + effects: { work: (_: unknown) => work }, + branches: { finished: { done: { target: "Done" } } } + }).handle({ + root: ({ input }) => input, + initial: { target: "Idle" }, + states: { + Idle: { on: { Go: { target: "Working" } } }, + Working: { + invoke: { + src: "work", + input: ({ root }) => root, + onDone: { + branches: "finished", + resolve: ({ parent, select }, enqueue) => { + enqueue.sendTo(parent, ParentEvents.Finished()) + return select.done() + } + } + } + }, + Done: {} + } + }) + +const ParentStates = Machine.state({ states: { Hosting: {}, Left: {} } }) + +const makeParent = (work: Effect.Effect) => { + const Child = Machine.child("child", makeChild(work)) + return { + Child, + machine: Machine.make({ + root: ParentStates, + events: ParentEvents, + children: { child: Child } + }).handle({ + initial: { target: "Hosting" }, + states: { + Hosting: { + invoke: { src: "child", input: () => ({ id: "a" }) }, + on: { Finished: { target: "Left" } } + }, + Left: {} + } + }) + } +} + +// The child's worker starts in the turn of the invocation that completes, so +// stopping the child from that turn must not wait for the invocation. +describe("a child invocation's onDone notifies a parent that stops the child", () => { + for ( + const [name, work] of [ + ["synchronous", Effect.void], + ["asynchronous", Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 10)))] + ] as const + ) { + for (const strategy of ["generic", "compiled"] as const) { + it.live(`${name} invocation, ${strategy} parent`, () => + Effect.gen(function*() { + const { Child, machine: definition } = makeParent(work) + const machine = yield* openWithRuntimeStrategy(definition, strategy) + const child = Option.getOrThrow(yield* machine.child(Child)) + yield* child.send(ChildEvents.Go()) + yield* Machine.waitFor(machine, (snapshot) => ParentStates.matches(snapshot.state, "Left")) + expect(Option.isNone(yield* machine.child(Child))).toBe(true) + }).pipe(Effect.scoped, Effect.timeout("2 seconds"))) + } + } +})