Skip to content

Process sends to an idle local machine in the sender's turn - #246

Merged
SandroMaglione merged 3 commits into
mainfrom
fix/synchronous-idle-delivery
Oct 23, 2026
Merged

SandroMaglione merged 3 commits into
mainfrom
fix/synchronous-idle-delivery

Conversation

@SandroMaglione

Copy link
Copy Markdown
Member

Summary

  • A local MachineRef.send to an idle machine now processes the event in the sender's turn. Work that completes without suspending, including raised events and the committed state, is reflected by snapshot when send returns. Suspending work, such as an invocation, continues in the background. Events sent while the machine is busy are queued, as before. MachineTarget.send, and so any future Cluster delivery, keeps promising only delivery.
  • Controlled inputs bound to machine state through AtomMachine now keep their value. Before, React restored the previous value because the state arrived a turn later, which also left stale form-validation messages after autofill. Local references expose an internal synchronous snapshot observer that the atom bridge uses; references without one fall back to changes.
  • Performance:
    • Machines without invocations drain synchronously, without a worker fiber, and keep their compact execution state while idle.
    • changes publication is coalesced through the sender fiber's scheduler dispatcher, as Queue releases its takers, so a burst still reaches subscribers in one publication.
  • The generic reference runtime now uses a mailbox that wakes a waiting receiver inline, so both strategies share delivery timing. It also publishes EventSent before the receiver processes the event.
  • Fix: stopping an idle machine now completes an observed emissions stream; it previously never ended.

Tests:

  • Public: test/machine/SynchronousDelivery.test.ts, covering machines with and without invocations, raised events, emissions, observer re-entrancy, and suspended invocations.
  • Generic-versus-compiled differential coverage, including targetless, reentering, terminal, raised-event, active-invocation and retained-snapshot cases.
  • AtomMachine tests for root and invoked-child projections.
  • A jsdom controlled-input test in effect-machine-react.

Changeset

  • Added or updated for a library or package-metadata change (minor: the timing of send is observable)
  • Not required because this PR does not change a publishable package

Validation

  • pnpm check
  • Automated type-performance measurement passed or was not required (pnpm perf:types passes the budgets locally; only internal types and documentation changed)
  • Automated runtime- and memory-performance measurement passed or was not required (pending the workflow; the change touches the send hot path)

Also checked against the downstream app that reported the bug, with Playwright:

  • Typing in the middle of a field and autofill after typing now pass on every run.
  • The "autofill on load" scenario still fails when hydration is slow. React resets the input during hydration before any handler runs, and the original 0.44.0 build shows the same result, so this PR does not address it.

🤖 Generated with Claude Code

An idle compiled machine now drains an accepted event before `send`
returns, so `snapshot` reflects work that completes without suspending.
Machines without invocations drain without a worker fiber and retain
their compact execution state while idle. `changes` publication is
coalesced through the sender's scheduler dispatcher, so bursts still
reach subscribers together.

Local references expose a synchronous snapshot observer that
`AtomMachine` uses, so controlled inputs bound to machine state keep
their value within the input event. The generic runtime wakes a
waiting receiver inline so both strategies share the delivery timing.

Stopping an idle machine now also completes an observed emission
stream.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@github-actions

github-actions Bot commented Oct 23, 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,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.03s 0.03s
Import effect-machine 0.03s 0.03s
Machine.state (3 children) 0.09s 0.09s
Machine.make (3 states, 2 events) 0.14s 0.14s
machine.handle (3 states, 2 transitions) 0.30s 0.30s
declared transition (10 named branches) 0.38s 0.38s
atomic construction, guards, and reentry 0.51s 0.50s
registered invocation (input-taking Effect) 0.31s 0.31s
machine.handle (depth 24) 0.63s 0.64s
machine.handle (wide depth 16) 0.71s 0.70s
machine.handle (parallel/history/choice) 0.51s 0.52s
machine definition (3 independent implementations) 0.53s 0.54s
machine exact input/output/error/services 0.51s 0.49s
execution adapter readiness 0.45s 0.43s

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

@github-actions

github-actions Bot commented Oct 23, 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 112,739 transitions/s
Drain burst with terminal fence 400,825 increments/s
Drain burst with a change observer 374,012 increments/s
Lookup and send to one child 310,125 increments/s
Start and stop a machine 115,929 machines/s
Start and stop a parent with one child 23,234 families/s
Complete an Effect invocation with tracing enabled 7,260 invocations/s
Complete an Effect invocation with tracing disabled 8,745 invocations/s
Plan transitions through a compound state 101,287 transitions/s
Plan transitions through parallel regions 80,319 transitions/s
Drain burst through a compound state 343,261 events/s
Drain burst through two parallel regions 318,828 events/s
Drain a compound-state burst with a change observer 319,948 events/s

Process runtime reference points

Scenario Effect Machine
Start and stop a raw generic process 20,842 processes/s
Start and stop a raw compiled process 68,985 processes/s
Memory profile Effect Machine
Idle machine 1.9 KiB
Raw generic managed process 11.1 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 113,455 transitions/s 0.4% MAD 112,739 transitions/s 1.1% MAD -0.6%
Drain burst with terminal fence 396,551 increments/s 1.1% MAD 400,825 increments/s 0.8% MAD +1.1%
Drain burst with a change observer 368,462 increments/s 1.4% MAD 374,012 increments/s 0.4% MAD +1.5%
Lookup and send to one child 323,565 increments/s 0.4% MAD 310,125 increments/s 0.4% MAD -4.2%
Start and stop a machine 114,077 machines/s 2.7% MAD 115,929 machines/s 2.0% MAD +1.6%
Start and stop a parent with one child 23,474 families/s 2.5% MAD 23,234 families/s 1.2% MAD -1.0%
Complete an Effect invocation with tracing enabled 7,000 invocations/s 0.5% MAD 7,260 invocations/s 1.0% MAD +3.7%
Complete an Effect invocation with tracing disabled 8,490 invocations/s 1.3% MAD 8,745 invocations/s 1.6% MAD +3.0%
Plan transitions through a compound state 99,721 transitions/s 0.4% MAD 101,287 transitions/s 1.0% MAD +1.6%
Plan transitions through parallel regions 79,416 transitions/s 0.7% MAD 80,319 transitions/s 2.2% MAD +1.1%
Drain burst through a compound state 337,390 events/s 1.3% MAD 343,261 events/s 1.4% MAD +1.7%
Drain burst through two parallel regions 312,225 events/s 0.3% MAD 318,828 events/s 1.8% MAD +2.1%
Drain a compound-state burst with a change observer 316,426 events/s 1.3% MAD 319,948 events/s 1.2% MAD +1.1%
Idle machine heap per unit 1.9 KiB 0.5% MAD 1.9 KiB 1.3% MAD -0.4%
Raw generic managed process heap per unit 11.7 KiB 0.0% MAD 11.1 KiB 0.1% MAD -5.3%
Raw compiled process heap per unit 3.1 KiB 0.0% MAD 3.1 KiB 0.0% MAD +0.6%
Two independent idle machines heap per unit 3.5 KiB 0.0% MAD 3.5 KiB 0.0% MAD +0.9%
Idle parent with one child heap per unit 6.0 KiB 0.0% MAD 6.0 KiB 0.0% MAD +0.5%
Parent with observed child registry heap per unit 9.5 KiB 0.0% MAD 9.5 KiB 0.0% MAD +0.3%
Parent with observed invoked child snapshots heap per unit 6.9 KiB 0.0% MAD 6.9 KiB 0.1% MAD +0.4%

Process runtime reference change from base

Metric Base Base variability PR PR variability Difference
Start and stop a raw generic process 19,617 processes/s 2.0% MAD 20,842 processes/s 0.9% MAD +6.2%
Start and stop a raw compiled process 67,123 processes/s 0.8% MAD 68,985 processes/s 0.4% MAD +2.8%

Regression guard

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

Versions and interpretation
  • Effect Machine: 0.44.0

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 and others added 2 commits October 23, 2026 13:18
Plan synchronously drained messages from the process context directly and
build the effectful continuation only when a message needs it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
An idle machine has an empty mailbox, so a send hands its message to the
synchronous drain directly instead of queueing and polling it back.
Messages sent during initialization stay queued for its final drain.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@SandroMaglione
SandroMaglione merged commit 10b1458 into main Oct 23, 2026
9 checks passed
@SandroMaglione
SandroMaglione deleted the fix/synchronous-idle-delivery branch October 23, 2026 04:53
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