diff --git a/.changeset/quiet-machines-wait.md b/.changeset/quiet-machines-wait.md new file mode 100644 index 00000000..4a1de3f8 --- /dev/null +++ b/.changeset/quiet-machines-wait.md @@ -0,0 +1,9 @@ +--- +"@typeonce/effect-machine": minor +--- + +Add `Machine.waitFor(ref, predicate)` for external Effects and tests awaiting a current or subsequent published snapshot. Type predicates narrow the result. Unmatched failures preserve their Cause, stopping fails with `StoppedError`, and completion without a match fails with `Cause.NoSuchElementError`. Compose `Effect.timeout` to bound the wait; cancellation releases observation without stopping the machine. + +Effect, Stream, and timer invocations now run inside `Machine.invoke` spans with machine, state, source, and invocation identity. Use ordinary Effect tracing configuration to export them. This preserves resource ownership and does not propagate each sender's trace context through the mailbox. + +Fix indexed self-transitions and reentry for states without a value schema, so their invocations restart consistently with the generic runtime. diff --git a/packages/effect-machine/README.md b/packages/effect-machine/README.md index 58294c03..71292ec3 100644 --- a/packages/effect-machine/README.md +++ b/packages/effect-machine/README.md @@ -397,6 +397,43 @@ address it from Effects; resolvers use `enqueue.sendTo` and `enqueue.stop`. Duplicate active IDs fail with `ChildAlreadyExistsError` and preserve the existing child. Parent-protocol compatibility is checked at each spawn. +## External observation and tracing + +Use `Machine.waitFor(ref, predicate)` when an external Effect needs a particular +current or subsequent published snapshot. Type predicates narrow the result. +It does not send an event or prove that a particular event caused the snapshot. +Prefer `ref.join` for final output, invocation `onDone` / `onFailure` for workflow +behavior, atom selectors for UI reads, and `ref.changes` for continuous observation. + +```ts +const ready = yield * Machine.waitFor(ref, isReady).pipe(Effect.timeout("5 seconds")) +``` + +The predicate runs first, including for terminal snapshots. An unmatched failure +preserves its `Cause`; an unmatched stop fails with `StoppedError`; completion +without a match fails with `Cause.NoSuchElementError`. A predicate exception is +a defect. Observation is lazy, has no default timeout, and releases its +subscription on completion or interruption without stopping the machine. It does +not replay historical snapshots or expose intermediate macrostep configurations. + +Effect, Stream, and timer invocations run inside an Effect span named +`Machine.invoke`. Application spans inside that work inherit the invocation span. +Configure the application's ordinary Effect tracer and exporter; no Machine-specific +exporter or inspection subscription is required. Standard Effect tracing and +sampling controls apply, including `Effect.withTracerEnabled(false)`. + +Spans identify `machine.id`, `machine.sessionId`, `machine.state.path`, +`machine.invoke.id`, `machine.invoke.source`, `machine.invoke.kind`, and +`machine.invoke.sessionId`. Source is the registered name; invocation IDs can be +overridden independently. Payloads are not recorded automatically. Execution spans +end when the Effect, Stream consumption, or timer settles, including cancellation; +they do not change the lifetime of resources held in an enclosing Scope. Source +construction keeps its existing startup failure boundary. This does not add +machine-lifetime spans or propagate the trace context of each sender through the +mailbox. Tracing can add diagnostic Cause annotations without changing failure +values or interruption semantics. Even without an exporter, tracing has runtime +cost. + ## Reactivity `AtomMachine` runs one lazy machine instance per `AtomRegistry`: diff --git a/packages/effect-machine/docs/agent-guide.md b/packages/effect-machine/docs/agent-guide.md index d3ed1d91..c15fa8eb 100644 --- a/packages/effect-machine/docs/agent-guide.md +++ b/packages/effect-machine/docs/agent-guide.md @@ -474,3 +474,23 @@ const testProgram = Effect.gen(function*() { Use pure planner traces for state and transition rules. Start a live machine and use `MachineTest.probe` when a test depends on timers, invoked work, raised events, or runtime scheduling. + +## Choose observation deliberately + +Use atom selectors to render state, invocation outcomes to express workflow +behavior, `ref.join` to await final output, and `ref.changes` for ongoing +observation. Reach for `Machine.waitFor(ref, predicate)` only when an external +Effect or test must wait for a specific current or subsequent snapshot, such as +a long-lived connection becoming ready. Compose `Effect.timeout` when needed. + +Do not use `send` followed by `waitFor` as an acknowledgement protocol. The current +snapshot might already match, and a match does not identify which event caused +it. Keep workflow steps in the machine. A matching terminal snapshot succeeds; +an unmatched error preserves its Cause, stopping fails with `StoppedError`, and +completion without a match fails with `Cause.NoSuchElementError`. + +Invoked Effects, Streams, and timers already receive a `Machine.invoke` Effect +span with machine, state, source, and invocation identity. Use ordinary Effect +tracing configuration and application spans such as `Effect.fn("Payments.charge")`. +Do not add manual spans around every transition or introduce a second exporter. +Sender trace context is not automatically carried through machine mailboxes. diff --git a/packages/effect-machine/perf/runtime/README.md b/packages/effect-machine/perf/runtime/README.md index 65e73ffc..59bd4617 100644 --- a/packages/effect-machine/perf/runtime/README.md +++ b/packages/effect-machine/perf/runtime/README.md @@ -14,6 +14,7 @@ The command reports: - repeated child lookup and delivery to one running child; - machine start-and-stop throughput; - parent-with-child start-and-stop throughput; +- complete Effect invocation throughput with standard tracing enabled and disabled; - generic and compiled raw-process lifecycle throughput; - heap and resident-memory growth at 100, 500, and 1,000 live units, including a raw generic process, a raw compiled process, an idle statechart, two diff --git a/packages/effect-machine/perf/runtime/counter.mjs b/packages/effect-machine/perf/runtime/counter.mjs index 62261946..f51523ed 100644 --- a/packages/effect-machine/perf/runtime/counter.mjs +++ b/packages/effect-machine/perf/runtime/counter.mjs @@ -26,6 +26,22 @@ if (machineRuntimePath === undefined) { const machineRuntime = await import(pathToFileURL(machineRuntimePath).href) const { Effect, Fiber, Option, Schema, Stream } = effect +const invocationLifecycleMachine = benchmarkApi.effectLifecycle?.(Effect.void, Schema.Void) +const invocationLifecycleBenchmarks = invocationLifecycleMachine === undefined ? [] : [true, false].map((enabled) => ({ + id: `effect-invocation-tracing-${enabled ? "enabled" : "disabled"}`, + label: `Complete an Effect invocation with tracing ${enabled ? "enabled" : "disabled"}`, + unit: "invocations/s", + operations: () => 1, + expected: () => 1, + start: () => undefined, + run: () => Effect.runPromise(Effect.gen(function*() { + const ref = yield* Machine.start(invocationLifecycleMachine) + yield* ref.join + return 1 + }).pipe(Effect.withTracerEnabled(enabled))), + stop: () => undefined +})) + const CounterState = Schema.TaggedUnion({ Count: { value: Schema.Number @@ -616,6 +632,7 @@ export const effectMachineAdapter = { stopObservedCounter, stopCounters, additionalMachineBenchmarks: [ + ...invocationLifecycleBenchmarks, { id: "hierarchical-plan-counter", label: "Plan transitions through a compound state", diff --git a/packages/effect-machine/perf/runtime/effect-machine-compatibility.mjs b/packages/effect-machine/perf/runtime/effect-machine-compatibility.mjs index a4954fd4..6b574d8c 100644 --- a/packages/effect-machine/perf/runtime/effect-machine-compatibility.mjs +++ b/packages/effect-machine/perf/runtime/effect-machine-compatibility.mjs @@ -65,6 +65,17 @@ export const makeEffectMachineBenchmarkApi = (Machine) => { return { snapshot, + effectLifecycle: hasHandlerInitial ? (work, output) => { + const root = Machine.state({ states: { Working: {}, Complete: { type: "final", output } } }) + const targets = Machine.targets(root) + return Machine.make({ root, events: Machine.events({}), effects: { work } }).handle({ + initial: { target: targets.root.Working }, + states: { + Working: { invoke: { src: "work", onDone: { target: targets.root.Complete } } }, + Complete: { output: () => undefined } + } + }) + } : undefined, make: (config) => { if (!hasRoot) return Machine.make(config) const { states: root, initial, ...rest } = config diff --git a/packages/effect-machine/src/Machine.ts b/packages/effect-machine/src/Machine.ts index 8fd276ae..86649b8a 100644 --- a/packages/effect-machine/src/Machine.ts +++ b/packages/effect-machine/src/Machine.ts @@ -9186,6 +9186,53 @@ export const watch: ( ref: MachineRef ) => Stream.Stream> = internal.watch +/** + * Waits for the first current or subsequent published snapshot matching a predicate. + * + * Use for external Effect coordination and tests. Prefer atom selectors for UI + * reads, invocation outcomes for workflow behavior, and `ref.join` for output. + * This observes state; it does not acknowledge or correlate a sent event. + * + * The predicate runs before terminal classification, so explicitly matching a + * done, error, or stopped snapshot succeeds. Otherwise an error preserves its + * Cause, stopping fails with `StoppedError`, and completion without a match + * fails with `Cause.NoSuchElementError`. Predicate exceptions are defects. + * + * Evaluation subscribes lazily. Interruption releases only the subscription, + * never the machine. There is no default timeout; compose `Effect.timeout`. + * Type predicates narrow the returned snapshot. Intermediate microsteps and + * historical snapshots are not observed. + * + * @example + * ```ts + * const snapshot = yield* Machine.waitFor(ref, isReady).pipe(Effect.timeout("5 seconds")) + * ``` + * @category combinators + * @since 0.37.0 + */ +export const waitFor: { + >( + predicate: (snapshot: RuntimeSnapshot) => snapshot is Narrowed + ): (ref: MachineRef) => Effect.Effect< + Narrowed, + Error | StoppedError | Cause.NoSuchElementError + > + ( + predicate: (snapshot: RuntimeSnapshot) => boolean + ): (ref: MachineRef) => Effect.Effect< + RuntimeSnapshot, + Error | StoppedError | Cause.NoSuchElementError + > + >( + ref: MachineRef, + predicate: (snapshot: RuntimeSnapshot) => snapshot is Narrowed + ): Effect.Effect + ( + ref: MachineRef, + predicate: (snapshot: RuntimeSnapshot) => boolean + ): Effect.Effect, Error | StoppedError | Cause.NoSuchElementError> +} = internal.waitFor + /** * Prepares a fresh machine without initializing it. * diff --git a/packages/effect-machine/src/internal/machine/declaration.ts b/packages/effect-machine/src/internal/machine/declaration.ts index 4f03bcad..843e7702 100644 --- a/packages/effect-machine/src/internal/machine/declaration.ts +++ b/packages/effect-machine/src/internal/machine/declaration.ts @@ -131,6 +131,7 @@ export const invocation = ( const field = { effects: "effect", streams: "stream", timers: "after", logic: "logic" }[source.kind] return { ...rest, + sourceName: src, id: rest.id ?? src, [field]: !parameterized && (source.kind === "timers" || source.kind === "logic") ? source.value : resolve } diff --git a/packages/effect-machine/src/internal/machine/executionPlan.ts b/packages/effect-machine/src/internal/machine/executionPlan.ts index 7165045c..6143267d 100644 --- a/packages/effect-machine/src/internal/machine/executionPlan.ts +++ b/packages/effect-machine/src/internal/machine/executionPlan.ts @@ -537,7 +537,8 @@ const normalizeIndexedTargetStateSync = ( const targetIndex = isTarget(target) ? descriptor.indexByPath.get(String(target.path)) : undefined if ( targetIndex === activeLeafIndex && current.active[activeLeafIndex] === 1 && isTarget(target) && - target[TargetSnapshotTypeId] === undefined && target.values === undefined && current.completedOrder.length === 0 + target[TargetSnapshotTypeId] === undefined && target.values === undefined && current.completedOrder.length === 0 && + descriptor.valued[activeLeafIndex] ) { const values = current.values.slice() values[activeLeafIndex] = decodeStateValueSync( @@ -863,7 +864,8 @@ const planIndexedFlatState = ( ? target[TargetSnapshotTypeId] === undefined && target.values === undefined : !("state" in target) && !("states" in target) && !("completed" in target) && !("history" in target) if ( - targetIndex === sourceIndex && isSimpleTarget && current.completedOrder.length === 0 + targetIndex === sourceIndex && isSimpleTarget && current.completedOrder.length === 0 && + descriptor.valued[sourceIndex] ) { updateOwnedIndexedValue( current, diff --git a/packages/effect-machine/src/internal/machine/invocation.ts b/packages/effect-machine/src/internal/machine/invocation.ts index 2a39eb61..6cee5f20 100644 --- a/packages/effect-machine/src/internal/machine/invocation.ts +++ b/packages/effect-machine/src/internal/machine/invocation.ts @@ -19,6 +19,7 @@ import { ChildMachineLogicTypeId } from "./symbols.js" /** @internal */ export interface AnyConfig { readonly id: string + readonly sourceName?: string | undefined readonly address?: string readonly descriptor?: ChildMachine.Any readonly src: () => Runtime.ProcessLogic @@ -82,6 +83,7 @@ const resolveOne = ( if ("effect" in raw) { return { id: String(raw.id), + sourceName: raw.sourceName, src: () => oneShot(raw.effect(context) as Effect.Effect) as unknown as Runtime.ProcessLogic< any, @@ -100,6 +102,7 @@ const resolveOne = ( if ("after" in raw) { return { id: String(raw.id), + sourceName: raw.sourceName, src: () => oneShot(Effect.sleep(resolveValue(raw.after, context) as any)) as unknown as Runtime.ProcessLogic< any, @@ -117,6 +120,7 @@ const resolveOne = ( const id = String(raw.id) return { id, + sourceName: raw.sourceName, src: () => streamLogic(raw.stream(context), path, id) as unknown as Runtime.ProcessLogic, onDone: raw.onDone, @@ -192,11 +196,37 @@ const startResolved = ( onDone: unknown, onFailure: unknown, onSnapshot: unknown, - activityKind?: Inspection.Activity["kind"] + activityKind?: Inspection.Activity["kind"], + sourceName?: string ): Effect.Effect => Effect.suspend(() => { const key = makeKey(path, invokeId) - return ownedChildren.spawn(src, { + // Source construction keeps its startup failure boundary. Only the hosted + // program is wrapped; child statecharts retain their execution descriptors. + const tracedSource = activityKind === undefined ? src : () => { + const logic = src() + return { + ...logic, + run: (context: Runtime.ProcessContext) => + Effect.withSpan( + Effect.suspend(() => logic.run(context)), + "Machine.invoke", + { + attributes: { + "machine.id": scope.self.id, + "machine.sessionId": scope.self.sessionId, + "machine.state.path": path, + "machine.invoke.id": invokeId, + "machine.invoke.source": sourceName ?? invokeId, + "machine.invoke.kind": activityKind, + "machine.invoke.sessionId": context.self.sessionId + }, + captureStackTrace: false + } + ) + } + } + return ownedChildren.spawn(tracedSource, { key, path, id: childId, @@ -260,7 +290,8 @@ const start = ( config.onDone, config.onFailure, config.onSnapshot, - config.activityKind + config.activityKind, + config.sourceName ) } diff --git a/packages/effect-machine/src/internal/machine/invocationDefinition.ts b/packages/effect-machine/src/internal/machine/invocationDefinition.ts index 6992f020..c621cf96 100644 --- a/packages/effect-machine/src/internal/machine/invocationDefinition.ts +++ b/packages/effect-machine/src/internal/machine/invocationDefinition.ts @@ -16,6 +16,7 @@ interface Outcomes { export type InvocationDefinition = & Outcomes + & { readonly sourceName?: string } & ( | { readonly id: string; readonly effect: (context: Context) => Effect.Effect } | { readonly id: string; readonly stream: (context: Context) => Stream.Stream } diff --git a/packages/effect-machine/src/internal/machine/machine.ts b/packages/effect-machine/src/internal/machine/machine.ts index 96c7520f..2569a766 100644 --- a/packages/effect-machine/src/internal/machine/machine.ts +++ b/packages/effect-machine/src/internal/machine/machine.ts @@ -34,6 +34,7 @@ import type { ChildAlreadyExistsError, InfiniteTransitionError, StartupError } f import type { CapturedStateConfig } from "./implementation.js" import * as InitialDeclaration from "./initialDeclaration.js" import * as InvocationDefinition from "./invocationDefinition.js" +import * as Observation from "./observation.js" import * as internalPlanner from "./planner.js" import * as internalProcess from "./process.js" import * as Protocol from "./protocol.js" @@ -1411,6 +1412,8 @@ export const watch = ( ref: MachineRef ): Stream.Stream> => internalRuntime.watch(ref) +export const waitFor = Observation.waitFor + export const prepare = internalProcess.prepare export const start: < diff --git a/packages/effect-machine/src/internal/machine/observation.ts b/packages/effect-machine/src/internal/machine/observation.ts new file mode 100644 index 00000000..7a35c7cb --- /dev/null +++ b/packages/effect-machine/src/internal/machine/observation.ts @@ -0,0 +1,39 @@ +/** Observation of current and subsequent published runtime snapshots. */ +import * as Cause from "effect/Cause" +import * as Effect from "effect/Effect" +import { dual } from "effect/Function" +import * as Option from "effect/Option" +import * as Stream from "effect/Stream" +import type * as Machine from "../../Machine.js" +import { StoppedError } from "./errors.js" + +export const waitFor: typeof Machine.waitFor = dual(2, ( + ref: Machine.MachineRef, + predicate: (snapshot: Machine.RuntimeSnapshot) => boolean +): Effect.Effect, Error | StoppedError | Cause.NoSuchElementError> => + Effect.suspend(() => { + let result = Option.none>() + const absent = () => new Cause.NoSuchElementError(`Machine "${ref.id}" ended without a matching snapshot`) + return Stream.runForEachWhile( + ref.changes, + (snapshot) => + Effect.suspend((): Effect.Effect => { + if (predicate(snapshot)) { + result = Option.some(snapshot) + return Effect.succeed(false) + } + switch (snapshot.status) { + case "active": + return Effect.succeed(true) + case "error": + return Effect.failCause(snapshot.cause) + case "stopped": + return Effect.fail(new StoppedError()) + case "done": + return Effect.fail(absent()) + } + }) + ).pipe( + Effect.flatMap(() => Option.isSome(result) ? Effect.succeed(result.value) : Effect.fail(absent())) + ) + })) diff --git a/packages/effect-machine/test/fixtures/observability.ts b/packages/effect-machine/test/fixtures/observability.ts new file mode 100644 index 00000000..124a4ed5 --- /dev/null +++ b/packages/effect-machine/test/fixtures/observability.ts @@ -0,0 +1,45 @@ +import { Effect, Schema, Stream, Tracer } from "effect" +import { Machine } from "../../src/index.js" + +export const Events = Machine.events({ Start: {}, Reenter: {}, Finish: {} }) +const Root = Machine.state({ states: { Idle: {}, Active: {}, Complete: { type: "final", output: Schema.Void } } }) +const targets = Machine.targets(Root) +export const observedMachine = (work: Effect.Effect, stream: Stream.Stream = Stream.never) => + Machine.make({ + id: "Observed", + root: Root, + events: Events, + effects: { work }, + streams: { updates: stream }, + timers: { timeout: "1 hour" } + }).handle({ + initial: { target: targets.root.Idle }, + states: { + Idle: { on: { Start: { target: targets.root.Active } } }, + Active: { + invoke: [ + { src: "work", id: "custom-work", onDone: { none: true }, onFailure: { target: targets.root.Complete } }, + { src: "updates", onElement: { none: true }, onDone: { none: true } }, + { src: "timeout", onDone: { none: true } } + ], + on: { + Reenter: { target: targets.root.Active, reenter: true }, + Finish: { target: targets.root.Complete } + } + }, + Complete: { output: () => undefined } + } + }) + +export const recordingTracer = Effect.gen(function*() { + const underlying = yield* Effect.tracer + const spans: Array = [] + const tracer = Tracer.make({ + span(options) { + const span = underlying.span(options) + spans.push(span) + return span + } + }) + return { spans, tracer } +}) diff --git a/packages/effect-machine/test/internal/ObservationDifferential.test.ts b/packages/effect-machine/test/internal/ObservationDifferential.test.ts new file mode 100644 index 00000000..14e54e55 --- /dev/null +++ b/packages/effect-machine/test/internal/ObservationDifferential.test.ts @@ -0,0 +1,86 @@ +import { assert, describe, it } from "@effect/vitest" +import { Cause, Deferred, Effect, Exit, Fiber } from "effect" +import { Machine } from "../../src/index.js" +import { startWithRuntimeStrategyForTesting } from "../../src/internal/machine/process.js" +import { Events, observedMachine, recordingTracer } from "../fixtures/observability.js" +import { verifyPlannerStrategies } from "./machine/support/strategyDifferential.js" + +describe("Observation across runtime strategies", () => { + it.effect("preserves schema-less self targets in the indexed planner", () => + verifyPlannerStrategies({ + machine: observedMachine(Effect.never), + events: [Events.Start(), Events.Reenter(), Events.Reenter(), Events.Finish()], + expected: "indexed-flat", + label: "structural state reentry" + })) + for (const strategy of ["generic", "compiled"] as const) { + it.effect(`${strategy}: preserves waiting and invocation ownership across reentry`, () => + Effect.gen(function*() { + const { spans, tracer } = yield* recordingTracer + const first = yield* Deferred.make() + const second = yield* Deferred.make() + let runs = 0 + const work = Effect.gen(function*() { + yield* Deferred.succeed(runs++ === 0 ? first : second, undefined) + return yield* Effect.never + }) + const ref = yield* startWithRuntimeStrategyForTesting(observedMachine(work), strategy).pipe( + Effect.withTracer(tracer) + ) + const initial = yield* Machine.waitFor(ref, () => true) + assert.strictEqual(initial.status, "active") + const waiting = yield* Machine.waitFor(ref, (snapshot) => snapshot.status === "done").pipe( + Effect.forkScoped({ startImmediately: true }) + ) + yield* ref.send(Events.Start()) + yield* Deferred.await(first) + yield* ref.send(Events.Reenter()) + yield* Effect.raceFirst( + Deferred.await(second), + Effect.flatMap(ref.join, () => Effect.die("unexpected completion")) + ) + yield* ref.send(Events.Finish()) + const terminal = yield* Fiber.join(waiting) + assert.strictEqual(terminal.status, "done") + assert.strictEqual((yield* Machine.waitFor(ref, (snapshot) => snapshot.status === "done")).status, "done") + const absent = yield* Effect.flip(Machine.waitFor(ref, () => false)) + assert.ok(Cause.isNoSuchElementError(absent)) + yield* ref.join + const workSpans = spans.filter((span) => span.attributes.get("machine.invoke.source") === "work") + assert.strictEqual(workSpans.length, 2) + assert.notStrictEqual( + workSpans[0]?.attributes.get("machine.invoke.sessionId"), + workSpans[1]?.attributes.get("machine.invoke.sessionId") + ) + const invocationSpans = spans.filter((span) => span.name === "Machine.invoke") + assert.strictEqual(invocationSpans.length, 6) + for (const span of invocationSpans) { + assert.strictEqual(span.status._tag, "Ended") + if (span.status._tag === "Ended") { + assert.ok(Exit.isFailure(span.status.exit) && Cause.hasInterruptsOnly(span.status.exit.cause)) + } + } + })) + + it.effect(`${strategy}: stopping settles existing and later waiters`, () => + Effect.gen(function*() { + const ref = yield* startWithRuntimeStrategyForTesting(observedMachine(Effect.never), strategy) + const waiting = yield* Machine.waitFor(ref, () => false).pipe( + Effect.exit, + Effect.forkScoped({ startImmediately: true }) + ) + yield* ref.stop + const result = yield* Fiber.join(waiting) + assert.ok(Exit.isFailure(result)) + if (Exit.isFailure(result)) { + assert.ok( + result.cause.reasons.some((reason) => + Cause.isFailReason(reason) && reason.error instanceof Machine.StoppedError + ) + ) + } + const later = yield* Effect.flip(Machine.waitFor(ref, () => false)) + assert.ok(later instanceof Machine.StoppedError) + })) + } +}) diff --git a/packages/effect-machine/test/machine/Tracing.test.ts b/packages/effect-machine/test/machine/Tracing.test.ts new file mode 100644 index 00000000..d690b9f2 --- /dev/null +++ b/packages/effect-machine/test/machine/Tracing.test.ts @@ -0,0 +1,89 @@ +import { assert, describe, it } from "@effect/vitest" +import { Cause, Deferred, Effect, Exit, Option, Stream } from "effect" +import { Machine } from "../../src/index.js" +import { Events, observedMachine, recordingTracer } from "../fixtures/observability.js" + +describe("Invocation tracing", () => { + it.effect("records Effects, Streams and timers without an inspection subscriber", () => + Effect.gen(function*() { + const { spans, tracer } = yield* recordingTracer + const entered = yield* Deferred.make() + const work = Effect.withSpan( + Deferred.succeed(entered, undefined).pipe(Effect.andThen(Effect.never)), + "Application.work" + ) + const ref = yield* Machine.start(observedMachine(work)).pipe(Effect.withTracer(tracer)) + yield* ref.send(Events.Start()) + yield* Deferred.await(entered) + yield* ref.send(Events.Finish()) + yield* ref.join + const invocations = spans.filter((span) => span.name === "Machine.invoke") + assert.deepStrictEqual(invocations.map((span) => span.attributes.get("machine.invoke.kind")), [ + "Effect", + "Stream", + "Timer" + ]) + for (const span of invocations) { + assert.strictEqual(span.attributes.get("machine.id"), "Observed") + assert.strictEqual(span.attributes.get("machine.sessionId"), ref.sessionId) + assert.strictEqual(span.attributes.get("machine.state.path"), "Active") + assert.strictEqual(span.status._tag, "Ended") + if (span.status._tag === "Ended") { + assert.ok(Exit.isFailure(span.status.exit) && Cause.hasInterruptsOnly(span.status.exit.cause)) + } + } + assert.strictEqual(invocations[0]?.attributes.get("machine.invoke.source"), "work") + assert.strictEqual(invocations[0]?.attributes.get("machine.invoke.id"), "custom-work") + const child = spans.find((span) => span.name === "Application.work")! + assert.ok(Option.isSome(child.parent)) + if (Option.isSome(child.parent)) assert.strictEqual(child.parent.value.spanId, invocations[0]?.spanId) + })) + + it.effect("ends spans after program finalizers and preserves typed failure values", () => + Effect.gen(function*() { + const { spans, tracer } = yield* recordingTracer + let finalized = false + const work = Effect.fail("failed" as const).pipe(Effect.ensuring(Effect.gen(function*() { + const span = yield* Effect.orDie(Effect.currentSpan) + assert.strictEqual(span.status._tag, "Started") + finalized = true + }))) + const ref = yield* Machine.start(observedMachine(work)).pipe(Effect.withTracer(tracer)) + yield* ref.send(Events.Start()) + yield* ref.join + assert.strictEqual(finalized, true) + const span = spans.find((span) => span.attributes.get("machine.invoke.source") === "work")! + assert.strictEqual(span.status._tag, "Ended") + if (span.status._tag === "Ended") { + assert.ok( + Exit.isFailure(span.status.exit) && + span.status.exit.cause.reasons.some((reason) => Cause.isFailReason(reason) && reason.error === "failed") + ) + } + })) + + it.effect("honors disabled tracing and preserves sequential Stream consumption", () => + Effect.gen(function*() { + const { spans, tracer } = yield* recordingTracer + const consumed = yield* Deferred.make() + const seen: Array = [] + const stream = Stream.make(1, 2, 3).pipe( + Stream.tap((n) => + Effect.sync(() => { + seen.push(n) + }) + ), + Stream.ensuring(Deferred.succeed(consumed, undefined)) + ) + const ref = yield* Machine.start(observedMachine(Effect.never, stream)).pipe( + Effect.withTracer(tracer), + Effect.withTracerEnabled(false) + ) + yield* ref.send(Events.Start()) + yield* Deferred.await(consumed) + yield* ref.send(Events.Finish()) + yield* ref.join + assert.deepStrictEqual(seen, [1, 2, 3]) + assert.deepStrictEqual(spans, []) + })) +}) diff --git a/packages/effect-machine/test/machine/WaitFor.test.ts b/packages/effect-machine/test/machine/WaitFor.test.ts new file mode 100644 index 00000000..663273d1 --- /dev/null +++ b/packages/effect-machine/test/machine/WaitFor.test.ts @@ -0,0 +1,144 @@ +import { assert, describe, it } from "@effect/vitest" +import { Cause, Deferred, Effect, Exit, Fiber, Option, Stream } from "effect" +import { TestClock } from "effect/testing" +import { Machine } from "../../src/index.js" + +type Snapshot = Machine.RuntimeSnapshot +const active: Snapshot = { status: "active", state: 1 } +const done: Snapshot = { status: "done", state: 2, output: "ok" } +const stopped: Snapshot = { status: "stopped", state: 1 } +const error: Snapshot = { status: "error", state: 1, cause: Cause.fail("failed") } +const reference = ( + changes: Stream.Stream +): Machine.MachineRef => ({ + id: "test", + sessionId: "test-1", + changes: Stream.scoped(changes), + state: Effect.succeed(1), + snapshot: Effect.succeed(active), + emissions: Stream.empty, + join: Effect.never, + stop: Effect.die("must not stop the machine"), + send: () => Effect.die("must not send"), + child: () => Effect.succeed(Option.none()), + childChanges: () => Stream.succeed(Option.none()) +}) + +describe("Machine.waitFor", () => { + it.effect("stops at the first match before a later failure in the same chunk", () => + Effect.gen(function*() { + let calls = 0 + const result = yield* Machine.waitFor(reference(Stream.make(active, error)), (snapshot) => { + calls++ + return snapshot.state === 1 + }) + assert.strictEqual(result, active) + assert.strictEqual(calls, 1) + })) + + it.effect("matches terminal snapshots before classifying them", () => + Effect.gen(function*() { + for (const terminal of [done, stopped, error]) { + const result = yield* Machine.waitFor(reference(Stream.make(active, terminal)), (snapshot) => + snapshot.status === terminal.status) + assert.strictEqual(result, terminal) + } + })) + + it.effect("preserves unmatched failures, defects, and interruption", () => + Effect.gen(function*() { + for (const cause of [Cause.fail("failed" as const), Cause.die("defect"), Cause.interrupt()]) { + const result = yield* Effect.exit(Machine.waitFor( + reference(Stream.make({ status: "error", state: 1, cause })), + () => false + )) + assert.ok(Exit.isFailure(result)) + if (Exit.isFailure(result)) assert.deepStrictEqual(result.cause, cause) + } + })) + + it.effect("distinguishes stopping from completion without a match", () => + Effect.gen(function*() { + const stopError = yield* Effect.flip(Machine.waitFor(reference(Stream.make(stopped)), () => false)) + assert.ok(stopError instanceof Machine.StoppedError) + const doneError = yield* Effect.flip(Machine.waitFor(reference(Stream.make(done)), () => false)) + assert.ok(Cause.isNoSuchElementError(doneError)) + })) + + it.effect("is lazy, reusable, and releases each subscription", () => + Effect.gen(function*() { + let opened = 0 + let closed = 0 + const ref = reference(Stream.fromEffect(Effect.acquireRelease( + Effect.sync(() => { + opened++ + return active + }), + () => + Effect.sync(() => { + closed++ + }) + ))) + const waiting = Machine.waitFor(ref, () => true) + assert.strictEqual(opened, 0) + yield* Effect.all([waiting, waiting], { concurrency: "unbounded" }) + assert.strictEqual(opened, 2) + assert.strictEqual(closed, 2) + })) + + it.effect("cleans up on timeout without stopping the machine", () => + Effect.gen(function*() { + const subscribed = yield* Deferred.make() + let closed = false + const ref = reference( + Stream.fromEffect(Effect.acquireRelease( + Deferred.succeed(subscribed, undefined), + () => + Effect.sync(() => { + closed = true + }) + )).pipe(Stream.flatMap(() => Stream.never)) + ) + const fiber = yield* Machine.waitFor(ref, () => false).pipe( + Effect.timeout("1 second"), + Effect.exit, + Effect.forkScoped + ) + yield* Deferred.await(subscribed) + yield* TestClock.adjust("1 second") + const result = yield* Fiber.join(fiber) + assert.ok(Exit.isFailure(result)) + if (Exit.isFailure(result)) { + assert.ok( + result.cause.reasons.some((reason) => Cause.isFailReason(reason) && Cause.isTimeoutError(reason.error)) + ) + } + assert.strictEqual(closed, true) + })) + + it.effect("turns predicate exceptions into defects and cleans up", () => + Effect.gen(function*() { + const defect = new Error("bad predicate") + let closed = false + const ref = reference(Stream.fromEffect(Effect.acquireRelease(Effect.succeed(active), () => + Effect.sync(() => { + closed = true + })))) + const result = yield* Effect.exit(Machine.waitFor(ref, () => { + throw defect + })) + assert.ok(Exit.isFailure(result)) + if (Exit.isFailure(result)) { + assert.ok(result.cause.reasons.some((reason) => Cause.isDieReason(reason) && reason.defect === defect)) + } + assert.strictEqual(closed, true) + })) + + it.effect("supports the curried form", () => + Effect.gen(function*() { + const result = yield* Machine.waitFor((snapshot: Snapshot) => snapshot.status === "done")( + reference(Stream.make(active, done)) + ) + assert.strictEqual(result, done) + })) +}) diff --git a/packages/effect-machine/typetest/machine/WaitFor.tst.ts b/packages/effect-machine/typetest/machine/WaitFor.tst.ts new file mode 100644 index 00000000..f9c75729 --- /dev/null +++ b/packages/effect-machine/typetest/machine/WaitFor.tst.ts @@ -0,0 +1,41 @@ +import type { Cause, Effect } from "effect" +import { pipe } from "effect" +import { describe, expect, it } from "tstyche" +import { Machine } from "../../src/index.js" + +type Snapshot = Machine.RuntimeSnapshot<{ readonly value: number }, "failed", string> +declare const ref: Machine.MachineRef< + { readonly value: number }, + { readonly _tag: "Go" }, + "failed", + string, + { readonly _tag: "Notice" } +> +type Failure = "failed" | Machine.StoppedError | Cause.NoSuchElementError +const isDone = (snapshot: Snapshot): snapshot is Extract => snapshot.status === "done" + +describe("Machine.waitFor", () => { + it("preserves snapshots, errors, and service-free observation", () => { + const waiting = Machine.waitFor(ref, (snapshot): boolean => snapshot.state.value > 1) + expect>().type.toBe() + expect>().type.toBe() + expect>().type.toBe() + }) + it("narrows both calling forms", () => { + const direct = Machine.waitFor(ref, isDone) + const curried = pipe(ref, Machine.waitFor(isDone)) + expect>().type.toBe>() + expect>().type.toBe>() + expect>().type.toBe() + const inline = pipe(ref, Machine.waitFor((snapshot) => snapshot.status === "done")) + expect>().type.toBe() + expect>().type.toBe>() + }) + it("rejects unrelated predicates and nonboolean results", () => { + expect(Machine.waitFor).type.not.toBeCallableWith( + ref, + (snapshot: Machine.RuntimeSnapshot) => snapshot.state.length > 0 + ) + expect(Machine.waitFor).type.not.toBeCallableWith(ref, () => 1) + }) +})