Skip to content

feat(beam): implement Async.StartChild in fable_async runtime#4760

Merged
dbrattli merged 2 commits into
mainfrom
feat/beam-async-start-child
Jul 10, 2026
Merged

feat(beam): implement Async.StartChild in fable_async runtime#4760
dbrattli merged 2 commits into
mainfrom
feat/beam-async-start-child

Conversation

@dbrattli

@dbrattli dbrattli commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

The gap

The Beam backend already emits fable_async:start_child/1,2 for Async.StartChild(computation[, millisecondsTimeout]) (via the catch-all in Beam/Replacements.fs + snake_case conversion), but the runtime function did not exist in src/fable-library-beam/fable_async.erl. Any F# using Async.StartChild therefore crashed at runtime with:

{undef,[{fable_async,start_child,2,[]}, ...]}

This surfaced downstream in the Scriptorium test runner (Scriptorium.Quill), whose per-test timeout helper compiles to Async.StartChild(computation, ms).

No compiler change is needed — confirmed via quicktest that the correct arities are already emitted (start_child/1 for no-timeout, start_child/2 for timeout). This PR is runtime-library only.

Implementation

Added start_child/1,2 to fable_async.erl (export, specs, body). The child runs the computation to completion in its own process via run_synchronously, then becomes a small result holder that serves the result on demand: each awaiter sends its own pid tagged with a unique ResultRef, and the holder replies to that pid. The returned inner async sends such a request and blocks on receive, with an after TimeoutMs clause that kills the child, flushes a possibly-raced reply, and raises #{message => <<"The operation has timed out."/utf8>>}.

The request/reply design (rather than the child eagerly messaging a captured self()) means the inner async matches .NET semantics in two ways that a naive version misses:

  • Awaitable from any process, not only the one that called StartChild — e.g. awaiting the child inside an Async.Parallel worker (a separate BEAM process) works instead of deadlocking.
  • Awaitable any number of times — the holder loops and replies to each request, instead of a single reply being consumed by the first await.

Trade-off: a child whose result is never awaited leaves an idle holder process behind (documented in a code comment).

Two Beam-specific details verified rather than assumed:

  • Exception shape: on Beam, all built-in .NET exceptions compile to bare #{message => Msg} maps with no __type tag, so :? TimeoutException cannot match — asserting on .Message is the correct idiom (and matches how Quill consumes it).
  • Concurrency proof: Fable Beam refs/mutables are backed by the process dictionary (process-local), so StartChild's separate processes cannot share a mutable. Concurrency is proved by timing instead.

Cancellation tokens are intentionally not propagated to the child. They are also held in the process dictionary (see fable_cancellation.erl), so a token is only meaningful within a single process and cannot be shared with the child's separate process — passing it across would crash on get(Token). This matches parallel/1; making tokens cross-process is a larger follow-up affecting both.

Tests

Six tests in tests/Beam/AsyncTests.fs: basic let! c = StartChild(...) in let! r = c → 42; concurrent execution (timing); timeout raises; completion before timeout; result awaited multiple times; and result awaited from another process (via Async.Parallel). The last two directly exercise the paths that a naive single-message implementation would hang on.

Full Beam suite: 2487 passed, 0 failed, including all six start_child tests.

🤖 Generated with Claude Code

dbrattli and others added 2 commits July 10, 2026 19:06
The Beam backend already emits fable_async:start_child/1,2 for
Async.StartChild, but the runtime function did not exist, so any F#
using it crashed with {undef,[{fable_async,start_child,...}]}.

Add start_child/1,2: spawn a process that runs the child via
run_synchronously and messages back a ResultRef-tagged result; the
returned inner async blocks on receive, with an after-timeout clause
that kills the child and raises a timeout error map. No compiler change
is needed.

Add four Beam async tests covering basic StartChild, concurrent
execution (proved by timing, since Beam refs are process-local),
timeout, and completion before timeout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…epeatedly

Replace the eager single-message design in fable_async:start_child with a
request/reply result holder. The child now runs the computation to completion
in its own process, then serves the result on demand: each awaiter sends its
own pid and the holder replies to it.

This fixes two latent issues:
- The inner async can now be awaited from any process, not only the one that
  called start_child (the previous version captured self() at start time and
  would deadlock if awaited inside e.g. an Async.Parallel worker).
- The result can be awaited any number of times, matching .NET/Python; the
  previous version consumed the single reply and hung on a second await.

Also flush a raced reply after the timeout kill so it does not linger in the
mailbox, and document why cancellation tokens are not propagated to the child
(they live in the process dictionary and cannot cross process boundaries).

Add tests for repeated await and cross-process await.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dbrattli dbrattli merged commit eb2ad9a into main Jul 10, 2026
31 checks passed
@dbrattli dbrattli deleted the feat/beam-async-start-child branch July 10, 2026 17:38
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