diff --git a/src/Fable.Transforms/Python/Fable2Python.Util.fs b/src/Fable.Transforms/Python/Fable2Python.Util.fs index 40fe337adb..566f277627 100644 --- a/src/Fable.Transforms/Python/Fable2Python.Util.fs +++ b/src/Fable.Transforms/Python/Fable2Python.Util.fs @@ -329,12 +329,11 @@ module Util = && List.exists2 mismatches expectedArgs actualArgs | _ -> false + // Generic-to-unit callback inputs need the typing adapter both directly and inside callbacks. expectedType |> Option.exists (fun expected -> match FableTransforms.uncurryType expected, FableTransforms.uncurryType argExpr.Type with - | Fable.DelegateType(expectedArgs, _), Fable.DelegateType(actualArgs, _) -> - List.sameLength expectedArgs actualArgs - && List.exists2 mismatches expectedArgs actualArgs + | Fable.DelegateType _, Fable.DelegateType _ -> mismatches expected argExpr.Type | _ -> false ) diff --git a/src/fable-library-py/fable_library/async_.py b/src/fable-library-py/fable_library/async_.py index 4f82c01e59..7f08e65750 100644 --- a/src/fable-library-py/fable_library/async_.py +++ b/src/fable-library-py/fable_library/async_.py @@ -275,7 +275,12 @@ def from_continuations_unit( ) -> Async[None]: def callback(conts: Continuations[None]) -> None: success, error, cancelled = conts - f((lambda: success(None), error, cancelled)) + + # Generic callers pass None explicitly; specialized F# unit callers erase it. + def unit_success(value: None = None) -> None: + success(value) + + f((unit_success, error, cancelled)) return from_continuations(callback) diff --git a/src/fable-library-py/fable_library/mailbox_processor.py b/src/fable-library-py/fable_library/mailbox_processor.py index ef8bc9e734..5762d22f28 100644 --- a/src/fable-library-py/fable_library/mailbox_processor.py +++ b/src/fable-library-py/fable_library/mailbox_processor.py @@ -73,20 +73,20 @@ def post_and_async_reply[Reply](self, build_message: Callable[[AsyncReplyChannel # Track whether `reply` was actually called, since the reply value itself # may legitimately be `None` (e.g. `AsyncReplyChannel[None]` for unit). replied = False - delivered = False gate = Lock() continuation: Continuations[Any] | None = ( None # This is the continuation for the `done` callback of the awaiting poster. ) def check_completion() -> None: - nonlocal delivered + nonlocal continuation # reply may run on another thread before the poster's continuation is installed with gate: - if delivered or not replied or continuation is None: + if not replied or continuation is None: return - delivered = True deliver = continuation[0] + # Consume this waiter once, retaining the reply for later executions. + continuation = None deliver(result) def reply_callback(res: Reply): diff --git a/tests/Js/Main/AsyncTests.fs b/tests/Js/Main/AsyncTests.fs index fc17756520..2ca07f8b86 100644 --- a/tests/Js/Main/AsyncTests.fs +++ b/tests/Js/Main/AsyncTests.fs @@ -853,16 +853,31 @@ let tests = testCaseAsync "MailboxProcessor second Reply is ignored" <| fun () -> async { let log = ResizeArray() + let completed = new MailboxProcessor(fun _ -> async { return () }) let agent = MailboxProcessor>.Start(fun inbox -> async { let! channel = inbox.Receive() channel.Reply 1 channel.Reply 2 - log.Add "body done" }) + log.Add "body done" + completed.Post() }) let! reply = agent.PostAndAsyncReply(id) equal 1 reply + do! completed.Receive() equal [|"body done"|] (log.ToArray()) } + testCaseAsync "MailboxProcessor reply computation can be awaited twice" <| fun () -> + async { + let agent = MailboxProcessor>.Start(fun inbox -> async { + let! channel = inbox.Receive() + channel.Reply 42 }) + let work = agent.PostAndAsyncReply(id) + let! first = work + let! second = work + equal 42 first + equal 42 second + } + testCase "Async try .. with returns correctly from 'with' branch" <| fun () -> let work = async { try diff --git a/tests/Python/TestAsync.fs b/tests/Python/TestAsync.fs index c41e74086c..b2c1755ecd 100644 --- a/tests/Python/TestAsync.fs +++ b/tests/Python/TestAsync.fs @@ -1093,6 +1093,16 @@ let ``test TryFinally propagates downstream continuation errors once`` () = let private fromGenericCallback (callback: ('T -> unit) -> (exn -> unit) -> (OperationCanceledException -> unit) -> unit) : Async<'T> = Async.FromContinuations(fun (ok, error, cancelled) -> callback ok error cancelled) +let private forwardGenericValue (continuation: 'T -> unit) (value: 'T) = + continuation value + +[] +let ``test unit FromContinuations accepts a forwarded generic value`` () = + let work: Async = Async.FromContinuations(fun (ok, _, _) -> forwardGenericValue ok ()) + let mutable completed = false + Async.StartWithContinuations(work, (fun () -> completed <- true), raise, raise) + equal true completed + [] let ``test generic FromContinuations accepts erased unit callbacks`` () = let calls = ResizeArray() diff --git a/tests/Python/TestMailboxProcessor.fs b/tests/Python/TestMailboxProcessor.fs index e085dbb11c..9eeac160b2 100644 --- a/tests/Python/TestMailboxProcessor.fs +++ b/tests/Python/TestMailboxProcessor.fs @@ -322,12 +322,28 @@ let ``test MailboxProcessor postAndAsyncReply can compute reply from state`` () let ``test MailboxProcessor second Reply is ignored`` () = async { let log = ResizeArray() + let completed = new MailboxProcessor(fun _ -> async { return () }) let agent = MailboxProcessor>.Start(fun inbox -> async { let! channel = inbox.Receive() channel.Reply 1 channel.Reply 2 - log.Add "body done" }) + log.Add "body done" + completed.Post() }) let! reply = agent.PostAndAsyncReply(id) equal 1 reply + do! completed.Receive() equal [|"body done"|] (log.ToArray()) - } |> Async.StartImmediate + } |> Async.RunSynchronously + +[] +let ``test MailboxProcessor reply computation can be awaited twice`` () = + async { + let agent = MailboxProcessor>.Start(fun inbox -> async { + let! channel = inbox.Receive() + channel.Reply 42 }) + let work = agent.PostAndAsyncReply(id) + let! first = work + let! second = work + equal 42 first + equal 42 second + } |> Async.RunSynchronously diff --git a/tests/Python/test_async_runtime.py b/tests/Python/test_async_runtime.py index b3cf21a3c9..72ad4a3ccb 100644 --- a/tests/Python/test_async_runtime.py +++ b/tests/Python/test_async_runtime.py @@ -462,6 +462,22 @@ def callback( assert calls == [channel] +@pytest.mark.parametrize("explicit_unit", [False, True]) +def test_unit_from_continuations_accepts_erased_and_explicit_unit(explicit_unit: bool) -> None: + from fable_library.async_ import from_continuations_unit + + calls: list[object] = [] + + def callback(conts: tuple[Callable[..., None], object, object]) -> None: + if explicit_unit: + conts[0](None) + else: + conts[0]() + + start_with_continuations(from_continuations_unit(callback), calls.append, lambda e: calls.append(("error", e))) + assert calls == [None] + + def test_unit_from_continuations_rejects_duplicate_completion() -> None: from fable_library.async_ import from_continuations_unit @@ -552,6 +568,35 @@ def reply() -> None: assert all(not thread.is_alive() for thread in threads) assert failures == [] assert calls == [42] + start_with_continuations(work, calls.append, lambda e: failures.append(e), lambda e: failures.append(e)) + assert failures == [] + assert calls == [42, 42] + + +@pytest.mark.parametrize("value", [None, 42]) +@pytest.mark.parametrize("reply_before_start", [False, True]) +def test_post_and_async_reply_can_be_reused(value: int | None, reply_before_start: bool) -> None: + from fable_library.mailbox_processor import AsyncReplyChannel, MailboxProcessor + + channels: list[AsyncReplyChannel[int | None]] = [] + calls: list[object] = [] + mailbox: MailboxProcessor[AsyncReplyChannel[int | None]] = MailboxProcessor(lambda _: lambda _: None) + + def build(channel: AsyncReplyChannel[int | None]) -> AsyncReplyChannel[int | None]: + channels.append(channel) + return channel + + work = mailbox.post_and_async_reply(build) + if reply_before_start: + channels[0].reply(value) + start_with_continuations(work, calls.append, lambda e: calls.append(("error", e))) + if not reply_before_start: + channels[0].reply(value) + assert calls == [value] + + start_with_continuations(work, calls.append, lambda e: calls.append(("error", e))) + assert calls == [value, value] + assert len(channels) == 1 def test_post_and_async_reply_ignores_a_second_reply() -> None: