Skip to content
Draft
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
14 changes: 14 additions & 0 deletions src/Fable.Transforms/Python/Replacements.fs
Original file line number Diff line number Diff line change
Expand Up @@ -3693,6 +3693,9 @@ let asyncs com (ctx: Context) r t (i: CallInfo) (_: Expr option) (args: Expr lis
| "Start" ->
Helper.LibCall(com, "async_", "start", t, args, i.SignatureArgTypes, ?loc = r)
|> Some
| "StartChild" ->
Helper.LibCall(com, "async_", "start_child_linked", t, args, i.SignatureArgTypes, ?loc = r)
|> Some
// Make sure cancellationToken is called as a function and not a getter
| "get_CancellationToken" -> Helper.LibCall(com, "async_", "cancellationToken", t, [], ?loc = r) |> Some
// `catch` cannot be used as a function name in JS
Expand Down Expand Up @@ -3793,6 +3796,16 @@ let taskBuilder (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: Expr
Helper.LibCall(com, "task_builder", Naming.lowerFirst meth, t, args, i.SignatureArgTypes, ?loc = r)
|> Some

let taskBuilderAsync (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: Expr option) (args: Expr list) =
let startAsTask computation =
Helper.LibCall(com, "async_", "start_as_task", Any, [ computation ], ?loc = r)

match i.CompiledName, args with
| "TaskBuilderBase.Bind", [ computation; binder ] ->
taskBuilder com ctx r t i thisArg [ startAsTask computation; binder ]
| "TaskBuilderBase.ReturnFrom", [ computation ] -> taskBuilder com ctx r t i thisArg [ startAsTask computation ]
| _ -> None

let guids
(com: ICompiler)
(ctx: Context)
Expand Down Expand Up @@ -4276,6 +4289,7 @@ let private replacedModules =
"Microsoft.FSharp.Control.TaskBuilderBase", taskBuilder
"Microsoft.FSharp.Control.TaskBuilderModule", taskBuilder
"Microsoft.FSharp.Control.TaskBuilderExtensions.HighPriority", taskBuilder
"Microsoft.FSharp.Control.TaskBuilderExtensions.MediumPriority", taskBuilderAsync
"Microsoft.FSharp.Control.TaskBuilderExtensions.LowPriority", taskBuilder
Types.guid, guids
"System.Uri", uris
Expand Down
114 changes: 114 additions & 0 deletions src/fable-library-py/fable_library/async_.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
from .task import TaskCompletionSource
from .time_span import TimeSpan, to_milliseconds
from .types import UNIT, Unit
from .util import to_iterable


def cancellation_token() -> Async[CancellationToken]:
Expand Down Expand Up @@ -193,6 +194,75 @@ def to_array(results: list[T]) -> Async[Array[T]]:
return delay(delayed)


def choice[T](computations: IEnumerable_1[Async[T | None]]) -> Async[T | None]:
def cont(ctx: IAsyncContext[T | None]) -> None:
works = list(to_iterable(computations))
if not works:
ctx.on_success(None)
return
token = CancellationToken()

def cancel_children() -> None:
try:
token.cancel()
except Exception:
pass

async def run_choice() -> T | None:
registration = ctx.cancel_token.register(cancel_children)
tasks = [asyncio.ensure_future(start_as_task(work, cancellation_token=token)) for work in works]
# Observe sibling failures after a winner cancels them.
asyncio.gather(*tasks, return_exceptions=True)
try:
for completed in asyncio.as_completed(tasks):
result = await completed
if result is not None:
cancel_children()
return result
return None
except Exception:
cancel_children()
raise
finally:
registration.Dispose()

await_task(run_choice())(ctx)

return protected_cont(cont)


class _OnCancelRegistration:
def __init__(self, token: CancellationToken, interruption: Callable[[], None]) -> None:
self._token = token
self._interruption = interruption
self._interrupted = False
self._gate = RLock()
self._listener_id = token.add_listener(self._interrupt)

def _interrupt(self) -> None:
with self._gate:
if self._interrupted:
return
self._interrupted = True
self._interruption()

def Dispose(self) -> None:
self._token.remove_listener(self._listener_id)
# decision: a use! finalizer may dispose during cancellation before its callback is visited.
if self._token.is_cancelled:
try:
self._interrupt()
except Exception:
pass


def on_cancel(interruption: Callable[[], None]) -> Async[_OnCancelRegistration]:
def cont(ctx: IAsyncContext[_OnCancelRegistration]) -> None:
ctx.on_success(_OnCancelRegistration(ctx.cancel_token, interruption))

return protected_cont(cont)


def sequential[T](computations: IEnumerable_1[Async[T]]) -> Async[Array[T]]:
def delayed() -> Async[Array[T]]:
results: list[T] = []
Expand Down Expand Up @@ -426,6 +496,12 @@ def cancel(_: OperationCanceledError) -> None:
return tcs.get_task()


def start_immediate_as_task[T](
computation: Async[T], cancellation_token: CancellationToken | None = None
) -> Awaitable[T]:
return start_as_task(computation, cancellation_token=cancellation_token)


def _start_child_timeout[T](computation: Async[T], ms: int | TimeSpan) -> Async[Async[T]]:
# Return the child result when ready, or raise TimeoutException when the deadline expires.
task = asyncio.ensure_future(start_as_task(computation))
Expand All @@ -445,6 +521,33 @@ def cont(ctx: IAsyncContext[Async[T]]) -> None:
return protected_cont(cont)


def _start_child_timeout_linked[T](computation: Async[T], ms: int | TimeSpan) -> Async[Async[T]]:
def cont(ctx: IAsyncContext[Async[T]]) -> None:
# The child uses a linked token, so its deadline never cancels the parent.
child_token = CancellationToken()
registration = ctx.cancel_token.register(child_token.cancel)
task = asyncio.ensure_future(start_as_task(computation, cancellation_token=child_token))

async def with_timeout() -> T:
try:
return await asyncio.wait_for(task, timeout=to_milliseconds(ms) / 1000.0)
except TimeoutError:
# invariant: a completed child retains its exception; only deadline cancellation is translated.
if not task.cancelled():
raise
try:
child_token.cancel()
except Exception:
pass
raise TimeoutException__ctor()
finally:
registration.Dispose()

protected_return(await_task(with_timeout()))(ctx)

return protected_cont(cont)


def start_child[T](computation: Async[T], ms: int | TimeSpan | None = None) -> Async[Async[T]]:
# decision: share TimeoutException across callers; its native base preserves Python TimeoutError handlers
if ms is not None:
Expand All @@ -458,6 +561,17 @@ def cont(ctx: IAsyncContext[Async[T]]) -> None:
return protected_cont(cont)


def start_child_linked[T](computation: Async[T], ms: int | TimeSpan | None = None) -> Async[Async[T]]:
if ms is not None:
return _start_child_timeout_linked(computation, ms)

def cont(ctx: IAsyncContext[Async[T]]) -> None:
task = start_as_task(computation, cancellation_token=ctx.cancel_token)
protected_return(await_task(task))(ctx)

return protected_cont(cont)


def start_immediate(
computation: Async[Any],
cancellation_token: CancellationToken | None = None,
Expand Down
38 changes: 38 additions & 0 deletions src/fable-library-py/fable_library/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@
from collections.abc import Awaitable
from typing import Any, TypeVar

from .async_builder import CancellationToken, TaskCanceledError
from .system import ArgumentOutOfRangeException__ctor_Z721C83C5
from .time_span import TimeSpan, to_milliseconds


_T = TypeVar("_T")

Expand Down Expand Up @@ -71,6 +75,39 @@ async def from_result[T](value: T) -> T:
return value


async def delay(milliseconds_delay: int | TimeSpan, cancellation_token: CancellationToken | None = None) -> None:
milliseconds = to_milliseconds(milliseconds_delay)
if milliseconds < -1:
raise ArgumentOutOfRangeException__ctor_Z721C83C5("millisecondsDelay")

loop = asyncio.get_running_loop()
done: Future[None] = loop.create_future()

def cancel() -> None:
def settle() -> None:
if not done.done():
done.set_exception(TaskCanceledError())

loop.call_soon_threadsafe(settle)

def complete() -> None:
if not done.done():
if cancellation_token is not None and cancellation_token.is_cancelled:
done.set_exception(TaskCanceledError())
else:
done.set_result(None)

registration = cancellation_token.register(cancel) if cancellation_token is not None else None
timer = loop.call_later(milliseconds / 1000.0, complete) if milliseconds >= 0 else None
try:
await done
finally:
if timer is not None:
timer.cancel()
if registration is not None:
registration.Dispose()


def get_awaiter[T](value: Awaitable[T]) -> Awaitable[T]:
# Wrap awaitable in coroutine (so we can run it using create_task)
async def get_value() -> T:
Expand Down Expand Up @@ -113,6 +150,7 @@ async def runner():

__all__ = [
"TaskCompletionSource",
"delay",
"from_result",
"get_awaiter",
"get_result",
Expand Down
67 changes: 67 additions & 0 deletions tests/Python/TestAsync.fs
Original file line number Diff line number Diff line change
Expand Up @@ -808,6 +808,73 @@ let ``test Async.StartChild applies timeout`` () =
equal "BC" x
} |> Async.RunSynchronously

[<Fact>]
let ``test Async.StartChild timeout cancels child without cancelling parent`` () =
async {
let mutable completed = false
let child = async {
do! Async.Sleep 200
completed <- true
}
let! inner = Async.StartChild(child, 50)
let! result = Async.Catch inner
match result with
| Choice1Of2 _ -> failwith "Expected timeout"
| Choice2Of2 error -> (error :? TimeoutException) |> equal true
let! token = Async.CancellationToken
token.IsCancellationRequested |> equal false
do! Async.Sleep 250
completed |> equal false
} |> Async.RunSynchronously

[<Fact>]
let ``test Async.Choice returns first Some and stops siblings`` () =
async {
let mutable slowCompleted = false
let slow = async {
do! Async.Sleep 200
slowCompleted <- true
return Some 2
}
let fast = async {
do! Async.Sleep 20
return Some 1
}
let! winner = Async.Choice [ slow; async { return None }; fast ]
winner |> equal (Some 1)
do! Async.Sleep 250
slowCompleted |> equal false
let! empty = Async.Choice([]: Async<int option> list)
empty |> equal None
} |> Async.RunSynchronously

[<Fact>]
let ``test Async.OnCancel invokes only active registration`` () =
async {
use source = new CancellationTokenSource()
let mutable active = 0
let mutable disposed = 0
Async.StartImmediate(async {
use! _ = Async.OnCancel(fun () -> active <- active + 1)
let! registration = Async.OnCancel(fun () -> disposed <- disposed + 1)
registration.Dispose()
do! Async.Sleep 200
}, source.Token)
do! Async.Sleep 20
source.Cancel()
do! Async.Sleep 20
active |> equal 1
disposed |> equal 0
} |> Async.RunSynchronously

[<Fact>]
let ``test Async.StartImmediateAsTask completes`` () =
async {
let task = Async.StartImmediateAsTask(async { return 42 })
let! result = Async.AwaitTask task
result |> equal 42
} |> Async.RunSynchronously

[<Fact>]
let ``test Async.StartChild with timeout completes when computation finishes before timeout`` () = // See #4481
async {
Expand Down
36 changes: 36 additions & 0 deletions tests/Python/TestTask.fs
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,42 @@ let ``test TaskCompletionSource is executed correctly`` () =

equal 42 result

[<Fact>]
let ``test Task.Delay supports milliseconds and TimeSpan`` () =
let task = task {
do! Task.Delay 10
do! Task.Delay(TimeSpan.FromMilliseconds 10.0)
return 42
}
task.GetAwaiter().GetResult() |> equal 42

[<Fact>]
let ``test Task.Delay observes cancellation`` () =
use source = new System.Threading.CancellationTokenSource()
let delayed = Task.Delay(1_000, source.Token)
source.Cancel()
let task = task {
try
do! delayed
return false
with :? System.Threading.Tasks.TaskCanceledException -> return true
}
task.GetAwaiter().GetResult() |> equal true

[<Fact>]
let ``test task can bind and return an Async`` () =
let work = async {
do! Async.Sleep 10
return 41
}
let bound = task {
let! value = work
return value + 1
}
bound.GetAwaiter().GetResult() |> equal 42
let returned = task { return! async { return 42 } }
returned.GetAwaiter().GetResult() |> equal 42

// Regression: async functions returning Task from if/else branches must await both branches
let skipPipeline () : Task<int option> = Task.FromResult None

Expand Down
Loading