From 3dde3fd05a165231de686c19ce0ec730760a14a2 Mon Sep 17 00:00:00 2001 From: SandroMaglione Date: Fri, 23 Oct 2026 18:36:10 +0900 Subject: [PATCH] Stop a child from its own invocation's turn without hanging Since 0.45.0 an idle machine's worker starts in the sender's turn. When a child's invocation completed, its onDone ran inside the invocation fiber, and a parent that stopped the child in response requested an interrupt there. The invocation fiber then waited for the child's worker, which was waiting for the invocation fiber to exit. The interruption now runs in its own fiber, while whoever requested the stop awaits completion. Co-Authored-By: Claude Opus 5.5 --- .../child-stopped-from-invocation-turn.md | 7 ++ .../src/internal/machine/runtimeCompiled.ts | 6 +- .../test/machine/ChildStoppedByParent.test.ts | 89 +++++++++++++++++++ 3 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 .changeset/child-stopped-from-invocation-turn.md create mode 100644 packages/effect-machine/test/machine/ChildStoppedByParent.test.ts 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"))) + } + } +})