From 5f57ab5327c9027e9208ffd03fb4361c240aa440 Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Fri, 16 Oct 2026 21:56:10 +0200 Subject: [PATCH 1/3] fix(python): complete Async and Task APIs --- src/Fable.Transforms/Python/Replacements.fs | 11 ++ src/fable-library-py/fable_library/async_.py | 108 ++++++++++++++++--- src/fable-library-py/fable_library/task.py | 38 +++++++ tests/Python/TestAsync.fs | 67 ++++++++++++ tests/Python/TestTask.fs | 36 +++++++ 5 files changed, 247 insertions(+), 13 deletions(-) diff --git a/src/Fable.Transforms/Python/Replacements.fs b/src/Fable.Transforms/Python/Replacements.fs index 5ef2df57fe..a3877fa7c7 100644 --- a/src/Fable.Transforms/Python/Replacements.fs +++ b/src/Fable.Transforms/Python/Replacements.fs @@ -3793,6 +3793,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) @@ -4276,6 +4286,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 diff --git a/src/fable-library-py/fable_library/async_.py b/src/fable-library-py/fable_library/async_.py index 462b951e8e..ca319af5fe 100644 --- a/src/fable-library-py/fable_library/async_.py +++ b/src/fable-library-py/fable_library/async_.py @@ -193,6 +193,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(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] = [] @@ -426,20 +495,34 @@ def cancel(_: OperationCanceledError) -> None: return tcs.get_task() -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)) +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) - 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 - raise TimeoutException__ctor() +def _start_child_timeout[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) @@ -450,9 +533,8 @@ def start_child[T](computation: Async[T], ms: int | TimeSpan | None = None) -> A if ms is not None: return _start_child_timeout(computation, ms) - task = start_as_task(computation) - 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) diff --git a/src/fable-library-py/fable_library/task.py b/src/fable-library-py/fable_library/task.py index a8752d3a47..f816fc4db7 100644 --- a/src/fable-library-py/fable_library/task.py +++ b/src/fable-library-py/fable_library/task.py @@ -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") @@ -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: @@ -113,6 +150,7 @@ async def runner(): __all__ = [ "TaskCompletionSource", + "delay", "from_result", "get_awaiter", "get_result", diff --git a/tests/Python/TestAsync.fs b/tests/Python/TestAsync.fs index 6d8cd7002d..054999bf81 100644 --- a/tests/Python/TestAsync.fs +++ b/tests/Python/TestAsync.fs @@ -808,6 +808,73 @@ let ``test Async.StartChild applies timeout`` () = equal "BC" x } |> Async.RunSynchronously +[] +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 + +[] +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 list) + empty |> equal None + } |> Async.RunSynchronously + +[] +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 + +[] +let ``test Async.StartImmediateAsTask completes`` () = + async { + let task = Async.StartImmediateAsTask(async { return 42 }) + let! result = Async.AwaitTask task + result |> equal 42 + } |> Async.RunSynchronously + [] let ``test Async.StartChild with timeout completes when computation finishes before timeout`` () = // See #4481 async { diff --git a/tests/Python/TestTask.fs b/tests/Python/TestTask.fs index e505f3b79f..57173260a4 100644 --- a/tests/Python/TestTask.fs +++ b/tests/Python/TestTask.fs @@ -168,6 +168,42 @@ let ``test TaskCompletionSource is executed correctly`` () = equal 42 result +[] +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 + +[] +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 + +[] +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 = Task.FromResult None From 987f0eac8d9c5d296a541c0fc76eced2ebf81509 Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Fri, 16 Oct 2026 22:06:32 +0200 Subject: [PATCH 2/3] fix(python): preserve published StartChild runtime behavior --- src/Fable.Transforms/Python/Replacements.fs | 3 ++ src/fable-library-py/fable_library/async_.py | 31 ++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/src/Fable.Transforms/Python/Replacements.fs b/src/Fable.Transforms/Python/Replacements.fs index a3877fa7c7..663097b10f 100644 --- a/src/Fable.Transforms/Python/Replacements.fs +++ b/src/Fable.Transforms/Python/Replacements.fs @@ -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 diff --git a/src/fable-library-py/fable_library/async_.py b/src/fable-library-py/fable_library/async_.py index ca319af5fe..8d8d40f3f6 100644 --- a/src/fable-library-py/fable_library/async_.py +++ b/src/fable-library-py/fable_library/async_.py @@ -502,6 +502,25 @@ def start_immediate_as_task[T]( 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)) + + 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 + raise TimeoutException__ctor() + + def cont(ctx: IAsyncContext[Async[T]]) -> None: + protected_return(await_task(with_timeout()))(ctx) + + 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() @@ -533,6 +552,18 @@ def start_child[T](computation: Async[T], ms: int | TimeSpan | None = None) -> A if ms is not None: return _start_child_timeout(computation, ms) + task = start_as_task(computation) + + def cont(ctx: IAsyncContext[Async[T]]) -> None: + protected_return(await_task(task))(ctx) + + 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) From 068afbc8fc9cce13cbb342fe0e366e76efa141a3 Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Fri, 16 Oct 2026 22:14:26 +0200 Subject: [PATCH 3/3] fix(python): adapt Async.Choice enumerable for Pyright --- src/fable-library-py/fable_library/async_.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/fable-library-py/fable_library/async_.py b/src/fable-library-py/fable_library/async_.py index 8d8d40f3f6..034137e0cb 100644 --- a/src/fable-library-py/fable_library/async_.py +++ b/src/fable-library-py/fable_library/async_.py @@ -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]: @@ -195,7 +196,7 @@ def to_array(results: list[T]) -> Async[Array[T]]: def choice[T](computations: IEnumerable_1[Async[T | None]]) -> Async[T | None]: def cont(ctx: IAsyncContext[T | None]) -> None: - works = list(computations) + works = list(to_iterable(computations)) if not works: ctx.on_success(None) return