From b38d60b897fd5247677e235536c93192f72dd4aa Mon Sep 17 00:00:00 2001 From: Tuomas Hietanen Date: Wed, 8 Jul 2026 18:21:31 +0100 Subject: [PATCH 1/3] fix: MailboxProcessor dropping falsy messages and unit replies - Dequeued messages were tested for truthiness, so 0, false, "", null and undefined (unit) messages were silently dropped; the queue now wraps dequeued values so emptiness is distinguishable - postAndAsyncReply gated completion on result !== undefined, so AsyncReplyChannel never completed and the caller hung forever; use an explicit replied flag --- src/fable-library-ts/MailboxProcessor.ts | 18 ++++++++----- tests/Js/Main/AsyncTests.fs | 34 ++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/src/fable-library-ts/MailboxProcessor.ts b/src/fable-library-ts/MailboxProcessor.ts index fc0ed8d0ab..b78689d7c7 100644 --- a/src/fable-library-ts/MailboxProcessor.ts +++ b/src/fable-library-ts/MailboxProcessor.ts @@ -28,7 +28,7 @@ class MailboxQueue { } } - public tryGet() { + public tryGet(): { value: Msg } | undefined { if (this.firstAndLast) { const value = this.firstAndLast[0].value; if (this.firstAndLast[0].next) { @@ -36,7 +36,9 @@ class MailboxQueue { } else { delete this.firstAndLast; } - return value; + // Wrap the value so falsy messages (0, false, "", null, undefined/unit) + // can be distinguished from an empty queue. + return { value }; } return void 0; } @@ -64,11 +66,11 @@ export class MailboxProcessor { function __processEvents($this: MailboxProcessor) { if ($this.continuation) { - const value = $this.messages.tryGet(); - if (value) { + const dequeued = $this.messages.tryGet(); + if (dequeued !== void 0) { const cont = $this.continuation; delete $this.continuation; - cont(value); + cont(dequeued.value); } } } @@ -97,15 +99,19 @@ export function postAndAsyncReply( buildMessage: (c: AsyncReplyChannel) => Msg, ) { let result: Reply; + // Use an explicit flag because the reply value itself may be undefined + // (e.g. AsyncReplyChannel), which must still complete the async. + let replied = false; let continuation: Continuation; function checkCompletion() { - if (result !== void 0 && continuation !== void 0) { + if (replied && continuation !== void 0) { continuation(result); } } const reply = { reply: (res: Reply) => { result = res; + replied = true; checkCompletion(); }, }; diff --git a/tests/Js/Main/AsyncTests.fs b/tests/Js/Main/AsyncTests.fs index d94f726c3c..5bd29a6255 100644 --- a/tests/Js/Main/AsyncTests.fs +++ b/tests/Js/Main/AsyncTests.fs @@ -414,6 +414,40 @@ let tests = equal 0 resp } + testCaseAsync "MailboxProcessor delivers falsy messages in order" <| fun () -> // See #1588 + async { + let mutable collected = [] + let agent = MailboxProcessor.Start(fun inbox -> + let rec loop () = async { + let! msg = inbox.Receive() + if msg = -1 then () + else + collected <- msg :: collected + return! loop () + } + loop ()) + for msg in [1; 0; 2; 0; 3] do + agent.Post(msg) + agent.Post(-1) + do! Async.Sleep 200 + equal [1; 0; 2; 0; 3] (List.rev collected) + } + + testCaseAsync "MailboxProcessor.postAndAsyncReply completes with unit reply" <| fun () -> + async { + let mutable ran = false + let agent = MailboxProcessor>.Start(fun inbox -> + let rec loop () = async { + let! chan = inbox.Receive() + chan.Reply() + return! loop () + } + loop ()) + do! agent.PostAndAsyncReply(fun ch -> ch) + ran <- true + equal true ran + } + testCase "Async try .. with returns correctly from 'with' branch" <| fun () -> let work = async { try From 7136bc2a99abfc2535a3eb93dce49e81c8cafec4 Mon Sep 17 00:00:00 2001 From: Mangel Maxime Date: Thu, 9 Jul 2026 21:15:32 +0200 Subject: [PATCH 2/3] test(beam): add additional test for MailboxProcessor --- tests/Beam/MailboxProcessorTests.fs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/Beam/MailboxProcessorTests.fs b/tests/Beam/MailboxProcessorTests.fs index 73a8d09987..58634b919c 100644 --- a/tests/Beam/MailboxProcessorTests.fs +++ b/tests/Beam/MailboxProcessorTests.fs @@ -87,6 +87,22 @@ let ``test MailboxProcessor.postAndAsyncReply works with falsy values`` () = equal 0 resp } |> Async.StartImmediate +[] +let ``test MailboxProcessor.postAndAsyncReply completes with unit reply`` () = + async { + let mutable ran = false + let agent = MailboxProcessor>.Start(fun inbox -> + let rec loop () = async { + let! chan = inbox.Receive() + chan.Reply() + return! loop () + } + loop ()) + do! agent.PostAndAsyncReply(fun ch -> ch) + ran <- true + equal true ran + } |> Async.StartImmediate + // Message ordering [] From 2a5d1eded47852df3690b6f18f5883d5a8ff6934 Mon Sep 17 00:00:00 2001 From: Mangel Maxime Date: Thu, 9 Jul 2026 21:16:02 +0200 Subject: [PATCH 3/3] fix(python): MailboxProcessor dropping unit replies --- .../fable_library/mailbox_processor.py | 8 ++++++-- tests/Python/TestMailboxProcessor.fs | 16 ++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/fable-library-py/fable_library/mailbox_processor.py b/src/fable-library-py/fable_library/mailbox_processor.py index e55f9957e8..663635f589 100644 --- a/src/fable-library-py/fable_library/mailbox_processor.py +++ b/src/fable-library-py/fable_library/mailbox_processor.py @@ -67,17 +67,21 @@ def post_and_async_reply[Reply](self, build_message: Callable[[AsyncReplyChannel """ result: Reply | None = None + # Track whether `reply` was actually called, since the reply value itself + # may legitimately be `None` (e.g. `AsyncReplyChannel[None]` for unit). + replied = False continuation: Continuations[Any] | None = ( None # This is the continuation for the `done` callback of the awaiting poster. ) def check_completion() -> None: - if result is not None and continuation is not None: + if replied and continuation is not None: continuation[0](result) def reply_callback(res: Reply): - nonlocal result + nonlocal result, replied result = res + replied = True check_completion() reply_channel: AsyncReplyChannel[Reply] = AsyncReplyChannel(reply_callback) diff --git a/tests/Python/TestMailboxProcessor.fs b/tests/Python/TestMailboxProcessor.fs index e899539ed3..6e6ce289b1 100644 --- a/tests/Python/TestMailboxProcessor.fs +++ b/tests/Python/TestMailboxProcessor.fs @@ -86,6 +86,22 @@ let ``test MailboxProcessor.postAndAsyncReply works with falsy values`` () = equal 0 resp } |> Async.StartImmediate +[] +let ``test MailboxProcessor.postAndAsyncReply completes with unit reply`` () = + async { + let mutable ran = false + let agent = MailboxProcessor>.Start(fun inbox -> + let rec loop () = async { + let! chan = inbox.Receive() + chan.Reply() + return! loop () + } + loop ()) + do! agent.PostAndAsyncReply(fun ch -> ch) + ran <- true + equal true ran + } |> Async.StartImmediate + // Message ordering []