From dc40f35e9d04df1b150be445be54539d2564b3dd Mon Sep 17 00:00:00 2001 From: Leo Conforti Date: Tue, 27 Oct 2026 17:04:01 -0600 Subject: [PATCH 1/6] fix(js/ts): complete TaskCompletionSource like .NET TaskCompletionSource differed from .NET in several ways: - SetCanceled works. The runtime spelt it SetCancelled, so every call raised "SetCanceled is not a function". SetCancelled is kept for JS code that calls it. - TrySetResult, TrySetException and TrySetCanceled return whether they completed the task. - A second SetResult, SetException or SetCanceled raises InvalidOperationException with the .NET message instead of being ignored. - SetException and TrySetException with a sequence fault the task with the first exception, which is what awaiting it throws on .NET. They rejected the task with the F# list itself. An empty sequence or a null element raises ArgumentException. - The non-generic TaskCompletionSource compiles to the same class instead of failing with "not supported by Fable". --- src/Fable.Transforms/Replacements.fs | 9 ++++ src/fable-library-ts/Task.ts | 40 +++++++++++++-- tests/Js/Main/TaskTests.fs | 77 ++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 3 deletions(-) diff --git a/src/Fable.Transforms/Replacements.fs b/src/Fable.Transforms/Replacements.fs index 673aefef71..f7c325e940 100644 --- a/src/Fable.Transforms/Replacements.fs +++ b/src/Fable.Transforms/Replacements.fs @@ -4447,6 +4447,14 @@ let tasks (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: Expr optio ?loc = r ) |> Some + // Like awaiting on .NET, awaiters see the first exception of the seq overloads + | Some x, ("SetException" | "TrySetException" as meth) when + (match i.SignatureArgTypes with + | [ IEnumerable ] -> true + | _ -> false) + -> + Helper.InstanceCall(x, meth + "s", t, args, i.SignatureArgTypes, ?loc = r) + |> Some | Some x, meth -> Helper.InstanceCall(x, meth, t, args, i.SignatureArgTypes, genArgs = i.GenericArgs, ?loc = r) |> Some @@ -4999,6 +5007,7 @@ let private replacedModules = "Microsoft.FSharp.Control.AsyncPrimitives", asyncs Types.task, tasks Types.taskGeneric, tasks + "System.Threading.Tasks.TaskCompletionSource", tasks "System.Threading.Tasks.TaskCompletionSource`1", tasks "System.Runtime.CompilerServices.TaskAwaiter`1", tasks "Microsoft.FSharp.Control.TaskBuilder", taskBuilder diff --git a/src/fable-library-ts/Task.ts b/src/fable-library-ts/Task.ts index 6e1cb1c369..a8bd008347 100644 --- a/src/fable-library-ts/Task.ts +++ b/src/fable-library-ts/Task.ts @@ -1,8 +1,10 @@ import { CancellationToken, OperationCanceledException } from "./AsyncBuilder.ts"; +import { ArgumentException_$ctor_Z384F8060, InvalidOperationException_$ctor_Z721C83C5 } from "./System.ts"; export class TaskCompletionSource { private _resolve!: (value: T) => void; private _reject!: (reason?: unknown) => void; + private _completed = false; public task: Promise; constructor() { @@ -12,12 +14,44 @@ export class TaskCompletionSource { }); } - SetResult(value: T): void { this._resolve(value); } - SetException(error: unknown): void { this._reject(error); } - SetCancelled(): void { this._reject(new OperationCanceledException()); } + private trySettle(settle: () => void): boolean { + if (this._completed) { return false; } + this._completed = true; + settle(); + return true; + } + + private settle(settle: () => void): void { + if (!this.trySettle(settle)) { + throw InvalidOperationException_$ctor_Z721C83C5("An attempt was made to transition a task to a final state when it had already completed."); + } + } + + SetResult(value: T): void { this.settle(() => this._resolve(value)); } + TrySetResult(value: T): boolean { return this.trySettle(() => this._resolve(value)); } + SetException(error: unknown): void { this.settle(() => this._reject(error)); } + TrySetException(error: unknown): boolean { return this.trySettle(() => this._reject(error)); } + SetExceptions(errors: Iterable): void { this.SetException(firstException(errors)); } + TrySetExceptions(errors: Iterable): boolean { return this.TrySetException(firstException(errors)); } + SetCanceled(): void { this.settle(() => this._reject(new OperationCanceledException())); } + TrySetCanceled(): boolean { return this.trySettle(() => this._reject(new OperationCanceledException())); } + // Former spelling of SetCanceled + SetCancelled(): void { this.SetCanceled(); } get_Task(): Promise { return this.task; } } +// Awaiting a task faulted with several exceptions throws the first one on .NET +function firstException(errors: Iterable): unknown { + const all = Array.from(errors); + if (all.length === 0) { + throw ArgumentException_$ctor_Z384F8060("The exceptions collection was empty.", "exceptions"); + } + if (all.some((error) => error == null)) { + throw ArgumentException_$ctor_Z384F8060("The exceptions collection included at least one null element.", "exceptions"); + } + return all[0]; +} + export function fromResult(value: T): Promise { return Promise.resolve(value); } diff --git a/tests/Js/Main/TaskTests.fs b/tests/Js/Main/TaskTests.fs index 63e60d4006..24c64a3b4b 100644 --- a/tests/Js/Main/TaskTests.fs +++ b/tests/Js/Main/TaskTests.fs @@ -3,6 +3,7 @@ module Fable.Tests.Task open System open System.Threading.Tasks open Util.Testing +open Fable.Tests.Util type DisposableAction(f) = interface IDisposable with @@ -175,6 +176,82 @@ let tests = equal 42 result } + testCaseAsync "TaskCompletionSource.SetCanceled works" <| fun () -> + async { + let tcs = TaskCompletionSource() + tcs.SetCanceled() + let! result = awaitTask (task { + try + let! _ = tcs.Task + return "completed" + with :? OperationCanceledException -> return "cancelled" + }) + equal "cancelled" result + } + + testCaseAsync "TaskCompletionSource.TrySet methods return whether they completed the task" <| fun () -> + async { + let tcs = TaskCompletionSource() + equal true (tcs.TrySetResult 1) + equal false (tcs.TrySetResult 2) + equal false (tcs.TrySetException(Exception "late")) + equal false (tcs.TrySetCanceled()) + let! result = awaitTask tcs.Task + equal 1 result + let failed = TaskCompletionSource() + equal true (failed.TrySetException(Exception "bad")) + equal false (failed.TrySetResult 3) + let cancelled = TaskCompletionSource() + equal true (cancelled.TrySetCanceled()) + equal false (cancelled.TrySetCanceled()) + } + + testCase "TaskCompletionSource raises InvalidOperationException when completed twice" <| fun () -> + let message = "An attempt was made to transition a task to a final state when it had already completed." + let tcs = TaskCompletionSource() + tcs.SetResult 1 + raisedExceptionType (fun () -> tcs.SetResult 2) |> equal "InvalidOperationException" + throwsError message (fun () -> tcs.SetException(Exception "x")) + throwsError message (fun () -> tcs.SetCanceled()) + let tried = TaskCompletionSource() + tried.TrySetCanceled() |> ignore + throwsError message (fun () -> tried.SetResult 1) + + testCaseAsync "TaskCompletionSource.SetException with a sequence faults with the first exception" <| fun () -> + async { + let tcs = TaskCompletionSource() + tcs.SetException([ Exception "a"; Exception "b" ]) + equal false (tcs.TrySetException([ Exception "c" ])) + let! result = awaitTask (task { + try + let! _ = tcs.Task + return "completed" + with e -> return e.Message + }) + equal "a" result + let empty = TaskCompletionSource() + raisedExceptionType (fun () -> empty.SetException(List.empty)) |> equal "ArgumentException" + raisedExceptionType (fun () -> empty.TrySetException([ Exception "a"; null ])) |> equal "ArgumentException" + equal true (empty.TrySetResult 1) + } + + testCaseAsync "Non-generic TaskCompletionSource works" <| fun () -> + async { + let tcs = TaskCompletionSource() + equal true (tcs.TrySetResult()) + equal false (tcs.TrySetResult()) + do! Async.AwaitTask tcs.Task + let cancelled = TaskCompletionSource() + cancelled.SetCanceled() + let! result = awaitTask (task { + try + do! cancelled.Task + return "completed" + with :? OperationCanceledException -> return "cancelled" + }) + equal "cancelled" result + } + testCaseAsync "Task.Delay works" <| fun () -> async { let mutable steps = [] From d267e58c1b2d14db848aa1d9996d51dc5bbb681b Mon Sep 17 00:00:00 2001 From: Leo Conforti Date: Tue, 27 Oct 2026 17:04:45 -0600 Subject: [PATCH 2/6] fix(js/ts): support Task.Run Task.Run compiled to an import of a run function that the library did not export, so the module that used it failed to load. Task.Run now runs the function after the caller continues, for the Func, Func>, Action and Func overloads, and unwraps a returned task. With a CancellationToken that is cancelled by the time it would run, the function does not run and the task is cancelled. --- src/fable-library-ts/Task.ts | 8 +++++++ tests/Js/Main/TaskTests.fs | 43 ++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/src/fable-library-ts/Task.ts b/src/fable-library-ts/Task.ts index a8bd008347..689b6a1ecf 100644 --- a/src/fable-library-ts/Task.ts +++ b/src/fable-library-ts/Task.ts @@ -56,6 +56,14 @@ export function fromResult(value: T): Promise { return Promise.resolve(value); } +// Like .NET, the function runs after the caller continues and not at all if the token is cancelled by then +export function run(f: () => T | PromiseLike, cancellationToken?: CancellationToken): Promise { + return Promise.resolve().then(() => { + if (cancellationToken?.isCancelled) { throw new OperationCanceledException(); } + return f(); + }); +} + export function zero(): Promise { return Promise.resolve(); } diff --git a/tests/Js/Main/TaskTests.fs b/tests/Js/Main/TaskTests.fs index 24c64a3b4b..8801733576 100644 --- a/tests/Js/Main/TaskTests.fs +++ b/tests/Js/Main/TaskTests.fs @@ -252,6 +252,49 @@ let tests = equal "cancelled" result } + testCaseAsync "Task.Run works" <| fun () -> + async { + let! a = awaitTask (Task.Run(fun () -> 41 + 1)) + equal 42 a + let! b = awaitTask (Task.Run(Func>(fun () -> task { return 7 }))) + equal 7 b + let mutable ran = 0 + do! Async.AwaitTask(Task.Run(fun () -> ran <- ran + 1)) + do! Async.AwaitTask(Task.Run(fun () -> (task { ran <- ran + 1 } : Task))) + equal 2 ran + let cts = new System.Threading.CancellationTokenSource() + let! c = awaitTask (Task.Run((fun () -> 5), cts.Token)) + equal 5 c + } + + testCaseAsync "Task.Run with a cancelled token gives a cancelled task" <| fun () -> + async { + let cts = new System.Threading.CancellationTokenSource() + cts.Cancel() + let mutable ran = false + let t = Task.Run((fun () -> ran <- true; 1), cts.Token) + let! result = awaitTask (task { + try + let! _ = t + return "completed" + with :? OperationCanceledException -> return "cancelled" + }) + equal "cancelled" result + equal false ran + } + + testCaseAsync "Task.Run faults with the exception of the function" <| fun () -> + async { + let t = Task.Run(fun () -> failwith "boom"; 1) + let! result = awaitTask (task { + try + let! _ = t + return "completed" + with e -> return e.Message + }) + equal "boom" result + } + testCaseAsync "Task.Delay works" <| fun () -> async { let mutable steps = [] From 2589bd6c10a12c5d87bdcee8e2ab4467f9819e4d Mon Sep 17 00:00:00 2001 From: Leo Conforti Date: Tue, 27 Oct 2026 17:06:40 -0600 Subject: [PATCH 3/6] fix(js/ts): no unhandled rejection for tasks that nothing awaits A faulted or cancelled task that nothing awaits is silent on .NET, but in JS its rejected promise was reported as an unhandled rejection, which ends a Node process by default. This happened for the tasks of TaskCompletionSource, Async.StartAsTask, Async.StartImmediateAsTask, Task.Run and a cancelled Task.Delay. They now get a rejection handler when they are created, as Async.StartChild children already do, and awaiters still see the failure. --- src/fable-library-ts/Async.ts | 5 +++-- src/fable-library-ts/Task.ts | 18 ++++++++++++------ tests/Js/Main/AsyncTests.fs | 18 ++++++++++++++++++ tests/Js/Main/TaskTests.fs | 20 ++++++++++++++++++++ 4 files changed, 53 insertions(+), 8 deletions(-) diff --git a/src/fable-library-ts/Async.ts b/src/fable-library-ts/Async.ts index c668bd85c0..2e3329251f 100644 --- a/src/fable-library-ts/Async.ts +++ b/src/fable-library-ts/Async.ts @@ -7,6 +7,7 @@ import { FSharpChoice$2_$union, Choice_makeChoice1Of2, Choice_makeChoice2Of2 } f import { ArgumentException_$ctor_Z384F8060, InvalidOperationException_$ctor_Z721C83C5, TimeoutException_$ctor } from "./System.ts"; import { Exception, ensureErrorOrException, IDisposable } from "./Util.ts"; import type { Option } from "./Option.ts"; +import { markObserved } from "./Task.ts"; function emptyContinuation(_x: T) { // NOP @@ -370,9 +371,9 @@ export function startAsPromise(computation: Async, cancellationToken?: Can } export function startAsTask(computation: Async, _taskCreationOptions?: number, cancellationToken?: CancellationToken) { - return startAsPromise(computation, cancellationToken); + return markObserved(startAsPromise(computation, cancellationToken)); } export function startImmediateAsTask(computation: Async, cancellationToken?: CancellationToken) { - return startAsPromise(computation, cancellationToken); + return markObserved(startAsPromise(computation, cancellationToken)); } diff --git a/src/fable-library-ts/Task.ts b/src/fable-library-ts/Task.ts index 689b6a1ecf..aaa1e68cb7 100644 --- a/src/fable-library-ts/Task.ts +++ b/src/fable-library-ts/Task.ts @@ -8,10 +8,10 @@ export class TaskCompletionSource { public task: Promise; constructor() { - this.task = new Promise((resolve, reject) => { + this.task = markObserved(new Promise((resolve, reject) => { this._resolve = resolve; this._reject = reject; - }); + })); } private trySettle(settle: () => void): boolean { @@ -56,12 +56,18 @@ export function fromResult(value: T): Promise { return Promise.resolve(value); } +// Like .NET, a failed task that nothing awaits is not an error, while awaiters still see the failure +export function markObserved(task: Promise): Promise { + task.then(undefined, () => { /* NOP */ }); + return task; +} + // Like .NET, the function runs after the caller continues and not at all if the token is cancelled by then export function run(f: () => T | PromiseLike, cancellationToken?: CancellationToken): Promise { - return Promise.resolve().then(() => { + return markObserved(Promise.resolve().then(() => { if (cancellationToken?.isCancelled) { throw new OperationCanceledException(); } return f(); - }); + })); } export function zero(): Promise { @@ -70,7 +76,7 @@ export function zero(): Promise { // Like .NET, -1 waits until the token is cancelled export function delay(millisecondsDelay: number, cancellationToken?: CancellationToken): Promise { - return new Promise((resolve, reject) => { + return markObserved(new Promise((resolve, reject) => { if (cancellationToken?.isCancelled) { reject(new OperationCanceledException()); return; @@ -86,7 +92,7 @@ export function delay(millisecondsDelay: number, cancellationToken?: Cancellatio reject(new OperationCanceledException()); }); } - }); + })); } // Task = Promise in JS/TS. GetAwaiter/GetResult return the Promise itself; diff --git a/tests/Js/Main/AsyncTests.fs b/tests/Js/Main/AsyncTests.fs index bcf3274a14..f1ecb7594a 100644 --- a/tests/Js/Main/AsyncTests.fs +++ b/tests/Js/Main/AsyncTests.fs @@ -1664,6 +1664,24 @@ let tests = equal false completed } + testCaseAsync "Async.StartAsTask tasks that fail while nothing awaits them are not errors" <| fun () -> + async { + let failing = Async.StartAsTask(async { + do! Async.Sleep 5 + return failwith "unobserved" + }) + Async.StartImmediateAsTask(async { return failwith "unobserved" }) |> ignore + let cts = new CancellationTokenSource() + Async.StartAsTask(Async.Sleep 1000, cancellationToken = cts.Token) |> ignore + cts.Cancel() + do! Async.Sleep 30 + // .NET wraps the failure in an AggregateException + let! result = Async.Catch(Async.AwaitTask failing) + match result with + | Choice2Of2 e -> equal true (e.Message.Contains "unobserved") + | Choice1Of2 _ -> failwith "Expected the task to fail" + } + testCaseAsync "Async.StartImmediateAsTask works" <| fun () -> async { let t = Async.StartImmediateAsTask(async { return 42 }) diff --git a/tests/Js/Main/TaskTests.fs b/tests/Js/Main/TaskTests.fs index 8801733576..d5a527a371 100644 --- a/tests/Js/Main/TaskTests.fs +++ b/tests/Js/Main/TaskTests.fs @@ -295,6 +295,26 @@ let tests = equal "boom" result } + testCaseAsync "Faulted and cancelled tasks that nothing awaits are not errors" <| fun () -> + async { + let failed = TaskCompletionSource() + failed.SetException(Exception "unobserved") + let cancelled = TaskCompletionSource() + cancelled.SetCanceled() + let cts = new System.Threading.CancellationTokenSource() + Task.Delay(1000, cts.Token) |> ignore + cts.Cancel() + Task.Run(fun () -> failwith "unobserved"; 1) |> ignore + do! Async.Sleep 20 + let! result = awaitTask (task { + try + let! _ = failed.Task + return "completed" + with e -> return e.Message + }) + equal "unobserved" result + } + testCaseAsync "Task.Delay works" <| fun () -> async { let mutable steps = [] From 0ccf7710cb9b80ed6f8278c37de53c4a2902b6e6 Mon Sep 17 00:00:00 2001 From: Leo Conforti Date: Tue, 27 Oct 2026 17:08:29 -0600 Subject: [PATCH 4/6] fix(js/ts): raise OperationCanceledException from user code and tokens CancellationToken.ThrowIfCancellationRequested raised a plain Exception("Operation is cancelled"), and OperationCanceledException constructed in user code compiled to a plain Exception, so :? OperationCanceledException did not match either of them. Both now create the library's OperationCanceledException, which the type test already used. The constructor overloads that take a CancellationToken drop it, the inner exception is kept, and the default message is .NET's "The operation was canceled.". --- src/Fable.Transforms/Replacements.fs | 9 +++++++++ src/fable-library-ts/Async.ts | 2 +- src/fable-library-ts/AsyncBuilder.ts | 4 ++-- tests/Js/Main/AsyncTests.fs | 25 +++++++++++++++++++++++++ 4 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/Fable.Transforms/Replacements.fs b/src/Fable.Transforms/Replacements.fs index f7c325e940..6a7193dd43 100644 --- a/src/Fable.Transforms/Replacements.fs +++ b/src/Fable.Transforms/Replacements.fs @@ -3292,6 +3292,15 @@ let exceptions (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: Expr match i.DeclaringEntityFullName with | "System.Collections.Generic.KeyNotFoundException" | BuiltinSystemException _ -> bclType com ctx r t i thisArg args + // The library class has no CancellationToken property + | "System.OperationCanceledException" -> + let args = + match List.tryLast i.SignatureArgTypes with + | Some(DeclaredTypeFullName Types.cancellationToken) -> List.truncate (List.length args - 1) args + | _ -> args + + Helper.LibCall(com, "AsyncBuilder", "OperationCanceledException", t, args, isConstructor = true, ?loc = r) + |> Some | _ -> Helper.LibCall(com, "Util", "Exception", Any, args, isConstructor = true) |> Some diff --git a/src/fable-library-ts/Async.ts b/src/fable-library-ts/Async.ts index 2e3329251f..e716ea8a43 100644 --- a/src/fable-library-ts/Async.ts +++ b/src/fable-library-ts/Async.ts @@ -52,7 +52,7 @@ export function isCancellationRequested(token: CancellationToken) { export function throwIfCancellationRequested(token: CancellationToken) { if (token != null && token.isCancelled) { - throw new Exception("Operation is cancelled"); + throw new OperationCanceledException(); } } diff --git a/src/fable-library-ts/AsyncBuilder.ts b/src/fable-library-ts/AsyncBuilder.ts index 0a89c59e18..118e56c499 100644 --- a/src/fable-library-ts/AsyncBuilder.ts +++ b/src/fable-library-ts/AsyncBuilder.ts @@ -72,8 +72,8 @@ export class CancellationToken implements IDisposable { } export class OperationCanceledException extends Exception { - constructor(msg?: string) { - super(msg ?? "The operation was canceled"); + constructor(msg?: string, innerException?: Exception) { + super(msg ?? "The operation was canceled.", innerException); // Object.setPrototypeOf(this, OperationCanceledException.prototype); } } diff --git a/tests/Js/Main/AsyncTests.fs b/tests/Js/Main/AsyncTests.fs index f1ecb7594a..fc2767ea3a 100644 --- a/tests/Js/Main/AsyncTests.fs +++ b/tests/Js/Main/AsyncTests.fs @@ -1633,6 +1633,31 @@ let tests = equal false interrupted } + testCase "CancellationToken.ThrowIfCancellationRequested raises OperationCanceledException" <| fun () -> + let cts = new CancellationTokenSource() + cts.Token.ThrowIfCancellationRequested() + cts.Cancel() + let result = + try + cts.Token.ThrowIfCancellationRequested() + "returned" + with :? OperationCanceledException as e -> e.Message + equal "The operation was canceled." result + + testCase "OperationCanceledException constructors work" <| fun () -> + let describe (e: exn) = + match e with + | :? OperationCanceledException when isNull e.InnerException -> e.Message + | :? OperationCanceledException -> e.Message + " " + e.InnerException.Message + | _ -> "other" + let cts = new CancellationTokenSource() + equal "The operation was canceled." (describe (OperationCanceledException())) + equal "m" (describe (OperationCanceledException "m")) + equal "m inner" (describe (OperationCanceledException("m", Exception "inner"))) + equal "The operation was canceled." (describe (OperationCanceledException cts.Token)) + equal "m" (describe (OperationCanceledException("m", cts.Token))) + equal "m inner" (describe (OperationCanceledException("m", Exception "inner", cts.Token))) + testCaseAsync "Async.StartAsTask works" <| fun () -> async { let t = Async.StartAsTask(async { From 49549e61ed772bb3a58ff54fa21869a5f7c2e035 Mon Sep 17 00:00:00 2001 From: Leo Conforti Date: Tue, 27 Oct 2026 17:10:13 -0600 Subject: [PATCH 5/6] fix(js/ts): support linked CancellationTokenSources and registrations - CancellationTokenSource.CreateLinkedTokenSource with one token, two tokens or an array of tokens was not supported by Fable. The linked source is cancelled when any parent is, at once if a parent is already cancelled, and cancelling it leaves the parents alone. Like .NET, a default token is skipped, no tokens raise ArgumentException, and disposing the source unlinks it from its parents. Dispose on a CancellationTokenSource compiled to nothing, so it now calls the source's Dispose, which does nothing for a source that is not linked. - CancellationTokenRegistration.Dispose and Unregister called directly were not supported by Fable. They remove the callback, and Unregister returns whether it was still registered. Like disposing through IDisposable, which already worked, Dispose does nothing for a default registration. CancellationTokenRegistration.Token is still not supported. --- src/Fable.Transforms/Replacements.fs | 21 ++++++++- src/fable-library-ts/Async.ts | 30 ++++++++++--- src/fable-library-ts/AsyncBuilder.ts | 5 ++- tests/Js/Main/AsyncTests.fs | 66 ++++++++++++++++++++++++++++ 4 files changed, 114 insertions(+), 8 deletions(-) diff --git a/src/Fable.Transforms/Replacements.fs b/src/Fable.Transforms/Replacements.fs index 6a7193dd43..92a71681e2 100644 --- a/src/Fable.Transforms/Replacements.fs +++ b/src/Fable.Transforms/Replacements.fs @@ -4047,12 +4047,15 @@ let random (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: Expr opti | _ -> None let cancels (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: Expr option) (args: Expr list) = + let isRegistration = + i.DeclaringEntityFullName = "System.Threading.CancellationTokenRegistration" + match i.CompiledName with | "get_None" // TODO: implement as non-cancellable token | ".ctor" -> Helper.LibCall(com, "Async", "createCancellationToken", t, args, i.SignatureArgTypes) |> Some - | "get_Token" -> thisArg + | "get_Token" when not isRegistration -> thisArg | "Cancel" | "CancelAfter" | "get_IsCancellationRequested" @@ -4072,8 +4075,21 @@ let cancels (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: Expr opt ?loc = r ) |> Some + | "CreateLinkedTokenSource" -> + let tokens = + match args with + | [ ExprType(Array _) as tokens ] -> tokens + | tokens -> makeArray (List.head i.SignatureArgTypes) tokens + + Helper.LibCall(com, "Async", "createLinkedTokenSource", t, [ tokens ], ?loc = r) + |> Some + // A default registration is null, see disposables + | "Dispose" when isRegistration -> + Helper.LibCall(com, "Util", "disposeSafe", t, [ thisArg.Value ], ?loc = r) + |> Some // TODO: Add check so CancellationTokenSource cannot be cancelled after disposed? - | "Dispose" -> Null Type.Unit |> makeValue r |> Some + | "Dispose" + | "Unregister" -> Helper.InstanceCall(thisArg.Value, i.CompiledName, t, [], ?loc = r) |> Some | "Register" -> Helper.InstanceCall(thisArg.Value, "register", t, args, i.SignatureArgTypes, ?loc = r) |> Some @@ -4993,6 +5009,7 @@ let private replacedModules = Types.random, random "System.Threading.CancellationToken", cancels "System.Threading.CancellationTokenSource", cancels + "System.Threading.CancellationTokenRegistration", cancels "System.Threading.Monitor", monitor "System.Activator", activator "System.Text.Encoding", encoding diff --git a/src/fable-library-ts/Async.ts b/src/fable-library-ts/Async.ts index e716ea8a43..77ad544fab 100644 --- a/src/fable-library-ts/Async.ts +++ b/src/fable-library-ts/Async.ts @@ -4,7 +4,7 @@ import { Async, IAsyncContext, CancellationToken } from "./AsyncBuilder.ts"; import { protectedCont, protectedBind, protectedReturn } from "./AsyncBuilder.ts"; import type { IEvent$2 } from "./Event.ts"; import { FSharpChoice$2_$union, Choice_makeChoice1Of2, Choice_makeChoice2Of2 } from "./Choice.ts"; -import { ArgumentException_$ctor_Z384F8060, InvalidOperationException_$ctor_Z721C83C5, TimeoutException_$ctor } from "./System.ts"; +import { ArgumentException_$ctor_Z384F8060, ArgumentException_$ctor_Z721C83C5, InvalidOperationException_$ctor_Z721C83C5, TimeoutException_$ctor } from "./System.ts"; import { Exception, ensureErrorOrException, IDisposable } from "./Util.ts"; import type { Option } from "./Option.ts"; import { markObserved } from "./Task.ts"; @@ -56,11 +56,31 @@ export function throwIfCancellationRequested(token: CancellationToken) { } } -// The parent keeps the listener until unlink is called +// Like .NET, the parents keep their listener until the linked token is disposed +class LinkedCancellationToken extends CancellationToken { + private readonly _unlink: () => void; + constructor(tokens: CancellationToken[]) { + super(); + // A default token is null and never cancelled + const parents = tokens.filter((parent) => parent != null); + const ids = parents.map((parent) => parent.addListener(() => this.cancel())); + this._unlink = () => { parents.forEach((parent, i) => parent.removeListener(ids[i])); }; + } + public Dispose() { + this._unlink(); + } +} + function createLinkedToken(parent: CancellationToken): [token: CancellationToken, unlink: () => void] { - const token = new CancellationToken(); - const id = parent.addListener(() => token.cancel()); - return [token, () => { parent.removeListener(id); }]; + const token = new LinkedCancellationToken([parent]); + return [token, () => { token.Dispose(); }]; +} + +export function createLinkedTokenSource(tokens: CancellationToken[]): CancellationToken { + if (tokens.length === 0) { + throw ArgumentException_$ctor_Z721C83C5("No tokens were supplied."); + } + return new LinkedCancellationToken(tokens); } export function startChild(computation: Async, ms?: number): Async> { diff --git a/src/fable-library-ts/AsyncBuilder.ts b/src/fable-library-ts/AsyncBuilder.ts index 118e56c499..7843e20b85 100644 --- a/src/fable-library-ts/AsyncBuilder.ts +++ b/src/fable-library-ts/AsyncBuilder.ts @@ -62,7 +62,10 @@ export class CancellationToken implements IDisposable { public register(f: (state?: any) => void, state?: any) { const $ = this; const id = this.addListener(state == null ? f : () => f(state)); - return { Dispose() { $.removeListener(id); } }; + return { + Dispose() { $.removeListener(id); }, + Unregister() { return $.removeListener(id); }, + }; } public Dispose() { // Implement IDisposable for compatibility but do nothing diff --git a/tests/Js/Main/AsyncTests.fs b/tests/Js/Main/AsyncTests.fs index fc2767ea3a..d346074827 100644 --- a/tests/Js/Main/AsyncTests.fs +++ b/tests/Js/Main/AsyncTests.fs @@ -1658,6 +1658,72 @@ let tests = equal "m" (describe (OperationCanceledException("m", cts.Token))) equal "m inner" (describe (OperationCanceledException("m", Exception "inner", cts.Token))) + testCase "CancellationTokenSource.CreateLinkedTokenSource works" <| fun () -> + let parent = new CancellationTokenSource() + let linked = CancellationTokenSource.CreateLinkedTokenSource(parent.Token) + equal false linked.IsCancellationRequested + parent.Cancel() + equal true linked.IsCancellationRequested + let alreadyCancelled = CancellationTokenSource.CreateLinkedTokenSource(parent.Token) + equal true alreadyCancelled.IsCancellationRequested + let a = new CancellationTokenSource() + let b = new CancellationTokenSource() + let c = new CancellationTokenSource() + let two = CancellationTokenSource.CreateLinkedTokenSource(a.Token, b.Token) + let many = CancellationTokenSource.CreateLinkedTokenSource(a.Token, b.Token, c.Token) + let array = CancellationTokenSource.CreateLinkedTokenSource([| a.Token; c.Token |]) + c.Cancel() + equal false two.IsCancellationRequested + equal true many.IsCancellationRequested + equal true array.IsCancellationRequested + b.Cancel() + equal true two.IsCancellationRequested + equal false a.IsCancellationRequested + + testCase "CancellationTokenSource.CreateLinkedTokenSource handles default and missing tokens" <| fun () -> + let parent = new CancellationTokenSource() + let linked = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken(), parent.Token) + let alone = CancellationTokenSource.CreateLinkedTokenSource(Unchecked.defaultof) + parent.Cancel() + equal true linked.IsCancellationRequested + equal false alone.IsCancellationRequested + raisedExceptionType (fun () -> CancellationTokenSource.CreateLinkedTokenSource(Array.empty)) + |> equal "ArgumentException" + + testCase "Cancelling a linked CancellationTokenSource does not cancel its parents" <| fun () -> + let parent = new CancellationTokenSource() + let linked = CancellationTokenSource.CreateLinkedTokenSource(parent.Token) + linked.Cancel() + equal true linked.IsCancellationRequested + equal false parent.IsCancellationRequested + + testCase "Disposing a linked CancellationTokenSource unlinks it from its parents" <| fun () -> + let parent = new CancellationTokenSource() + let mutable fired = 0 + let linked = CancellationTokenSource.CreateLinkedTokenSource(parent.Token) + linked.Token.Register(fun () -> fired <- fired + 1) |> ignore + linked.Dispose() + do + use scoped = CancellationTokenSource.CreateLinkedTokenSource(parent.Token) + scoped.Token.Register(fun () -> fired <- fired + 10) |> ignore + parent.Cancel() + equal 0 fired + + testCase "CancellationTokenRegistration.Dispose and Unregister work" <| fun () -> + let cts = new CancellationTokenSource() + let mutable fired = 0 + let disposed = cts.Token.Register(fun () -> fired <- fired + 1) + let unregistered = cts.Token.Register(fun () -> fired <- fired + 10) + let kept = cts.Token.Register(fun () -> fired <- fired + 100) + disposed.Dispose() + equal true (unregistered.Unregister()) + equal false (unregistered.Unregister()) + cts.Cancel() + equal 100 fired + equal false (kept.Unregister()) + let unassigned = CancellationTokenRegistration() + unassigned.Dispose() + testCaseAsync "Async.StartAsTask works" <| fun () -> async { let t = Async.StartAsTask(async { From a39557cf7729d5239b1be4f50a4370e23812b3ff Mon Sep 17 00:00:00 2001 From: Leo Conforti Date: Tue, 27 Oct 2026 17:15:57 -0600 Subject: [PATCH 6/6] fix(js/ts): raise TaskCanceledException for cancelled tasks Async.AwaitTask of a cancelled task cancelled the awaiting workflow, so try/with did not catch it and the whole workflow was cancelled. Like .NET, it now raises TaskCanceledException through the error continuation, as #5064 did for Python. Async.AwaitPromise, Async.Parallel, Async.Sequential and Async.StartChild still treat an OperationCanceledException rejection as a cancellation. TaskCanceledException is a new OperationCanceledException subclass with .NET's "A task was canceled." message, and it can be constructed and type tested. The tasks of TaskCompletionSource.SetCanceled, Task.Run and Task.Delay with a cancelled token, and a cancelled Async.StartAsTask or Async.StartImmediateAsTask, reject with it, so awaiting them in task { } also raises TaskCanceledException as on .NET. --- src/Fable.Transforms/Replacements.fs | 16 +++++++---- src/fable-library-ts/Async.ts | 16 +++++++++-- src/fable-library-ts/AsyncBuilder.ts | 6 ++++ src/fable-library-ts/Task.ts | 12 ++++---- tests/Js/Main/AsyncTests.fs | 41 ++++++++++++++++++++++++++-- tests/Js/Main/TaskTests.fs | 28 +++++++++++++++++++ 6 files changed, 102 insertions(+), 17 deletions(-) diff --git a/src/Fable.Transforms/Replacements.fs b/src/Fable.Transforms/Replacements.fs index 92a71681e2..4e3264d4da 100644 --- a/src/Fable.Transforms/Replacements.fs +++ b/src/Fable.Transforms/Replacements.fs @@ -847,6 +847,8 @@ let tryEntityIdent (com: Compiler) entFullName = | Types.matchFail -> makeImportLib com Any "MatchFailureException" "Types" |> Some | Types.exception_ -> makeImportLib com Any "Exception" "Util" |> Some | "System.OperationCanceledException" -> makeImportLib com Any "OperationCanceledException" "AsyncBuilder" |> Some + | "System.Threading.Tasks.TaskCanceledException" -> + makeImportLib com Any "TaskCanceledException" "AsyncBuilder" |> Some | "System.Collections.Generic.KeyNotFoundException" -> makeImportLib com Any "KeyNotFoundException" "System.Collections.Generic" |> Some @@ -3292,14 +3294,18 @@ let exceptions (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: Expr match i.DeclaringEntityFullName with | "System.Collections.Generic.KeyNotFoundException" | BuiltinSystemException _ -> bclType com ctx r t i thisArg args - // The library class has no CancellationToken property - | "System.OperationCanceledException" -> + // The library classes have no CancellationToken or Task property + | "System.OperationCanceledException" + | "System.Threading.Tasks.TaskCanceledException" as entName -> let args = match List.tryLast i.SignatureArgTypes with - | Some(DeclaredTypeFullName Types.cancellationToken) -> List.truncate (List.length args - 1) args + | Some(DeclaredTypeFullName(Types.cancellationToken | Types.task)) -> + List.truncate (List.length args - 1) args | _ -> args - Helper.LibCall(com, "AsyncBuilder", "OperationCanceledException", t, args, isConstructor = true, ?loc = r) + let className = entName.Substring(entName.LastIndexOf('.') + 1) + + Helper.LibCall(com, "AsyncBuilder", className, t, args, isConstructor = true, ?loc = r) |> Some | _ -> Helper.LibCall(com, "Util", "Exception", Any, args, isConstructor = true) @@ -4379,7 +4385,7 @@ let asyncs com (ctx: Context) r t (i: CallInfo) (_: Expr option) (args: Expr lis | "RunSynchronously" -> None // Task is Promise in JS/TS | "AwaitTask" -> - Helper.LibCall(com, "Async", "awaitPromise", t, args, i.SignatureArgTypes, genArgs = i.GenericArgs, ?loc = r) + Helper.LibCall(com, "Async", "awaitTask", t, args, i.SignatureArgTypes, genArgs = i.GenericArgs, ?loc = r) |> Some // Fable.Core extensions | meth -> diff --git a/src/fable-library-ts/Async.ts b/src/fable-library-ts/Async.ts index 77ad544fab..d225d29b48 100644 --- a/src/fable-library-ts/Async.ts +++ b/src/fable-library-ts/Async.ts @@ -1,4 +1,4 @@ -import { OperationCanceledException, Trampoline } from "./AsyncBuilder.ts"; +import { OperationCanceledException, TaskCanceledException, Trampoline } from "./AsyncBuilder.ts"; import { Continuation, Continuations } from "./AsyncBuilder.ts"; import { Async, IAsyncContext, CancellationToken } from "./AsyncBuilder.ts"; import { protectedCont, protectedBind, protectedReturn } from "./AsyncBuilder.ts"; @@ -125,6 +125,13 @@ export function awaitPromise(p: Promise) { ? conts[2] : conts[1])(err))); } +// Like .NET, a cancelled task raises TaskCanceledException in the awaiting workflow instead of cancelling it +export function awaitTask(task: Promise) { + return fromContinuations((conts: Continuations) => + task.then(conts[0], (err) => + conts[1](err instanceof OperationCanceledException ? new TaskCanceledException() : err))); +} + export function awaitEvent(event: IEvent$2, cancelAction?: () => void): Async { return protectedCont((ctx: IAsyncContext) => { let tokenId: number; @@ -391,9 +398,12 @@ export function startAsPromise(computation: Async, cancellationToken?: Can } export function startAsTask(computation: Async, _taskCreationOptions?: number, cancellationToken?: CancellationToken) { - return markObserved(startAsPromise(computation, cancellationToken)); + return startImmediateAsTask(computation, cancellationToken); } +// Like .NET, cancelling the computation cancels the task, and awaiting that raises TaskCanceledException export function startImmediateAsTask(computation: Async, cancellationToken?: CancellationToken) { - return markObserved(startAsPromise(computation, cancellationToken)); + return markObserved(new Promise((resolve: Continuation, reject: Continuation) => + startWithContinuations(computation, resolve, reject, () => reject(new TaskCanceledException()), + cancellationToken ? cancellationToken : defaultCancellationToken))); } diff --git a/src/fable-library-ts/AsyncBuilder.ts b/src/fable-library-ts/AsyncBuilder.ts index 7843e20b85..68b08b106e 100644 --- a/src/fable-library-ts/AsyncBuilder.ts +++ b/src/fable-library-ts/AsyncBuilder.ts @@ -81,6 +81,12 @@ export class OperationCanceledException extends Exception { } } +export class TaskCanceledException extends OperationCanceledException { + constructor(msg?: string, innerException?: Exception) { + super(msg ?? "A task was canceled.", innerException); + } +} + export class Trampoline { static get maxTrampolineCallCount() { return 2000; diff --git a/src/fable-library-ts/Task.ts b/src/fable-library-ts/Task.ts index aaa1e68cb7..8691f162ca 100644 --- a/src/fable-library-ts/Task.ts +++ b/src/fable-library-ts/Task.ts @@ -1,4 +1,4 @@ -import { CancellationToken, OperationCanceledException } from "./AsyncBuilder.ts"; +import { CancellationToken, TaskCanceledException } from "./AsyncBuilder.ts"; import { ArgumentException_$ctor_Z384F8060, InvalidOperationException_$ctor_Z721C83C5 } from "./System.ts"; export class TaskCompletionSource { @@ -33,8 +33,8 @@ export class TaskCompletionSource { TrySetException(error: unknown): boolean { return this.trySettle(() => this._reject(error)); } SetExceptions(errors: Iterable): void { this.SetException(firstException(errors)); } TrySetExceptions(errors: Iterable): boolean { return this.TrySetException(firstException(errors)); } - SetCanceled(): void { this.settle(() => this._reject(new OperationCanceledException())); } - TrySetCanceled(): boolean { return this.trySettle(() => this._reject(new OperationCanceledException())); } + SetCanceled(): void { this.settle(() => this._reject(new TaskCanceledException())); } + TrySetCanceled(): boolean { return this.trySettle(() => this._reject(new TaskCanceledException())); } // Former spelling of SetCanceled SetCancelled(): void { this.SetCanceled(); } get_Task(): Promise { return this.task; } @@ -65,7 +65,7 @@ export function markObserved(task: Promise): Promise { // Like .NET, the function runs after the caller continues and not at all if the token is cancelled by then export function run(f: () => T | PromiseLike, cancellationToken?: CancellationToken): Promise { return markObserved(Promise.resolve().then(() => { - if (cancellationToken?.isCancelled) { throw new OperationCanceledException(); } + if (cancellationToken?.isCancelled) { throw new TaskCanceledException(); } return f(); })); } @@ -78,7 +78,7 @@ export function zero(): Promise { export function delay(millisecondsDelay: number, cancellationToken?: CancellationToken): Promise { return markObserved(new Promise((resolve, reject) => { if (cancellationToken?.isCancelled) { - reject(new OperationCanceledException()); + reject(new TaskCanceledException()); return; } let tokenId: number | undefined; @@ -89,7 +89,7 @@ export function delay(millisecondsDelay: number, cancellationToken?: Cancellatio if (cancellationToken != null) { tokenId = cancellationToken.addListener(() => { clearTimeout(timeoutId); - reject(new OperationCanceledException()); + reject(new TaskCanceledException()); }); } })); diff --git a/tests/Js/Main/AsyncTests.fs b/tests/Js/Main/AsyncTests.fs index d346074827..8bc4618dc7 100644 --- a/tests/Js/Main/AsyncTests.fs +++ b/tests/Js/Main/AsyncTests.fs @@ -1744,17 +1744,52 @@ let tests = }, cancellationToken = cts.Token) cts.Cancel() let mutable outcome = "pending" - // AwaitTask reports a cancelled task as an exception on .NET and as a cancellation in JS Async.StartWithContinuations( Async.AwaitTask t, (fun () -> outcome <- "completed"), - (fun _ -> outcome <- "cancelled"), + (function + | :? System.Threading.Tasks.TaskCanceledException -> outcome <- "TaskCanceledException" + | _ -> outcome <- "other error"), (fun _ -> outcome <- "cancelled")) do! Async.Sleep 300 - equal "cancelled" outcome + equal "TaskCanceledException" outcome equal false completed } + testCaseAsync "Async.AwaitTask raises TaskCanceledException for a cancelled task" <| fun () -> + async { + let cancelled = new CancellationTokenSource() + cancelled.Cancel() + let! result = Async.Catch(Async.AwaitTask(System.Threading.Tasks.Task.Delay(1000, cancelled.Token))) + match result with + | Choice2Of2 (:? System.Threading.Tasks.TaskCanceledException as e) -> equal "A task was canceled." e.Message + | _ -> failwith "Expected TaskCanceledException through the error continuation" + let cts = new CancellationTokenSource() + let t = Async.StartAsTask(Async.Sleep 1000, cancellationToken = cts.Token) + cts.Cancel() + let! caught = + async { + try + do! Async.AwaitTask t + return "completed" + with :? OperationCanceledException -> return "caught" + } + equal "caught" caught + } + + testCase "TaskCanceledException constructors work" <| fun () -> + let inner = Exception "inner" + let error: exn = System.Threading.Tasks.TaskCanceledException("task failure", inner) + match error with + | :? System.Threading.Tasks.TaskCanceledException -> + equal "task failure" error.Message + equal true (obj.ReferenceEquals(inner, error.InnerException)) + | _ -> failwith "Expected TaskCanceledException" + let error: exn = System.Threading.Tasks.TaskCanceledException() + match error with + | :? OperationCanceledException -> equal "A task was canceled." error.Message + | _ -> failwith "Expected an OperationCanceledException" + testCaseAsync "Async.StartAsTask tasks that fail while nothing awaits them are not errors" <| fun () -> async { let failing = Async.StartAsTask(async { diff --git a/tests/Js/Main/TaskTests.fs b/tests/Js/Main/TaskTests.fs index d5a527a371..36120ef3fd 100644 --- a/tests/Js/Main/TaskTests.fs +++ b/tests/Js/Main/TaskTests.fs @@ -12,6 +12,14 @@ type DisposableAction(f) = // Bridge pattern: works on .NET (Async.AwaitTask wraps Task) and JS (awaitPromise wraps Promise) let awaitTask (t: Task<'T>) = Async.AwaitTask t +let cancellationMessage (t: Task) = + task { + try + do! t + return "completed" + with :? TaskCanceledException as e -> return e.Message + } |> Async.AwaitTask + let tests = testList "Task" [ testCaseAsync "Simple task translates without exception" <| fun () -> @@ -187,6 +195,10 @@ let tests = with :? OperationCanceledException -> return "cancelled" }) equal "cancelled" result + let! awaited = Async.Catch(awaitTask tcs.Task) + match awaited with + | Choice2Of2 (:? TaskCanceledException) -> () + | _ -> failwith "Expected TaskCanceledException through the error continuation" } testCaseAsync "TaskCompletionSource.TrySet methods return whether they completed the task" <| fun () -> @@ -295,6 +307,22 @@ let tests = equal "boom" result } + testCaseAsync "Awaiting a cancelled task in a task raises TaskCanceledException" <| fun () -> + async { + let tcs = TaskCompletionSource() + tcs.SetCanceled() + let cancelled = new System.Threading.CancellationTokenSource() + cancelled.Cancel() + let cts = new System.Threading.CancellationTokenSource() + let started = Async.StartAsTask(Async.Sleep 1000, cancellationToken = cts.Token) + cts.Cancel() + let! a = cancellationMessage tcs.Task + let! b = cancellationMessage (Task.Run((fun () -> ()), cancelled.Token)) + let! c = cancellationMessage (Task.Delay(1000, cancelled.Token)) + let! d = cancellationMessage started + equal [ "A task was canceled."; "A task was canceled."; "A task was canceled."; "A task was canceled." ] [ a; b; c; d ] + } + testCaseAsync "Faulted and cancelled tasks that nothing awaits are not errors" <| fun () -> async { let failed = TaskCompletionSource()