Skip to content

Scope ChildAlreadyExistsError to caller-chosen invocation ids and add readable diagnostics - #237

Merged
SandroMaglione merged 3 commits into
mainfrom
invoke-error-channels-and-diagnostics
Oct 22, 2026
Merged

SandroMaglione merged 3 commits into
mainfrom
invoke-error-channels-and-diagnostics

Conversation

@SandroMaglione

Copy link
Copy Markdown
Member

Summary

  • Machine.InvokeError now adds ChildAlreadyExistsError only for invocations whose child id is caller-chosen: logic invocations (address) and Machine.child invocations. Effect, Stream, and timer invocations run under runtime-owned ids (Machine.invocation:<len>:<path><id>), so a machine with only such invocations has Machine.Error = never, and its join fails only with InfiniteTransitionError | MachineSchemaDecodeError | StoppedError.
  • A parent that invokes a fixed Machine.child through children needs onFailure exactly when the child's own Error is reachable. onFailure: { none: true } for a child that cannot fail is now rejected as unreachable. At runtime, the parent's onFailure still receives only the child's typed failures (isFrameworkFailure is unchanged).
  • The type change is backed by runtime guarantees. Without them, internal ids could still collide:
    • Invocation lifecycle ids inside one state must be distinct. Before this change, invoke: [{ src: "a" }, { src: "a" }] failed at runtime with ChildAlreadyExistsError. It is now a type error ("Invocation id 'a' is declared more than once in this state") and is rejected when handle captures the definition. That rejection backstops wide string ids.
    • The Machine.invocation: id namespace is reserved. A caller-chosen id in that namespace (family/Logic.Spawn ids, logic addresses, child descriptor ids) is treated as already taken and fails with ChildAlreadyExistsError, in a channel that already declares it. Runtime duplicate detection is otherwise unchanged.
  • Readable diagnostics replace never:
    • RootCompatible resolves to RootCompatibilityError<"Machine declares a required parent and cannot run as a root; invoke or spawn it from an owning machine">. This applies to start, resume, prepare, planInitial, and the AtomMachine root APIs.
    • Unreachable invoke channels and unknown fields map to InvocationDefinitionError<...>, for example Invocation 'forever' never completes, so onDone is unreachable, cannot fail, so onFailure is unreachable, emits no Stream elements, so onElement is unreachable, and has no snapshots; onSnapshot applies only to logic and child invocations. The set of accepted configurations is unchanged.
  • Tests:
    • New runtime test test/machine/InvocationIds.test.ts covers reserved-id spoofing, parallel regions and reentry under the same lifecycle id, collisions of fixed children and addressed logic across parallel regions, and definition-time duplicate rejection.
    • New typetest typetest/machine/InvocationErrors.tst.ts.
    • Two existing runtime tests that relied on same-state duplicate ids now assert the definition-time rejection.
  • The devtools examples dropped onFailure handlers that are now unreachable. The parallel-owner example keeps two deep transitions through a reachable ChildSignal event.
  • README documents distinct invocation ids and which invocations can raise ChildAlreadyExistsError.

Changeset

  • Added or updated for a library or package-metadata change (minor: .changeset/invoke-error-channels-and-diagnostics.md)
  • Not required because this PR does not change a publishable package

Validation

  • pnpm check
    • dprint check could not download plugins in the local sandbox (plugins.dprint.dev blocked). Formatting was applied with the identical plugin versions (typescript 0.93.4, markdown 0.20.0, json 0.21.1) loaded locally.
    • devtools/test/StaticSite.test.ts hit its 5s timeout once under full-suite load. It passes in isolation.
    • pnpm test:types, test:consumer, and the pack checks pass.
  • Automated type-performance measurement passed or was not required (pnpm perf:types stays within the configured budgets locally; see the CI comparison)
  • Automated runtime- and memory-performance measurement passed or was not required (pnpm perf:runtime smoke run completed; see the CI comparison)

Generated by Claude Code

Effect, Stream, and timer invocations run under runtime-reserved child ids,
so their machines no longer carry ChildAlreadyExistsError and fixed-child
parents need onFailure only when the child's own error is reachable.
Invocation ids within one state must be distinct, and constraint failures
for required parents and unreachable invoke channels now carry readable
diagnostics instead of never.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHnF2EL6ReDTKwoM9TVH7L
@github-actions

github-actions Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Type performance

Measured with TypeScript 6.0.3 and skipLibCheck=true.

Scenario Base PR Difference
Effect only 55 55 0 (0.0%)
Import effect-machine 55 55 0 (0.0%)
Machine.state (3 children) 2,664 2,664 0 (0.0%)
Machine.make (3 states, 2 events) 8,932 8,932 0 (0.0%)
machine.handle (3 states, 2 transitions) 39,348 39,348 0 (0.0%)
declared transition (10 named branches) 60,165 60,167 +2 (+0.0%)
atomic construction, guards, and reentry 85,402 85,404 +2 (+0.0%)
registered invocation (input-taking Effect) 39,467 39,707 +240 (+0.6%)
machine.handle (depth 24) 194,526 194,528 +2 (+0.0%)
machine.handle (wide depth 16) 218,233 218,235 +2 (+0.0%)
machine.handle (parallel/history/choice) 91,633 91,635 +2 (+0.0%)
machine definition (3 independent implementations) 98,225 98,227 +2 (+0.0%)
machine exact input/output/error/services 82,791 82,793 +2 (+0.0%)
execution adapter readiness 71,416 71,418 +2 (+0.0%)

Marginal instantiations are measured against the matching setup without that API call:

Scenario Base PR Difference
Import effect-machine 0 0 0
Machine.state (3 children) 2,609 2,609 0 (0.0%)
Machine.make (3 states, 2 events) 6,260 6,260 0 (0.0%)
machine.handle (3 states, 2 transitions) 30,416 30,416 0 (0.0%)
declared transition (10 named branches) 51,612 51,614 +2 (+0.0%)
atomic construction, guards, and reentry 70,062 70,064 +2 (+0.0%)
registered invocation (input-taking Effect) 32,330 32,570 +240 (+0.7%)
machine.handle (depth 24) 177,217 177,219 +2 (+0.0%)
machine.handle (wide depth 16) 201,702 201,704 +2 (+0.0%)
machine.handle (parallel/history/choice) 79,135 79,137 +2 (+0.0%)
machine definition (3 independent implementations) 87,544 87,546 +2 (+0.0%)
machine exact input/output/error/services 70,582 70,584 +2 (+0.0%)
execution adapter readiness 31,918 31,920 +2 (+0.0%)
Check times (informational)
Scenario Base PR
Effect only 0.03s 0.04s
Import effect-machine 0.04s 0.03s
Machine.state (3 children) 0.10s 0.09s
Machine.make (3 states, 2 events) 0.16s 0.15s
machine.handle (3 states, 2 transitions) 0.32s 0.32s
declared transition (10 named branches) 0.43s 0.42s
atomic construction, guards, and reentry 0.56s 0.53s
registered invocation (input-taking Effect) 0.34s 0.33s
machine.handle (depth 24) 0.72s 0.69s
machine.handle (wide depth 16) 0.75s 0.77s
machine.handle (parallel/history/choice) 0.57s 0.58s
machine definition (3 independent implementations) 0.60s 0.61s
machine exact input/output/error/services 0.54s 0.55s
execution adapter readiness 0.48s 0.49s

Type instantiations are the comparison metric. Check time varies with runner load and is informational only.

@github-actions

github-actions Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Runtime performance

Median of 5 independent benchmark processes on AMD EPYC 7763 64-Core Processor with Node v24.21.0.

Pull request baseline

Scenario Effect Machine
Plan counter transitions 114,723 transitions/s
Drain burst with terminal fence 403,850 increments/s
Drain burst with a change observer 376,077 increments/s
Lookup and send to one child 330,937 increments/s
Start and stop a machine 129,450 machines/s
Start and stop a parent with one child 25,785 families/s
Complete an Effect invocation with tracing enabled 7,339 invocations/s
Complete an Effect invocation with tracing disabled 9,009 invocations/s
Plan transitions through a compound state 101,571 transitions/s
Plan transitions through parallel regions 79,945 transitions/s
Drain burst through a compound state 347,102 events/s
Drain burst through two parallel regions 319,246 events/s
Drain a compound-state burst with a change observer 325,085 events/s

Process runtime reference points

Scenario Effect Machine
Start and stop a raw generic process 21,319 processes/s
Start and stop a raw compiled process 73,551 processes/s
Memory profile Effect Machine
Idle machine 1.8 KiB
Raw generic managed process 11.5 KiB
Raw compiled process 3.0 KiB
Two independent idle machines 3.4 KiB
Idle parent with one child 5.9 KiB
Parent with observed child registry 9.4 KiB
Parent with observed invoked child snapshots 6.4 KiB

Effect Machine change from base

Metric Base Base variability PR PR variability Difference
Plan counter transitions 114,985 transitions/s 0.9% MAD 114,723 transitions/s 0.8% MAD -0.2%
Drain burst with terminal fence 406,290 increments/s 0.2% MAD 403,850 increments/s 0.2% MAD -0.6%
Drain burst with a change observer 379,636 increments/s 0.1% MAD 376,077 increments/s 0.4% MAD -0.9%
Lookup and send to one child 334,184 increments/s 0.1% MAD 330,937 increments/s 0.6% MAD -1.0%
Start and stop a machine 132,732 machines/s 0.8% MAD 129,450 machines/s 1.2% MAD -2.5%
Start and stop a parent with one child 26,281 families/s 2.2% MAD 25,785 families/s 0.5% MAD -1.9%
Complete an Effect invocation with tracing enabled 7,369 invocations/s 0.9% MAD 7,339 invocations/s 0.6% MAD -0.4%
Complete an Effect invocation with tracing disabled 9,064 invocations/s 1.8% MAD 9,009 invocations/s 0.4% MAD -0.6%
Plan transitions through a compound state 102,102 transitions/s 0.7% MAD 101,571 transitions/s 0.4% MAD -0.5%
Plan transitions through parallel regions 80,595 transitions/s 0.5% MAD 79,945 transitions/s 0.2% MAD -0.8%
Drain burst through a compound state 348,615 events/s 0.9% MAD 347,102 events/s 1.5% MAD -0.4%
Drain burst through two parallel regions 322,425 events/s 0.7% MAD 319,246 events/s 0.3% MAD -1.0%
Drain a compound-state burst with a change observer 329,663 events/s 0.7% MAD 325,085 events/s 1.2% MAD -1.4%
Idle machine heap per unit 1.8 KiB 0.4% MAD 1.8 KiB 0.3% MAD +0.2%
Raw generic managed process heap per unit 11.4 KiB 0.0% MAD 11.5 KiB 0.0% MAD +1.0%
Raw compiled process heap per unit 3.0 KiB 0.2% MAD 3.0 KiB 0.5% MAD -0.3%
Two independent idle machines heap per unit 3.4 KiB 0.6% MAD 3.4 KiB 0.4% MAD -0.0%
Idle parent with one child heap per unit 5.9 KiB 0.0% MAD 5.9 KiB 0.0% MAD +0.1%
Parent with observed child registry heap per unit 9.4 KiB 0.0% MAD 9.4 KiB 0.0% MAD +0.1%
Parent with observed invoked child snapshots heap per unit 6.4 KiB 0.1% MAD 6.4 KiB 0.0% MAD +0.1%

Process runtime reference change from base

Metric Base Base variability PR PR variability Difference
Start and stop a raw generic process 21,296 processes/s 2.0% MAD 21,319 processes/s 1.1% MAD +0.1%
Start and stop a raw compiled process 73,014 processes/s 0.6% MAD 73,551 processes/s 0.3% MAD +0.7%

Regression guard

No large, noise-adjusted throughput or heap regressions detected.

Versions and interpretation
  • Effect Machine: 0.41.1

Higher throughput is better; lower heap is better. Variability is the median absolute deviation across independent processes, relative to their median. Small differences on shared GitHub-hosted hardware remain informational; the required guard rejects only large changes beyond the measured noise allowance.

Runtime-owned Effect, Stream, and timer invocation ids no longer pay for the
reservation check on every start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHnF2EL6ReDTKwoM9TVH7L
@SandroMaglione
SandroMaglione merged commit 36b1c0a into main Oct 22, 2026
9 checks passed
@SandroMaglione
SandroMaglione deleted the invoke-error-channels-and-diagnostics branch October 22, 2026 10:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants