feat(beam): implement Async.StartChild in fable_async runtime#4760
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The gap
The Beam backend already emits
fable_async:start_child/1,2forAsync.StartChild(computation[, millisecondsTimeout])(via the catch-all inBeam/Replacements.fs+ snake_case conversion), but the runtime function did not exist insrc/fable-library-beam/fable_async.erl. Any F# usingAsync.StartChildtherefore crashed at runtime with:This surfaced downstream in the Scriptorium test runner (
Scriptorium.Quill), whose per-test timeout helper compiles toAsync.StartChild(computation, ms).No compiler change is needed — confirmed via quicktest that the correct arities are already emitted (
start_child/1for no-timeout,start_child/2for timeout). This PR is runtime-library only.Implementation
Added
start_child/1,2tofable_async.erl(export, specs, body). The child runs the computation to completion in its own process viarun_synchronously, then becomes a small result holder that serves the result on demand: each awaiter sends its own pid tagged with a uniqueResultRef, and the holder replies to that pid. The returned inner async sends such a request and blocks onreceive, with anafter TimeoutMsclause 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:StartChild— e.g. awaiting the child inside anAsync.Parallelworker (a separate BEAM process) works instead of deadlocking.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:
#{message => Msg}maps with no__typetag, so:? TimeoutExceptioncannot match — asserting on.Messageis the correct idiom (and matches how Quill consumes it).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 onget(Token). This matchesparallel/1; making tokens cross-process is a larger follow-up affecting both.Tests
Six tests in
tests/Beam/AsyncTests.fs: basiclet! 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 (viaAsync.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_childtests.🤖 Generated with Claude Code