Skip to content

Commit 125868d

Browse files
github-actions[bot]claude
authored andcommitted
[JS/TS/Python] Fix Async.StartChild with timeout always timing out (fixes #4481)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d276f0f commit 125868d

7 files changed

Lines changed: 79 additions & 64 deletions

File tree

src/Fable.Cli/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2727
* [Python] Add `DateOnly` and `TimeOnly` support (by @dbrattli)
2828
* [Python] Fix `String.IndexOf`/`LastIndexOf` with `StringComparison` argument emitting it as a start-index instead of a compile error (by @repo-assist)
2929
* [Beam] Fix `String.IndexOf`/`LastIndexOf` with `StringComparison` argument incorrectly treating the enum value as a start index
30+
* [JS/TS/Python] Fix `Async.StartChild` with timeout always timing out even when the computation finishes before the deadline (fixes #4481) (by @MangelMaxime)
3031

3132
## 5.0.0-rc.6 - 2026-03-31
3233

src/Fable.Compiler/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2727
* [Python] Add `DateOnly` and `TimeOnly` support (by @dbrattli)
2828
* [Python] Fix `String.IndexOf`/`LastIndexOf` with `StringComparison` argument emitting it as a start-index instead of a compile error (by @repo-assist)
2929
* [Beam] Fix `String.IndexOf`/`LastIndexOf` with `StringComparison` argument incorrectly treating the enum value as a start index
30+
* [JS/TS/Python] Fix `Async.StartChild` with timeout always timing out even when the computation finishes before the deadline (fixes #4481) (by @MangelMaxime)
3031

3132
## 5.0.0-rc.12 - 2026-03-31
3233

src/fable-library-py/fable_library/async_.py

Lines changed: 12 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -115,19 +115,6 @@ def to_array(results: list[T]) -> Async[Array[T]]:
115115
return delay(delayed)
116116

117117

118-
def parallel2[T, U](a: Async[T], b: Async[U]) -> Async[Array[T | U]]:
119-
def delayed() -> Async[Array[T | U]]:
120-
tasks: Iterable[Future[T | U]] = map(start_as_task, [a, b]) # type: ignore
121-
all: Future[list[T | U]] = asyncio.gather(*tasks)
122-
123-
def to_array(results: list[T | U]) -> Async[Array[T | U]]:
124-
return protected_return(Array[T | U](results))
125-
126-
return protected_bind(await_task(all), to_array)
127-
128-
return delay(delayed)
129-
130-
131118
def sequential[T](computations: IEnumerable_1[Async[T]]) -> Async[Array[T]]:
132119
def delayed() -> Async[Array[T]]:
133120
results: list[T] = []
@@ -282,34 +269,23 @@ def cancel(_: OperationCanceledError) -> None:
282269
return tcs.get_task()
283270

284271

285-
def throw_after(milliseconds_due_time: int | TimeSpan) -> Async[None]:
286-
def cont(ctx: IAsyncContext[None]) -> None:
287-
def cancel() -> None:
288-
ctx.on_cancel(OperationCanceledError())
289-
290-
token_id = ctx.cancel_token.add_listener(cancel)
291-
292-
def timeout() -> None:
293-
ctx.cancel_token.remove_listener(token_id)
294-
ctx.on_error(TimeoutError())
295-
296-
due_time_ms = to_milliseconds(milliseconds_due_time)
297-
ctx.trampoline.run_later(timeout, due_time_ms / 1000.0)
298-
299-
return protected_cont(cont)
300-
301-
302272
def start_child[T](computation: Async[T], ms: int | TimeSpan | None = None) -> Async[Async[T]]:
303273
if ms is not None:
274+
# Race the computation against a timeout: whichever settles first wins.
275+
# asyncio.gather (the previous implementation via parallel2) waited for BOTH to settle,
276+
# which meant the timeout always fired even when the computation finished first.
277+
task = start_as_task(computation)
304278

305-
def binder(results: Array[T | None]) -> Async[T]:
306-
# TODO: the implementation looks suspicious since we use parallel2
307-
# which will wait for both computations to finish
308-
return protected_return(results[0]) # type: ignore
279+
async def with_timeout() -> T:
280+
try:
281+
return await asyncio.wait_for(task, timeout=to_milliseconds(ms) / 1000.0)
282+
except TimeoutError:
283+
raise TimeoutError()
309284

310-
computation_with_timeout: Async[T] = protected_bind(parallel2(computation, throw_after(ms)), binder)
285+
def cont(ctx: IAsyncContext[Async[T]]) -> None:
286+
protected_return(await_task(with_timeout()))(ctx)
311287

312-
return start_child(computation_with_timeout)
288+
return protected_cont(cont)
313289

314290
task = start_as_task(computation)
315291

src/fable-library-ts/Async.ts

Lines changed: 12 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -58,37 +58,25 @@ export function throwIfCancellationRequested(token: CancellationToken) {
5858
}
5959
}
6060

61-
function throwAfter(millisecondsDueTime: number): Async<void> {
62-
return protectedCont((ctx: IAsyncContext<void>) => {
63-
let tokenId: number;
64-
const timeoutId = setTimeout(() => {
65-
ctx.cancelToken.removeListener(tokenId);
66-
ctx.onError(TimeoutException_$ctor());
67-
}, millisecondsDueTime);
68-
tokenId = ctx.cancelToken.addListener(() => {
69-
clearTimeout(timeoutId);
70-
ctx.onCancel(new OperationCanceledException());
71-
});
72-
});
73-
}
74-
7561
export function startChild<T>(computation: Async<T>, ms?: number): Async<Async<T>> {
76-
if (ms) {
77-
const computationWithTimeout = protectedBind(
78-
parallel2(
79-
computation,
80-
throwAfter(ms)),
81-
xs => protectedReturn(xs[0]));
62+
const promise = startAsPromise(computation);
63+
let promiseToRun = promise;
8264

83-
return startChild(computationWithTimeout);
65+
if (ms) {
66+
// Race the computation against a timeout: whichever settles first wins.
67+
promiseToRun = new Promise<T>((resolve, reject) => {
68+
const timeoutId = setTimeout(() => reject(TimeoutException_$ctor()), ms);
69+
promise.then(
70+
value => { clearTimeout(timeoutId); resolve(value); },
71+
error => { clearTimeout(timeoutId); reject(error); }
72+
);
73+
});
8474
}
8575

86-
const promise = startAsPromise(computation);
87-
8876
// JS Promises are hot, computation has already started
8977
// but we delay returning the result
9078
return protectedCont((ctx) =>
91-
protectedReturn(awaitPromise(promise))(ctx));
79+
protectedReturn(awaitPromise(promiseToRun))(ctx));
9280
}
9381

9482
export function awaitPromise<T>(p: Promise<T>) {
@@ -129,10 +117,6 @@ export function parallel<T>(computations: Iterable<Async<T>>) {
129117
return delay(() => awaitPromise(Promise.all(Array.from(computations, (w) => startAsPromise(w)))));
130118
}
131119

132-
function parallel2<T, U>(a: Async<T>, b: Async<U>): Async<[T, U]> {
133-
return delay(() => awaitPromise(Promise.all([startAsPromise(a), startAsPromise(b)])));
134-
}
135-
136120
export function sequential<T>(computations: Iterable<Async<T>>) {
137121

138122
function _sequential<T>(computations: Iterable<Async<T>>): Promise<T[]> {

src/fable-library-ts/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## Unreleased
99

10+
### Fixed
11+
12+
* [JS/TS] Fix `Async.StartChild` with timeout always timing out even when the computation finishes before the deadline (fixes #4481) (by @MangelMaxime)
13+
1014
## 2.0.0-rc.5 - 2026-03-31
1115

1216
### Fixed

tests/Js/Main/AsyncTests.fs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -591,6 +591,18 @@ let tests =
591591
equal x "ABC"
592592
}
593593

594+
testCaseAsync "Async.StartChild with timeout completes when computation finishes before timeout" <| fun () -> // See #4481
595+
async {
596+
let fast = async { do! Async.Sleep 10 }
597+
try
598+
let! child = Async.StartChild(fast, 1_000)
599+
do! child
600+
equal true true // should reach here
601+
with
602+
| :? TimeoutException ->
603+
failwith "should not time out"
604+
}
605+
594606
testCaseAsync "Unit arguments are erased" <| fun () -> // See #1832
595607
let mutable token = 0
596608
async {

tests/Python/TestAsync.fs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,43 @@ let ``test Async.StartChild works`` () =
461461
equal x "ABCDEF"
462462
} |> Async.StartImmediate
463463

464+
[<Fact>]
465+
let ``test Async.StartChild applies timeout`` () =
466+
async {
467+
let mutable x = ""
468+
469+
let task = async {
470+
x <- x + "A"
471+
do! Async.Sleep 1_000
472+
x <- x + "X" // Never hit
473+
}
474+
475+
try
476+
let! childTask = Async.StartChild (task, 200)
477+
478+
do! childTask
479+
with
480+
| :? TimeoutException ->
481+
x <- x + "B"
482+
483+
x <- x + "C"
484+
485+
equal x "ABC"
486+
} |> Async.StartImmediate
487+
488+
[<Fact>]
489+
let ``test Async.StartChild with timeout completes when computation finishes before timeout`` () = // See #4481
490+
async {
491+
let fast = async { do! Async.Sleep 10 }
492+
try
493+
let! child = Async.StartChild(fast, 1_000)
494+
do! child
495+
equal true true // should reach here
496+
with
497+
| :? TimeoutException ->
498+
failwith "should not time out"
499+
} |> Async.StartImmediate
500+
464501
[<Fact>]
465502
let ``test Unit arguments are erased`` () = // See #1832
466503
let mutable token = 0

0 commit comments

Comments
 (0)