Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions src/Fable.Transforms/Python/Fable2Python.Util.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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
)

Expand Down
7 changes: 6 additions & 1 deletion src/fable-library-py/fable_library/async_.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
8 changes: 4 additions & 4 deletions src/fable-library-py/fable_library/mailbox_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
17 changes: 16 additions & 1 deletion tests/Js/Main/AsyncTests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -853,16 +853,31 @@ let tests =
testCaseAsync "MailboxProcessor second Reply is ignored" <| fun () ->
async {
let log = ResizeArray<string>()
let completed = new MailboxProcessor<unit>(fun _ -> async { return () })
let agent = MailboxProcessor<AsyncReplyChannel<int>>.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<AsyncReplyChannel<int>>.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
Expand Down
10 changes: 10 additions & 0 deletions tests/Python/TestAsync.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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

[<Fact>]
let ``test unit FromContinuations accepts a forwarded generic value`` () =
let work: Async<unit> = Async.FromContinuations(fun (ok, _, _) -> forwardGenericValue ok ())
let mutable completed = false
Async.StartWithContinuations(work, (fun () -> completed <- true), raise, raise)
equal true completed

[<Fact>]
let ``test generic FromContinuations accepts erased unit callbacks`` () =
let calls = ResizeArray<string>()
Expand Down
20 changes: 18 additions & 2 deletions tests/Python/TestMailboxProcessor.fs
Original file line number Diff line number Diff line change
Expand Up @@ -322,12 +322,28 @@ let ``test MailboxProcessor postAndAsyncReply can compute reply from state`` ()
let ``test MailboxProcessor second Reply is ignored`` () =
async {
let log = ResizeArray<string>()
let completed = new MailboxProcessor<unit>(fun _ -> async { return () })
let agent = MailboxProcessor<AsyncReplyChannel<int>>.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

[<Fact>]
let ``test MailboxProcessor reply computation can be awaited twice`` () =
async {
let agent = MailboxProcessor<AsyncReplyChannel<int>>.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
45 changes: 45 additions & 0 deletions tests/Python/test_async_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
Loading