Skip to content

Start invoked work in the sender's turn - #250

Merged
SandroMaglione merged 1 commit into
mainfrom
start-invocations-in-the-entering-turn
Oct 24, 2026
Merged

SandroMaglione merged 1 commit into
mainfrom
start-invocations-in-the-entering-turn

Conversation

@SandroMaglione

Copy link
Copy Markdown
Member

Summary

  • Work invoked by an entered state now starts in the sender's turn and runs until it first suspends. Before, every invocation was hosted by a process whose worker was scheduled for a later turn, so even the first synchronous step ran after send returned. A browser call that requires user activation (window.open, navigator.clipboard.writeText, HTMLMediaElement.play) therefore ran outside the click that sent the event; Chrome tolerates that through its activation window, stricter browsers block it.
  • The invocation's outcome is delivered one turn later, even when the work completes without suspending. The entered state is always published before onDone/onFailure leaves it, a synchronous defect still fails the machine after send returns, and a loop of instantly completing invocations still yields between steps. The outcome of a state exited in between is still discarded.
  • Implementation: runtimeGeneric forks the hosted worker with startImmediately (which also makes its trailing yieldNow unnecessary), and invocation.ts delivers outcomes behind one yieldNow.
  • No public API or type changes. Cluster entities are unaffected: they reject invocations and only use the pure planner. Stream sources are unchanged: Stream.callback registers in its own fiber.
  • test/machine/InvocationTurn.test.ts covers both runtime strategies: invoked work and an invoked child's initial work start before send returns (fail on main), and the entered state is published first, outcomes of exited states are discarded, synchronous defects fail later, and instant-completion loops do not run inside send (pass on main and here).
  • Docs: MachineRef JSDoc, the agent guide ("Let states own running work") and the Effect Atom guide ("Send without subscribing").

Changeset

  • Added or updated for a library or package-metadata change
  • Not required because this PR does not change a publishable package

Minor: the timing of invoked work is observable (it now runs on the sender until it first suspends).

Validation

  • pnpm check
  • Automated type-performance measurement passed or was not required
  • Automated runtime- and memory-performance measurement passed or was not required

Also checked against the consuming apps on a patched 0.45.1 build: creator-self-serve 357/357 tests, and in Chrome the Google sign-in popup opens within the click task (false on 0.45.1, true with this change).

🤖 Generated with Claude Code

An invocation's work was hosted by a process whose worker was scheduled
for a later turn, so even the synchronous first step of an invoked Effect
ran after send returned. A browser call that needs user activation, such
as window.open or clipboard.writeText, therefore ran outside the click.

The hosting worker now starts immediately and runs until it first
suspends, and the outcome is delivered to the machine one turn later. The
entered state is always published before onDone or onFailure leaves it,
and an outcome for a state exited in between is still discarded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

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,449 39,449 0 (0.0%)
declared transition (10 named branches) 60,402 60,402 0 (0.0%)
atomic construction, guards, and reentry 85,710 85,710 0 (0.0%)
registered invocation (input-taking Effect) 39,819 39,819 0 (0.0%)
machine.handle (depth 24) 194,531 194,531 0 (0.0%)
machine.handle (wide depth 16) 218,238 218,238 0 (0.0%)
machine.handle (parallel/history/choice) 91,642 91,642 0 (0.0%)
machine definition (3 independent implementations) 99,173 99,173 0 (0.0%)
machine exact input/output/error/services 82,941 82,941 0 (0.0%)
execution adapter readiness 71,428 71,428 0 (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,517 30,517 0 (0.0%)
declared transition (10 named branches) 51,849 51,849 0 (0.0%)
atomic construction, guards, and reentry 70,370 70,370 0 (0.0%)
registered invocation (input-taking Effect) 32,682 32,682 0 (0.0%)
machine.handle (depth 24) 177,222 177,222 0 (0.0%)
machine.handle (wide depth 16) 201,707 201,707 0 (0.0%)
machine.handle (parallel/history/choice) 79,144 79,144 0 (0.0%)
machine definition (3 independent implementations) 88,492 88,492 0 (0.0%)
machine exact input/output/error/services 70,732 70,732 0 (0.0%)
execution adapter readiness 31,922 31,922 0 (0.0%)
Check times (informational)
Scenario Base PR
Effect only 0.02s 0.02s
Import effect-machine 0.02s 0.02s
Machine.state (3 children) 0.05s 0.05s
Machine.make (3 states, 2 events) 0.08s 0.08s
machine.handle (3 states, 2 transitions) 0.19s 0.21s
declared transition (10 named branches) 0.28s 0.22s
atomic construction, guards, and reentry 0.32s 0.31s
registered invocation (input-taking Effect) 0.20s 0.19s
machine.handle (depth 24) 0.36s 0.38s
machine.handle (wide depth 16) 0.43s 0.39s
machine.handle (parallel/history/choice) 0.33s 0.32s
machine definition (3 independent implementations) 0.32s 0.34s
machine exact input/output/error/services 0.29s 0.32s
execution adapter readiness 0.28s 0.28s

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

@github-actions

Copy link
Copy Markdown
Contributor

Runtime performance

Median of 5 independent benchmark processes on AMD EPYC 9V74 80-Core Processor with Node v24.21.0.

Pull request baseline

Scenario Effect Machine
Plan counter transitions 146,065 transitions/s
Drain burst with terminal fence 487,518 increments/s
Drain burst with a change observer 457,111 increments/s
Lookup and send to one child 381,461 increments/s
Start and stop a machine 155,521 machines/s
Start and stop a parent with one child 46,529 families/s
Complete an Effect invocation with tracing enabled 12,117 invocations/s
Complete an Effect invocation with tracing disabled 15,051 invocations/s
Plan transitions through a compound state 132,385 transitions/s
Plan transitions through parallel regions 106,885 transitions/s
Drain burst through a compound state 412,868 events/s
Drain burst through two parallel regions 388,691 events/s
Drain a compound-state burst with a change observer 386,207 events/s

Process runtime reference points

Scenario Effect Machine
Start and stop a raw generic process 43,814 processes/s
Start and stop a raw compiled process 92,799 processes/s
Memory profile Effect Machine
Idle machine 1.9 KiB
Raw generic managed process 11.2 KiB
Raw compiled process 3.1 KiB
Two independent idle machines 3.5 KiB
Idle parent with one child 6.0 KiB
Parent with observed child registry 9.5 KiB
Parent with observed invoked child snapshots 6.9 KiB

Effect Machine change from base

Metric Base Base variability PR PR variability Difference
Plan counter transitions 149,183 transitions/s 0.5% MAD 146,065 transitions/s 0.2% MAD -2.1%
Drain burst with terminal fence 492,675 increments/s 0.7% MAD 487,518 increments/s 0.4% MAD -1.0%
Drain burst with a change observer 460,544 increments/s 0.5% MAD 457,111 increments/s 0.3% MAD -0.7%
Lookup and send to one child 395,576 increments/s 0.4% MAD 381,461 increments/s 0.3% MAD -3.6%
Start and stop a machine 155,039 machines/s 0.6% MAD 155,521 machines/s 0.4% MAD +0.3%
Start and stop a parent with one child 46,551 families/s 0.4% MAD 46,529 families/s 1.1% MAD -0.0%
Complete an Effect invocation with tracing enabled 13,626 invocations/s 0.8% MAD 12,117 invocations/s 0.3% MAD -11.1%
Complete an Effect invocation with tracing disabled 17,533 invocations/s 1.2% MAD 15,051 invocations/s 0.8% MAD -14.2%
Plan transitions through a compound state 132,517 transitions/s 0.4% MAD 132,385 transitions/s 0.2% MAD -0.1%
Plan transitions through parallel regions 107,654 transitions/s 0.3% MAD 106,885 transitions/s 0.8% MAD -0.7%
Drain burst through a compound state 409,838 events/s 0.7% MAD 412,868 events/s 0.2% MAD +0.7%
Drain burst through two parallel regions 382,913 events/s 0.3% MAD 388,691 events/s 0.6% MAD +1.5%
Drain a compound-state burst with a change observer 386,147 events/s 0.0% MAD 386,207 events/s 0.1% MAD +0.0%
Idle machine heap per unit 1.9 KiB 0.3% MAD 1.9 KiB 0.1% MAD -0.2%
Raw generic managed process heap per unit 11.2 KiB 0.1% MAD 11.2 KiB 0.0% MAD -0.2%
Raw compiled process heap per unit 3.1 KiB 0.1% MAD 3.1 KiB 0.0% MAD +0.0%
Two independent idle machines heap per unit 3.5 KiB 0.0% MAD 3.5 KiB 0.0% MAD +0.0%
Idle parent with one child heap per unit 6.0 KiB 0.0% MAD 6.0 KiB 0.0% MAD -0.0%
Parent with observed child registry heap per unit 9.5 KiB 0.0% MAD 9.5 KiB 0.1% MAD +0.1%
Parent with observed invoked child snapshots heap per unit 6.9 KiB 0.0% MAD 6.9 KiB 0.1% MAD +0.0%

Process runtime reference change from base

Metric Base Base variability PR PR variability Difference
Start and stop a raw generic process 38,537 processes/s 1.9% MAD 43,814 processes/s 0.5% MAD +13.7%
Start and stop a raw compiled process 92,276 processes/s 0.7% MAD 92,799 processes/s 0.3% MAD +0.6%

Regression guard

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

Versions and interpretation
  • Effect Machine: 0.45.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.

@SandroMaglione
SandroMaglione merged commit 3310fec into main Oct 24, 2026
9 checks passed
@SandroMaglione
SandroMaglione deleted the start-invocations-in-the-entering-turn branch October 24, 2026 03:54
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.

1 participant