Skip to content

Bind started machines to the caller's Scope - #235

Merged
SandroMaglione merged 7 commits into
mainfrom
scope-bound-machine-start
Oct 22, 2026
Merged

SandroMaglione merged 7 commits into
mainfrom
scope-bound-machine-start

Conversation

@SandroMaglione

@SandroMaglione SandroMaglione commented Oct 22, 2026 •

Copy link
Copy Markdown
Member

Summary

  • Machine.start, Machine.resume, and prepared.start now require Scope.Scope, matching Effect.forkScoped. Closing the scope is equivalent to ref.stop: active invocations are interrupted, owned children stop, the snapshot becomes stopped, and later sends fail with StoppedError.
  • Each owning scope carries one ownership record with a single finalizer and a live set of machines, stopped in reverse start order when the scope closes. The runtime registers a machine when it is ready and releases it synchronously when it terminates, so an earlier ref.stop or completion leaves no finalizer behind and ownership costs one set entry per machine. For prepared.start, the scope of the first evaluation owns the machine. Starting inside an already closed scope stops the machine immediately.
  • Internal: StartInternalOptions.onStopSync now receives the terminated ref, so the record can release it without a per-machine closure.
  • AtomMachine already acquires machines inside a scope and is unaffected. README, agent guide, and JSDoc examples now show Effect.scoped and the explicit Scope.make / Scope.provide pattern for a machine that must outlive its caller. Benchmarks start machines through one shared service context with a long-lived scope, as a program would.
  • Tests: scope closure for start, resume, and prepared start across stream, effect, and invoked-child work; stop-before-close and completion-before-close; first-evaluator ownership of prepared.start; explicit detached lifetime; generic-versus-compiled strategy coverage; single-finalizer ownership, reverse-order stopping, and closed-scope starts; typetests for the new requirement and Atom bridge acceptance.

This resolves the leak reported by a production migration, where machines started inside Effect.scoped kept running with their streams, effects, timers, and children after the scope closed.

Changeset

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

Minor: public signatures of start, resume, and prepare change (breaking, pre-1.0).

Validation

  • pnpm check — all steps pass locally except format:check, which could not download the dprint plugins through this environment's proxy; CI's format check passes.
  • Automated type-performance measurement passed or was not required — flat (at most +2 instantiations).
  • Automated runtime- and memory-performance measurement passed or was not required — start-and-stop throughput -9.5% and heap at most +4.7%, within the regression guard; all other scenarios within 1.5%.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MHnF2EL6ReDTKwoM9TVH7L

`Machine.start`, `Machine.resume`, and `prepared.start` now require
`Scope.Scope`. Closing that scope stops the machine like `ref.stop`, and a
machine that stops or completes earlier releases its place in the scope.
`AtomMachine` is unaffected.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHnF2EL6ReDTKwoM9TVH7L
Co-Authored-By: Claude Fable 5.1 <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,167 60,167 0 (0.0%)
atomic construction, guards, and reentry 85,404 85,404 0 (0.0%)
registered invocation (input-taking Effect) 39,707 39,707 0 (0.0%)
machine.handle (depth 24) 194,528 194,528 0 (0.0%)
machine.handle (wide depth 16) 218,235 218,235 0 (0.0%)
machine.handle (parallel/history/choice) 91,635 91,635 0 (0.0%)
machine definition (3 independent implementations) 98,227 98,227 0 (0.0%)
machine exact input/output/error/services 82,793 82,794 +1 (+0.0%)
execution adapter readiness 71,418 71,420 +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,614 51,614 0 (0.0%)
atomic construction, guards, and reentry 70,064 70,064 0 (0.0%)
registered invocation (input-taking Effect) 32,570 32,570 0 (0.0%)
machine.handle (depth 24) 177,219 177,219 0 (0.0%)
machine.handle (wide depth 16) 201,704 201,704 0 (0.0%)
machine.handle (parallel/history/choice) 79,137 79,137 0 (0.0%)
machine definition (3 independent implementations) 87,546 87,546 0 (0.0%)
machine exact input/output/error/services 70,584 70,585 +1 (+0.0%)
execution adapter readiness 31,920 31,922 +2 (+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.15s 0.15s
machine.handle (3 states, 2 transitions) 0.31s 0.31s
declared transition (10 named branches) 0.41s 0.41s
atomic construction, guards, and reentry 0.53s 0.54s
registered invocation (input-taking Effect) 0.32s 0.31s
machine.handle (depth 24) 0.66s 0.66s
machine.handle (wide depth 16) 0.74s 0.72s
machine.handle (parallel/history/choice) 0.53s 0.51s
machine definition (3 independent implementations) 0.58s 0.55s
machine exact input/output/error/services 0.53s 0.50s
execution adapter readiness 0.46s 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 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.22.0.

Pull request baseline

Scenario Effect Machine
Plan counter transitions 116,669 transitions/s
Drain burst with terminal fence 407,989 increments/s
Drain burst with a change observer 380,906 increments/s
Lookup and send to one child 333,943 increments/s
Start and stop a machine 117,014 machines/s
Start and stop a parent with one child 22,877 families/s
Complete an Effect invocation with tracing enabled 7,045 invocations/s
Complete an Effect invocation with tracing disabled 8,631 invocations/s
Plan transitions through a compound state 102,055 transitions/s
Plan transitions through parallel regions 81,503 transitions/s
Drain burst through a compound state 349,337 events/s
Drain burst through two parallel regions 321,039 events/s
Drain a compound-state burst with a change observer 324,572 events/s

Process runtime reference points

Scenario Effect Machine
Start and stop a raw generic process 19,096 processes/s
Start and stop a raw compiled process 67,622 processes/s
Memory profile Effect Machine
Idle machine 1.9 KiB
Raw generic managed process 11.7 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 116,450 transitions/s 1.5% MAD 116,669 transitions/s 0.5% MAD +0.2%
Drain burst with terminal fence 409,135 increments/s 0.9% MAD 407,989 increments/s 0.4% MAD -0.3%
Drain burst with a change observer 383,493 increments/s 1.2% MAD 380,906 increments/s 0.3% MAD -0.7%
Lookup and send to one child 332,597 increments/s 0.7% MAD 333,943 increments/s 0.5% MAD +0.4%
Start and stop a machine 129,299 machines/s 0.4% MAD 117,014 machines/s 0.9% MAD -9.5%
Start and stop a parent with one child 24,669 families/s 3.2% MAD 22,877 families/s 0.6% MAD -7.3%
Complete an Effect invocation with tracing enabled 7,210 invocations/s 0.6% MAD 7,045 invocations/s 0.5% MAD -2.3%
Complete an Effect invocation with tracing disabled 8,779 invocations/s 1.1% MAD 8,631 invocations/s 1.6% MAD -1.7%
Plan transitions through a compound state 103,349 transitions/s 1.5% MAD 102,055 transitions/s 0.6% MAD -1.3%
Plan transitions through parallel regions 82,316 transitions/s 1.4% MAD 81,503 transitions/s 1.2% MAD -1.0%
Drain burst through a compound state 352,326 events/s 0.1% MAD 349,337 events/s 0.4% MAD -0.8%
Drain burst through two parallel regions 324,657 events/s 0.2% MAD 321,039 events/s 0.6% MAD -1.1%
Drain a compound-state burst with a change observer 327,856 events/s 0.2% MAD 324,572 events/s 0.4% MAD -1.0%
Idle machine heap per unit 1.8 KiB 1.3% MAD 1.9 KiB 0.3% MAD +4.7%
Raw generic managed process heap per unit 11.4 KiB 0.0% MAD 11.7 KiB 0.0% MAD +2.6%
Raw compiled process heap per unit 3.0 KiB 0.5% MAD 3.1 KiB 0.0% MAD +2.4%
Two independent idle machines heap per unit 3.4 KiB 1.0% MAD 3.5 KiB 0.2% MAD +3.2%
Idle parent with one child heap per unit 5.9 KiB 0.0% MAD 6.0 KiB 0.0% MAD +0.4%
Parent with observed child registry heap per unit 9.4 KiB 0.0% MAD 9.5 KiB 0.0% MAD +0.6%
Parent with observed invoked child snapshots heap per unit 6.8 KiB 0.2% MAD 6.9 KiB 0.0% MAD +0.8%

Process runtime reference change from base

Metric Base Base variability PR PR variability Difference
Start and stop a raw generic process 21,314 processes/s 2.4% MAD 19,096 processes/s 2.0% MAD -10.4%
Start and stop a raw compiled process 72,537 processes/s 1.1% MAD 67,622 processes/s 1.5% MAD -6.8%

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.

Fork the owner scope only after a successful start, register the ref's
own stop effect, and close the owner scope synchronously on termination.
This avoids a per-machine copy of the services map and per-start closures.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHnF2EL6ReDTKwoM9TVH7L
Each owning Scope keeps one finalizer and a live set. The runtime registers a
machine when it is ready and releases it synchronously when it terminates, so
ownership costs one set entry per machine. Benchmarks start machines through
one shared service context with a long-lived scope.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHnF2EL6ReDTKwoM9TVH7L
The ownership record's ready hook refuses a machine once its scope closed,
so the runtime stops it itself and the start path needs no extra step.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHnF2EL6ReDTKwoM9TVH7L
@SandroMaglione
SandroMaglione merged commit 71d41b9 into main Oct 22, 2026
9 checks passed
@SandroMaglione
SandroMaglione deleted the scope-bound-machine-start branch October 22, 2026 10:15
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