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
1 change: 1 addition & 0 deletions src/Fable.Cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions src/Fable.Compiler/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
48 changes: 12 additions & 36 deletions src/fable-library-py/fable_library/async_.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []
Expand Down Expand Up @@ -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)

Expand Down
40 changes: 12 additions & 28 deletions src/fable-library-ts/Async.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,37 +58,25 @@ export function throwIfCancellationRequested(token: CancellationToken) {
}
}

function throwAfter(millisecondsDueTime: number): Async<void> {
return protectedCont((ctx: IAsyncContext<void>) => {
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<T>(computation: Async<T>, ms?: number): Async<Async<T>> {
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<T>((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<T>(p: Promise<T>) {
Expand Down Expand Up @@ -129,10 +117,6 @@ export function parallel<T>(computations: Iterable<Async<T>>) {
return delay(() => awaitPromise(Promise.all(Array.from(computations, (w) => startAsPromise(w)))));
}

function parallel2<T, U>(a: Async<T>, b: Async<U>): Async<[T, U]> {
return delay(() => awaitPromise(Promise.all([startAsPromise(a), startAsPromise(b)])));
}

export function sequential<T>(computations: Iterable<Async<T>>) {

function _sequential<T>(computations: Iterable<Async<T>>): Promise<T[]> {
Expand Down
4 changes: 4 additions & 0 deletions src/fable-library-ts/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions tests/Js/Main/AsyncTests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
37 changes: 37 additions & 0 deletions tests/Python/TestAsync.fs
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,43 @@ let ``test Async.StartChild works`` () =
equal x "ABCDEF"
} |> Async.StartImmediate

[<Fact>]
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

[<Fact>]
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

[<Fact>]
let ``test Unit arguments are erased`` () = // See #1832
let mutable token = 0
Expand Down
Loading