diff --git a/src/Fable.Cli/CHANGELOG.md b/src/Fable.Cli/CHANGELOG.md index 784242bbc2..3ce63c6306 100644 --- a/src/Fable.Cli/CHANGELOG.md +++ b/src/Fable.Cli/CHANGELOG.md @@ -31,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * [Python] Add `DateOnly` and `TimeOnly` support (by @dbrattli) * [Python] Fix `String.IndexOf`/`LastIndexOf` with `StringComparison` argument emitting it as a start-index instead of a compile error (by @repo-assist) * [Beam] Fix `String.IndexOf`/`LastIndexOf` with `StringComparison` argument incorrectly treating the enum value as a start index +* [JS/TS/Python] Fix `Async.StartChild` with timeout always timing out even when the computation finishes before the deadline (fixes #4481) (by @MangelMaxime) ## 5.0.0-rc.6 - 2026-03-31 diff --git a/src/Fable.Compiler/CHANGELOG.md b/src/Fable.Compiler/CHANGELOG.md index 6d2ace2072..43590679b1 100644 --- a/src/Fable.Compiler/CHANGELOG.md +++ b/src/Fable.Compiler/CHANGELOG.md @@ -31,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * [Python] Add `DateOnly` and `TimeOnly` support (by @dbrattli) * [Python] Fix `String.IndexOf`/`LastIndexOf` with `StringComparison` argument emitting it as a start-index instead of a compile error (by @repo-assist) * [Beam] Fix `String.IndexOf`/`LastIndexOf` with `StringComparison` argument incorrectly treating the enum value as a start index +* [JS/TS/Python] Fix `Async.StartChild` with timeout always timing out even when the computation finishes before the deadline (fixes #4481) (by @MangelMaxime) ## 5.0.0-rc.12 - 2026-03-31 diff --git a/src/fable-library-py/fable_library/async_.py b/src/fable-library-py/fable_library/async_.py index 32ca38ea2d..e33a223db2 100644 --- a/src/fable-library-py/fable_library/async_.py +++ b/src/fable-library-py/fable_library/async_.py @@ -115,19 +115,6 @@ def to_array(results: list[T]) -> Async[Array[T]]: return delay(delayed) -def parallel2[T, U](a: Async[T], b: Async[U]) -> Async[Array[T | U]]: - def delayed() -> Async[Array[T | U]]: - tasks: Iterable[Future[T | U]] = map(start_as_task, [a, b]) # type: ignore - all: Future[list[T | U]] = asyncio.gather(*tasks) - - def to_array(results: list[T | U]) -> Async[Array[T | U]]: - return protected_return(Array[T | U](results)) - - return protected_bind(await_task(all), to_array) - - return delay(delayed) - - def sequential[T](computations: IEnumerable_1[Async[T]]) -> Async[Array[T]]: def delayed() -> Async[Array[T]]: results: list[T] = [] @@ -282,34 +269,23 @@ def cancel(_: OperationCanceledError) -> None: return tcs.get_task() -def throw_after(milliseconds_due_time: int | TimeSpan) -> Async[None]: - def cont(ctx: IAsyncContext[None]) -> None: - def cancel() -> None: - ctx.on_cancel(OperationCanceledError()) - - token_id = ctx.cancel_token.add_listener(cancel) - - def timeout() -> None: - ctx.cancel_token.remove_listener(token_id) - ctx.on_error(TimeoutError()) - - due_time_ms = to_milliseconds(milliseconds_due_time) - ctx.trampoline.run_later(timeout, due_time_ms / 1000.0) - - return protected_cont(cont) - - def start_child[T](computation: Async[T], ms: int | TimeSpan | None = None) -> Async[Async[T]]: if ms is not None: + # Race the computation against a timeout: whichever settles first wins. + # asyncio.gather (the previous implementation via parallel2) waited for BOTH to settle, + # which meant the timeout always fired even when the computation finished first. + task = start_as_task(computation) - def binder(results: Array[T | None]) -> Async[T]: - # TODO: the implementation looks suspicious since we use parallel2 - # which will wait for both computations to finish - return protected_return(results[0]) # type: ignore + async def with_timeout() -> T: + try: + return await asyncio.wait_for(task, timeout=to_milliseconds(ms) / 1000.0) + except TimeoutError: + raise TimeoutError() - computation_with_timeout: Async[T] = protected_bind(parallel2(computation, throw_after(ms)), binder) + def cont(ctx: IAsyncContext[Async[T]]) -> None: + protected_return(await_task(with_timeout()))(ctx) - return start_child(computation_with_timeout) + return protected_cont(cont) task = start_as_task(computation) diff --git a/src/fable-library-ts/Async.ts b/src/fable-library-ts/Async.ts index 67c1090b42..ffb67cc81b 100644 --- a/src/fable-library-ts/Async.ts +++ b/src/fable-library-ts/Async.ts @@ -58,37 +58,25 @@ export function throwIfCancellationRequested(token: CancellationToken) { } } -function throwAfter(millisecondsDueTime: number): Async { - return protectedCont((ctx: IAsyncContext) => { - let tokenId: number; - const timeoutId = setTimeout(() => { - ctx.cancelToken.removeListener(tokenId); - ctx.onError(TimeoutException_$ctor()); - }, millisecondsDueTime); - tokenId = ctx.cancelToken.addListener(() => { - clearTimeout(timeoutId); - ctx.onCancel(new OperationCanceledException()); - }); - }); -} - export function startChild(computation: Async, ms?: number): Async> { - if (ms) { - const computationWithTimeout = protectedBind( - parallel2( - computation, - throwAfter(ms)), - xs => protectedReturn(xs[0])); + const promise = startAsPromise(computation); + let promiseToRun = promise; - return startChild(computationWithTimeout); + if (ms) { + // Race the computation against a timeout: whichever settles first wins. + promiseToRun = new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => reject(TimeoutException_$ctor()), ms); + promise.then( + value => { clearTimeout(timeoutId); resolve(value); }, + error => { clearTimeout(timeoutId); reject(error); } + ); + }); } - const promise = startAsPromise(computation); - // JS Promises are hot, computation has already started // but we delay returning the result return protectedCont((ctx) => - protectedReturn(awaitPromise(promise))(ctx)); + protectedReturn(awaitPromise(promiseToRun))(ctx)); } export function awaitPromise(p: Promise) { @@ -129,10 +117,6 @@ export function parallel(computations: Iterable>) { return delay(() => awaitPromise(Promise.all(Array.from(computations, (w) => startAsPromise(w))))); } -function parallel2(a: Async, b: Async): Async<[T, U]> { - return delay(() => awaitPromise(Promise.all([startAsPromise(a), startAsPromise(b)]))); -} - export function sequential(computations: Iterable>) { function _sequential(computations: Iterable>): Promise { diff --git a/src/fable-library-ts/CHANGELOG.md b/src/fable-library-ts/CHANGELOG.md index c7e0f2d2b0..3d3968a8c2 100644 --- a/src/fable-library-ts/CHANGELOG.md +++ b/src/fable-library-ts/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Fixed + +* [JS/TS] Fix `Async.StartChild` with timeout always timing out even when the computation finishes before the deadline (fixes #4481) (by @MangelMaxime) + ## 2.0.0-rc.5 - 2026-03-31 ### Fixed diff --git a/tests/Js/Main/AsyncTests.fs b/tests/Js/Main/AsyncTests.fs index 24ddded9cd..d80f8dc28b 100644 --- a/tests/Js/Main/AsyncTests.fs +++ b/tests/Js/Main/AsyncTests.fs @@ -591,6 +591,18 @@ let tests = equal x "ABC" } + testCaseAsync "Async.StartChild with timeout completes when computation finishes before timeout" <| fun () -> // See #4481 + async { + let fast = async { do! Async.Sleep 10 } + try + let! child = Async.StartChild(fast, 1_000) + do! child + equal true true // should reach here + with + | :? TimeoutException -> + failwith "should not time out" + } + testCaseAsync "Unit arguments are erased" <| fun () -> // See #1832 let mutable token = 0 async { diff --git a/tests/Python/TestAsync.fs b/tests/Python/TestAsync.fs index 2239524ad5..36c753cb5c 100644 --- a/tests/Python/TestAsync.fs +++ b/tests/Python/TestAsync.fs @@ -461,6 +461,43 @@ let ``test Async.StartChild works`` () = equal x "ABCDEF" } |> Async.StartImmediate +[] +let ``test Async.StartChild applies timeout`` () = + async { + let mutable x = "" + + let task = async { + x <- x + "A" + do! Async.Sleep 1_000 + x <- x + "X" // Never hit + } + + try + let! childTask = Async.StartChild (task, 200) + + do! childTask + with + | :? TimeoutException -> + x <- x + "B" + + x <- x + "C" + + equal x "ABC" + } |> Async.StartImmediate + +[] +let ``test Async.StartChild with timeout completes when computation finishes before timeout`` () = // See #4481 + async { + let fast = async { do! Async.Sleep 10 } + try + let! child = Async.StartChild(fast, 1_000) + do! child + equal true true // should reach here + with + | :? TimeoutException -> + failwith "should not time out" + } |> Async.StartImmediate + [] let ``test Unit arguments are erased`` () = // See #1832 let mutable token = 0