From 2464d8ca6328a1db5611a26a525e81fefb25f5c7 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Fri, 2 Oct 2026 23:26:29 +0200 Subject: [PATCH 1/2] test: probe IcedTasks computation expressions in Debug and Release The maintainers allowed IcedTasks on condition that it works in Debug builds. Since .NET SDK 10.0.400 the F# compiler lowers no resumable computation expression to a state machine without optimizations (dotnet/fsharp#20466), so Debug builds run every builder's dynamic implementation, where taskSeq once threw or silently lost items. IcedTasksProbeTests checks valueTask, valueTaskUnit, cancellableTask and cancellableValueTask for results, not only for missing exceptions: awaits of pending and completed Task, Task<'T>, ValueTask and ValueTask<'T>, try/finally and try/with around suspensions, use and use! with IDisposable and IAsyncDisposable, exceptions, cancellation reaching nested binds, and a second and an overlapping start of one cancellable value. The probes run in both configurations, because the builders are inlined into the test assembly and its own configuration decides which implementation runs. Every await a probe relies on suspends for certain: the probes await operations the test completes itself or SuspendingAwaitable, whose awaiter never reports completion up front and fails the probe when a builder takes its result without having suspended. The multi-start probes found that IcedTasks 0.11.9 shared one state machine between all starts of a value on the dynamic path (TheAngryByrd/IcedTasks#65). The fix, TheAngryByrd/IcedTasks#67, is released in 0.11.10-beta001, which this commit references; all 30 probes pass in Debug and Release with none skipped. Co-Authored-By: Claude Opus 5.5 --- Directory.Packages.props | 1 + .../FSharp.Azure.Cosmos.Tests.fsproj | 3 + tests/Cosmos.Tests/IcedTasksProbeTests.fs | 1446 +++++++++++++++++ tests/Cosmos.Tests/TestCategories.fs | 8 + 4 files changed, 1458 insertions(+) create mode 100644 tests/Cosmos.Tests/IcedTasksProbeTests.fs diff --git a/Directory.Packages.props b/Directory.Packages.props index cbda57ab..8824d13f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -9,6 +9,7 @@ + diff --git a/tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj b/tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj index 06996ea1..8223bcfc 100644 --- a/tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj +++ b/tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj @@ -19,6 +19,7 @@ + @@ -39,6 +40,8 @@ + + diff --git a/tests/Cosmos.Tests/IcedTasksProbeTests.fs b/tests/Cosmos.Tests/IcedTasksProbeTests.fs new file mode 100644 index 00000000..1c7728d8 --- /dev/null +++ b/tests/Cosmos.Tests/IcedTasksProbeTests.fs @@ -0,0 +1,1446 @@ +namespace FSharp.Azure.Cosmos.Tests + +open System +open System.Runtime.CompilerServices +open System.Threading +open System.Threading.Tasks +open System.Threading.Tasks.Sources +open Microsoft.VisualStudio.TestTools.UnitTesting +open IcedTasks + +// This awaiter and its awaitable are public, unlike the other helpers in this file: cancellableTask and +// cancellableValueTask bind an awaitable only when its GetAwaiter member is public (FS0043 otherwise), and that member +// returns this awaiter. + +/// +/// The awaiter of one await of : it never reports completion, so the computation +/// expression awaiting it has to suspend, and it resumes that computation expression the way the awaiter of +/// does. +/// +type SuspendingAwaiter<'T> (getResult : unit -> 'T) = + + // Set on the thread that resumes the awaiting computation expression, right before it resumes. + let mutable resumed = false + + let recordingResumption (continuation : Action) = + Action (fun () -> + resumed <- true + continuation.Invoke () + ) + + /// + /// Always , so the awaiting computation expression suspends instead of taking the result at + /// once. + /// + member _.IsCompleted = false + + /// + /// Returns the result, or fails the probe when the computation expression takes it without having been resumed + /// through or + /// . + /// + member _.GetResult () : 'T = + if not resumed then + Assert.Fail "The computation expression took the result of a pending awaiter without suspending on it first." + + getResult () + + interface INotifyCompletion with + /// + member _.OnCompleted continuation = Task.Yield().GetAwaiter().OnCompleted(recordingResumption continuation) + + interface ICriticalNotifyCompletion with + /// + member _.UnsafeOnCompleted continuation = Task.Yield().GetAwaiter().UnsafeOnCompleted(recordingResumption continuation) + +/// +/// An awaitable that always suspends the computation expression awaiting it, then resumes it the way +/// does, with the result of . +/// +/// +/// A task that completes after its producer has called can complete on another thread before +/// the computation expression awaiting it checks it, and the computation expression then takes the result without +/// suspending. This awaitable cannot complete that early, and its awaiter fails the probe when the result is taken +/// without a suspension, so every await of it exercises the builder's resumption path. +/// +type SuspendingAwaitable<'T> (getResult : unit -> 'T) = + + /// Creates the awaiter of one await. + member _.GetAwaiter () = SuspendingAwaiter<'T> getResult + +/// Values and failures the IcedTasks probes await, each of them only after the probe has suspended. +module private ProbeAwaitables = + + /// Suspends the awaiting computation expression, then resumes it with . + let yielded (value : 'T) : SuspendingAwaitable<'T> = SuspendingAwaitable<'T>(fun () -> value) + + /// + /// Suspends the awaiting computation expression, then resumes it with an + /// carrying . + /// + let failsAfterYield (message : string) : SuspendingAwaitable = + SuspendingAwaitable(fun () -> raise (InvalidOperationException message)) + +/// The results every probe expects, whichever computation expression produced them. +module private ProbeExpectations = + + /// The values the sequence-and-loop probes collect: two awaited in sequence, three in a for loop, three in a while loop. + let collected = [| 1..8 |] + + /// The log the awaitable-kinds probes write when every kind of awaitable, pending or already completed, resumes them + /// with its value. + let awaitableKinds = [| + "Task" + "Task: 1" + "ValueTask" + "ValueTask: 2" + "completed Task" + "completed Task: 3" + "completed ValueTask" + "completed ValueTask: 4" + |] + + /// The log the try/finally probes write: each finally block runs exactly once, right after its own try block, and + /// once per iteration inside a loop. + let tryFinally = [| + "inner try" + "inner finally" + "outer try awaited 1" + "loop try awaited 1" + "loop finally 1" + "loop try awaited 2" + "loop finally 2" + "outer finally" + "after try/finally" + |] + + /// The log the use probes write: each resource is disposed exactly once, after the body, in reverse order of acquisition. + let disposal = [| + "body started" + "body awaited 1" + "use! IAsyncDisposable disposed" + "use! IDisposable disposed" + "use IAsyncDisposable disposed" + "use IDisposable disposed" + |] + + /// The log the exception probes write: a handler runs only for a failure, the awaited failure is caught, and the + /// raised one propagates through each disposal and the finally block exactly once. + let exceptions = [| + "no failure: 1" + "caught: awaited failure" + "raising" + "use IAsyncDisposable disposed" + "use IDisposable disposed" + "finally" + |] + + /// The message of the exception the exception probes raise after an await. + [] + let RaisedMessage = "raised after an await" + + /// The log the multi-start probes write over two starts: each start runs the whole body, from its first statement. + let twoStarts = [| "started"; "finished"; "started"; "finished" |] + + /// How long the overlapping-start probes wait for two starts to return before they report a start as blocked. + let startTimeLimit = TimeSpan.FromSeconds 10.0 + +/// +/// The source of one pending or that completes only when the test +/// calls , backed by an rather than by a task, the way most +/// value tasks .NET hands out are. +/// +/// +/// Its continuations run synchronously, so resumes the awaiting probe on the caller's thread and +/// returns only once the probe has suspended again or completed. It counts how often its result was taken, because a +/// value task may be awaited only once. +/// +type private PendingValueTaskSource<'T> () = + + // Synchronous continuations are the default; stated here because the probes depend on them. + let mutable core = ManualResetValueTaskSourceCore<'T>(RunContinuationsAsynchronously = false) + let mutable resultsTaken = 0 + + /// Whether has completed the value task. + member _.IsCompleted = core.GetStatus core.Version <> ValueTaskSourceStatus.Pending + + /// How many times an awaiter has taken the result of the value task. + member _.ResultsTaken = resultsTaken + + /// The value task as a that completes with the value of . + member this.ValueTaskOfResult = ValueTask<'T>(this, core.Version) + + /// The value task as a that completes when is called. + member this.ValueTask = ValueTask (this, core.Version) + + /// Completes the value task with and runs its continuation before returning. + member _.SetResult (value : 'T) = core.SetResult value + + interface IValueTaskSource<'T> with + /// + member _.GetStatus token = core.GetStatus token + + /// + member _.OnCompleted (continuation, state, token, flags) = core.OnCompleted (continuation, state, token, flags) + + /// + member _.GetResult token = + resultsTaken <- resultsTaken + 1 + core.GetResult token + + interface IValueTaskSource with + /// + member this.GetStatus token = (this :> IValueTaskSource<'T>).GetStatus token + + /// + member this.OnCompleted (continuation, state, token, flags) = + (this :> IValueTaskSource<'T>).OnCompleted(continuation, state, token, flags) + + /// + member this.GetResult token = (this :> IValueTaskSource<'T>).GetResult token |> ignore + +/// +/// One pending operation of each awaitable kind a computation expression has to support – , +/// , and – each completed only when the test +/// releases it, so a probe awaits operations that have not finished yet instead of already completed ones. +/// +/// +/// +/// Every completion runs its continuations synchronously: releasing an operation resumes the probe on the test's own +/// thread and returns only once the probe has suspended on the next operation or completed. That is what keeps the next +/// operation pending when the probe reaches it. With asynchronous continuations the test releases every operation before +/// the probe resumes from the first one, and the probe finds the other three already completed. +/// +/// +/// The value tasks come from , which also counts how often the probe takes their +/// results. +/// +/// +type private PendingAwaitables () = + + // No TaskCreationOptions.RunContinuationsAsynchronously: see the remarks. + let pendingTask = TaskCompletionSource () + let pendingTaskOfInt = TaskCompletionSource() + let pendingValueTask = PendingValueTaskSource() + let pendingValueTaskOfInt = PendingValueTaskSource() + + // The operations the probe reached only after the test had released them, so it awaited them already completed. + let reachedAfterRelease = ResizeArray() + + let reach (operation : string) (isReleased : bool) = + if isReleased then + reachedAfterRelease.Add operation + + /// A that completes when releases it first. + member _.Task () : Task = + reach "Task" pendingTask.Task.IsCompleted + pendingTask.Task + + /// A that completes with 1 when releases it second. + member _.TaskOfInt () : Task = + reach "Task" pendingTaskOfInt.Task.IsCompleted + pendingTaskOfInt.Task + + /// A that completes when releases it third. + member _.ValueTask () : ValueTask = + reach "ValueTask" pendingValueTask.IsCompleted + pendingValueTask.ValueTask + + /// A that completes with 2 when releases it last. + member _.ValueTaskOfInt () : ValueTask = + reach "ValueTask" pendingValueTaskOfInt.IsCompleted + pendingValueTaskOfInt.ValueTaskOfResult + + /// + /// Releases the operations in the order the probes await them, checking before each release that the probe has not + /// completed (it cannot while it is still waiting for one of them, unless it skipped an await), and afterwards that + /// the last release ran the probe to its end, that the probe reached every operation before its release, and that it + /// took the result of each value task exactly once. + /// + /// The computation expression under test, for the failure messages. + /// Reports whether the probe has completed. + member _.ReleaseInOrder (builderName : string, isCompleted : unit -> bool) = + Assert.IsFalse (isCompleted (), $"%s{builderName} should still be waiting for the pending Task.") + pendingTask.SetResult () + Assert.IsFalse (isCompleted (), $"%s{builderName} should still be waiting for the pending Task.") + pendingTaskOfInt.SetResult 1 + Assert.IsFalse (isCompleted (), $"%s{builderName} should still be waiting for the pending ValueTask.") + pendingValueTask.SetResult () + Assert.IsFalse (isCompleted (), $"%s{builderName} should still be waiting for the pending ValueTask.") + pendingValueTaskOfInt.SetResult 2 + + Assert.IsTrue ( + isCompleted (), + $"%s{builderName} should run to its end while the last operation is released, because continuations run synchronously." + ) + + Assert.IsEmpty ( + reachedAfterRelease, + $"%s{builderName} should reach every operation while it is still pending, not after the test released it." + ) + + Assert.AreEqual ( + 1, + pendingValueTask.ResultsTaken, + $"%s{builderName} should take the result of the ValueTask exactly once." + ) + + Assert.AreEqual ( + 1, + pendingValueTaskOfInt.ResultsTaken, + $"%s{builderName} should take the result of the ValueTask exactly once." + ) + +/// An that appends one entry to on every disposal. +type private DisposalRecorder (log : ResizeArray, name : string) = + + interface IDisposable with + /// + member _.Dispose () = log.Add $"%s{name} disposed" + +/// +/// An that appends one entry to on every disposal, after +/// has suspended the disposal once. +/// +type private AsyncDisposalRecorder (log : ResizeArray, name : string) = + + interface IAsyncDisposable with + /// + member _.DisposeAsync () = valueTaskUnit { + do! Task.Yield () + log.Add $"%s{name} disposed" + } + +/// +/// Probes the IcedTasks computation expressions library code may use – +/// , +/// , +/// and +/// +/// – in whichever configuration this assembly was built. +/// +/// +/// +/// Since .NET SDK 10.0.400 the F# compiler no longer lowers resumable computation expressions to static state machines +/// in Debug builds (dotnet/fsharp#20466), so they run through their builder's dynamic implementation instead, where +/// threw or silently lost items. The builders are inlined +/// into this assembly, so its own configuration decides which implementation runs: every probe of a builder has to pass +/// in both Debug and Release, none skipped, before library code uses that builder. +/// +/// +/// Each probe asserts the values and the order of the side effects it produced, not only that nothing threw. Its body is +/// written out once per computation expression on purpose: it means something only when compiled by that builder. +/// +/// +/// Where a probe has to suspend, it awaits a or an operation the test completes +/// itself, never a task that might complete before the probe checks it, so the probe deterministically resumes through +/// the builder's resumption path, where the dynamic implementation differs from the static one. +/// +/// +/// The multi-start probes start one cancellable value twice, one start after the other and overlapping. IcedTasks +/// 0.11.9 failed them in Debug: its dynamic implementation created one state machine per value instead of one per start, +/// so a second start resumed where the previous one last suspended, and a start made while another was suspended blocked +/// its caller. IcedTasks 0.11.10-beta001 creates the state machine per start (TheAngryByrd/IcedTasks#67); these probes +/// keep that fix from regressing. +/// +/// +[] +type IcedTasksProbeTests () = + + // valueTask + + [] + member _.``valueTask collects every value awaited in sequence and in loops`` () : Task = task { + let running = valueTask { + let collected = ResizeArray() + let! first = ProbeAwaitables.yielded 1 + collected.Add first + do! Task.Yield () + let! second = ProbeAwaitables.yielded 2 + collected.Add second + + for candidate in 3..5 do + let! value = ProbeAwaitables.yielded candidate + collected.Add value + + let mutable next = 6 + + while next <= 8 do + do! Task.Yield () + let! value = ProbeAwaitables.yielded next + collected.Add value + next <- next + 1 + + return collected.ToArray () + } + + let! collected = running + + CollectionAssert.AreEqual ( + ProbeExpectations.collected, + collected, + "valueTask should collect every value awaited in sequence, in a for loop and in a while loop, in order." + ) + } + + [] + member _.``valueTask resumes with the value of each awaitable kind`` () : Task = task { + let awaitables = PendingAwaitables () + + let running = valueTask { + let log = ResizeArray() + do! awaitables.Task () + log.Add "Task" + let! fromTaskOfInt = awaitables.TaskOfInt () + log.Add $"Task: %d{fromTaskOfInt}" + do! awaitables.ValueTask () + log.Add "ValueTask" + let! fromValueTaskOfInt = awaitables.ValueTaskOfInt () + log.Add $"ValueTask: %d{fromValueTaskOfInt}" + do! Task.CompletedTask + log.Add "completed Task" + let! fromCompletedTaskOfInt = Task.FromResult 3 + log.Add $"completed Task: %d{fromCompletedTaskOfInt}" + do! ValueTask.CompletedTask + log.Add "completed ValueTask" + let! fromCompletedValueTaskOfInt = ValueTask 4 + log.Add $"completed ValueTask: %d{fromCompletedValueTaskOfInt}" + return log.ToArray () + } + + awaitables.ReleaseInOrder ("valueTask", fun () -> running.IsCompleted) + let! log = running + + CollectionAssert.AreEqual ( + ProbeExpectations.awaitableKinds, + log, + "valueTask should resume after a pending and an already completed Task, Task<'T>, ValueTask and ValueTask<'T> with each one's value, in order." + ) + } + + [] + member _.``valueTask runs each finally block exactly once and in order`` () : Task = task { + let log = ResizeArray() + + let running = valueTask { + try + try + log.Add "inner try" + do! Task.Yield () + finally + log.Add "inner finally" + + let! value = ProbeAwaitables.yielded 1 + log.Add $"outer try awaited %d{value}" + + for iteration in 1..2 do + try + let! awaited = ProbeAwaitables.yielded iteration + log.Add $"loop try awaited %d{awaited}" + finally + log.Add $"loop finally %d{iteration}" + finally + log.Add "outer finally" + + log.Add "after try/finally" + } + + do! running + + CollectionAssert.AreEqual ( + ProbeExpectations.tryFinally, + log.ToArray (), + "valueTask should run each finally block exactly once, right after its try block, and once per iteration inside a loop." + ) + } + + [] + member _.``valueTask disposes every used resource exactly once after the body`` () : Task = task { + let log = ResizeArray() + + let running = valueTask { + use syncResource = new DisposalRecorder (log, "use IDisposable") + use asyncResource = new AsyncDisposalRecorder (log, "use IAsyncDisposable") + use! awaitedSyncResource = ProbeAwaitables.yielded (new DisposalRecorder (log, "use! IDisposable")) + use! awaitedAsyncResource = + ProbeAwaitables.yielded (new AsyncDisposalRecorder (log, "use! IAsyncDisposable")) + log.Add "body started" + let! value = ProbeAwaitables.yielded 1 + log.Add $"body awaited %d{value}" + } + + do! running + + CollectionAssert.AreEqual ( + ProbeExpectations.disposal, + log.ToArray (), + "valueTask should dispose each IDisposable and IAsyncDisposable bound with use or use! exactly once, after the body, in reverse order." + ) + } + + [] + member _.``valueTask propagates exceptions through try/with and try/finally`` () : Task = task { + let log = ResizeArray() + + let running = valueTask { + try + try + let! value = ProbeAwaitables.yielded 1 + log.Add $"no failure: %d{value}" + with _ -> + log.Add "handler ran without a failure" + + try + let! _ = ProbeAwaitables.failsAfterYield "awaited failure" + log.Add "after the failed await" + with :? InvalidOperationException as ex -> + log.Add $"caught: %s{ex.Message}" + + use syncResource = new DisposalRecorder (log, "use IDisposable") + use asyncResource = new AsyncDisposalRecorder (log, "use IAsyncDisposable") + do! Task.Yield () + log.Add "raising" + return raise (ArgumentException ProbeExpectations.RaisedMessage) + finally + log.Add "finally" + } + + let! ex = + Assert.ThrowsExactlyAsync( + Func(fun () -> task { + let! (_ : int) = running + return () + }), + "valueTask should propagate an exception raised after an await to its awaiter." + ) + + Assert.AreEqual ( + ProbeExpectations.RaisedMessage, + ex.Message, + "valueTask should propagate the exception its body raised, not another one." + ) + + CollectionAssert.AreEqual ( + ProbeExpectations.exceptions, + log.ToArray (), + "valueTask should run a handler only for a failure, catch the awaited failure, then dispose each resource and run finally exactly once while the raised exception propagates." + ) + } + + // valueTaskUnit + + [] + member _.``valueTaskUnit collects every value awaited in sequence and in loops`` () : Task = task { + let collected = ResizeArray() + + let running = valueTaskUnit { + let! first = ProbeAwaitables.yielded 1 + collected.Add first + do! Task.Yield () + let! second = ProbeAwaitables.yielded 2 + collected.Add second + + for candidate in 3..5 do + let! value = ProbeAwaitables.yielded candidate + collected.Add value + + let mutable next = 6 + + while next <= 8 do + do! Task.Yield () + let! value = ProbeAwaitables.yielded next + collected.Add value + next <- next + 1 + } + + do! running + + CollectionAssert.AreEqual ( + ProbeExpectations.collected, + collected.ToArray (), + "valueTaskUnit should collect every value awaited in sequence, in a for loop and in a while loop, in order." + ) + } + + [] + member _.``valueTaskUnit resumes with the value of each awaitable kind`` () : Task = task { + let awaitables = PendingAwaitables () + let log = ResizeArray() + + let running = valueTaskUnit { + do! awaitables.Task () + log.Add "Task" + let! fromTaskOfInt = awaitables.TaskOfInt () + log.Add $"Task: %d{fromTaskOfInt}" + do! awaitables.ValueTask () + log.Add "ValueTask" + let! fromValueTaskOfInt = awaitables.ValueTaskOfInt () + log.Add $"ValueTask: %d{fromValueTaskOfInt}" + do! Task.CompletedTask + log.Add "completed Task" + let! fromCompletedTaskOfInt = Task.FromResult 3 + log.Add $"completed Task: %d{fromCompletedTaskOfInt}" + do! ValueTask.CompletedTask + log.Add "completed ValueTask" + let! fromCompletedValueTaskOfInt = ValueTask 4 + log.Add $"completed ValueTask: %d{fromCompletedValueTaskOfInt}" + } + + awaitables.ReleaseInOrder ("valueTaskUnit", fun () -> running.IsCompleted) + do! running + + CollectionAssert.AreEqual ( + ProbeExpectations.awaitableKinds, + log.ToArray (), + "valueTaskUnit should resume after a pending and an already completed Task, Task<'T>, ValueTask and ValueTask<'T> with each one's value, in order." + ) + } + + [] + member _.``valueTaskUnit runs each finally block exactly once and in order`` () : Task = task { + let log = ResizeArray() + + let running = valueTaskUnit { + try + try + log.Add "inner try" + do! Task.Yield () + finally + log.Add "inner finally" + + let! value = ProbeAwaitables.yielded 1 + log.Add $"outer try awaited %d{value}" + + for iteration in 1..2 do + try + let! awaited = ProbeAwaitables.yielded iteration + log.Add $"loop try awaited %d{awaited}" + finally + log.Add $"loop finally %d{iteration}" + finally + log.Add "outer finally" + + log.Add "after try/finally" + } + + do! running + + CollectionAssert.AreEqual ( + ProbeExpectations.tryFinally, + log.ToArray (), + "valueTaskUnit should run each finally block exactly once, right after its try block, and once per iteration inside a loop." + ) + } + + [] + member _.``valueTaskUnit disposes every used resource exactly once after the body`` () : Task = task { + let log = ResizeArray() + + let running = valueTaskUnit { + use syncResource = new DisposalRecorder (log, "use IDisposable") + use asyncResource = new AsyncDisposalRecorder (log, "use IAsyncDisposable") + use! awaitedSyncResource = ProbeAwaitables.yielded (new DisposalRecorder (log, "use! IDisposable")) + use! awaitedAsyncResource = + ProbeAwaitables.yielded (new AsyncDisposalRecorder (log, "use! IAsyncDisposable")) + log.Add "body started" + let! value = ProbeAwaitables.yielded 1 + log.Add $"body awaited %d{value}" + } + + do! running + + CollectionAssert.AreEqual ( + ProbeExpectations.disposal, + log.ToArray (), + "valueTaskUnit should dispose each IDisposable and IAsyncDisposable bound with use or use! exactly once, after the body, in reverse order." + ) + } + + [] + member _.``valueTaskUnit propagates exceptions through try/with and try/finally`` () : Task = task { + let log = ResizeArray() + + let running = valueTaskUnit { + try + try + let! value = ProbeAwaitables.yielded 1 + log.Add $"no failure: %d{value}" + with _ -> + log.Add "handler ran without a failure" + + try + let! _ = ProbeAwaitables.failsAfterYield "awaited failure" + log.Add "after the failed await" + with :? InvalidOperationException as ex -> + log.Add $"caught: %s{ex.Message}" + + use syncResource = new DisposalRecorder (log, "use IDisposable") + use asyncResource = new AsyncDisposalRecorder (log, "use IAsyncDisposable") + do! Task.Yield () + log.Add "raising" + raise (ArgumentException ProbeExpectations.RaisedMessage) + finally + log.Add "finally" + } + + let! ex = + Assert.ThrowsExactlyAsync( + Func(fun () -> task { do! running }), + "valueTaskUnit should propagate an exception raised after an await to its awaiter." + ) + + Assert.AreEqual ( + ProbeExpectations.RaisedMessage, + ex.Message, + "valueTaskUnit should propagate the exception its body raised, not another one." + ) + + CollectionAssert.AreEqual ( + ProbeExpectations.exceptions, + log.ToArray (), + "valueTaskUnit should run a handler only for a failure, catch the awaited failure, then dispose each resource and run finally exactly once while the raised exception propagates." + ) + } + + // cancellableTask + + [] + member _.``cancellableTask collects every value awaited in sequence and in loops`` () : Task = task { + let probe = cancellableTask { + let collected = ResizeArray() + let! first = ProbeAwaitables.yielded 1 + collected.Add first + do! Task.Yield () + let! second = ProbeAwaitables.yielded 2 + collected.Add second + + for candidate in 3..5 do + let! value = ProbeAwaitables.yielded candidate + collected.Add value + + let mutable next = 6 + + while next <= 8 do + do! Task.Yield () + let! value = ProbeAwaitables.yielded next + collected.Add value + next <- next + 1 + + return collected.ToArray () + } + + use cts = new CancellationTokenSource () + let! collected = probe cts.Token + + CollectionAssert.AreEqual ( + ProbeExpectations.collected, + collected, + "cancellableTask should collect every value awaited in sequence, in a for loop and in a while loop, in order." + ) + } + + [] + member _.``cancellableTask resumes with the value of each awaitable kind`` () : Task = task { + let awaitables = PendingAwaitables () + + let probe = cancellableTask { + let log = ResizeArray() + do! awaitables.Task () + log.Add "Task" + let! fromTaskOfInt = awaitables.TaskOfInt () + log.Add $"Task: %d{fromTaskOfInt}" + do! awaitables.ValueTask () + log.Add "ValueTask" + let! fromValueTaskOfInt = awaitables.ValueTaskOfInt () + log.Add $"ValueTask: %d{fromValueTaskOfInt}" + do! Task.CompletedTask + log.Add "completed Task" + let! fromCompletedTaskOfInt = Task.FromResult 3 + log.Add $"completed Task: %d{fromCompletedTaskOfInt}" + do! ValueTask.CompletedTask + log.Add "completed ValueTask" + let! fromCompletedValueTaskOfInt = ValueTask 4 + log.Add $"completed ValueTask: %d{fromCompletedValueTaskOfInt}" + return log.ToArray () + } + + use cts = new CancellationTokenSource () + let running = probe cts.Token + awaitables.ReleaseInOrder ("cancellableTask", fun () -> running.IsCompleted) + let! log = running + + CollectionAssert.AreEqual ( + ProbeExpectations.awaitableKinds, + log, + "cancellableTask should resume after a pending and an already completed Task, Task<'T>, ValueTask and ValueTask<'T> with each one's value, in order." + ) + } + + [] + member _.``cancellableTask runs each finally block exactly once and in order`` () : Task = task { + let log = ResizeArray() + + let probe = cancellableTask { + try + try + log.Add "inner try" + do! Task.Yield () + finally + log.Add "inner finally" + + let! value = ProbeAwaitables.yielded 1 + log.Add $"outer try awaited %d{value}" + + for iteration in 1..2 do + try + let! awaited = ProbeAwaitables.yielded iteration + log.Add $"loop try awaited %d{awaited}" + finally + log.Add $"loop finally %d{iteration}" + finally + log.Add "outer finally" + + log.Add "after try/finally" + } + + use cts = new CancellationTokenSource () + do! probe cts.Token + + CollectionAssert.AreEqual ( + ProbeExpectations.tryFinally, + log.ToArray (), + "cancellableTask should run each finally block exactly once, right after its try block, and once per iteration inside a loop." + ) + } + + [] + member _.``cancellableTask disposes every used resource exactly once after the body`` () : Task = task { + let log = ResizeArray() + + let probe = cancellableTask { + use syncResource = new DisposalRecorder (log, "use IDisposable") + use asyncResource = new AsyncDisposalRecorder (log, "use IAsyncDisposable") + use! awaitedSyncResource = ProbeAwaitables.yielded (new DisposalRecorder (log, "use! IDisposable")) + use! awaitedAsyncResource = + ProbeAwaitables.yielded (new AsyncDisposalRecorder (log, "use! IAsyncDisposable")) + log.Add "body started" + let! value = ProbeAwaitables.yielded 1 + log.Add $"body awaited %d{value}" + } + + use cts = new CancellationTokenSource () + do! probe cts.Token + + CollectionAssert.AreEqual ( + ProbeExpectations.disposal, + log.ToArray (), + "cancellableTask should dispose each IDisposable and IAsyncDisposable bound with use or use! exactly once, after the body, in reverse order." + ) + } + + [] + member _.``cancellableTask propagates exceptions through try/with and try/finally`` () : Task = task { + let log = ResizeArray() + + let probe = cancellableTask { + try + try + let! value = ProbeAwaitables.yielded 1 + log.Add $"no failure: %d{value}" + with _ -> + log.Add "handler ran without a failure" + + try + let! _ = ProbeAwaitables.failsAfterYield "awaited failure" + log.Add "after the failed await" + with :? InvalidOperationException as ex -> + log.Add $"caught: %s{ex.Message}" + + use syncResource = new DisposalRecorder (log, "use IDisposable") + use asyncResource = new AsyncDisposalRecorder (log, "use IAsyncDisposable") + do! Task.Yield () + log.Add "raising" + return raise (ArgumentException ProbeExpectations.RaisedMessage) + finally + log.Add "finally" + } + + use cts = new CancellationTokenSource () + + let! ex = + Assert.ThrowsExactlyAsync( + Func(fun () -> task { + let! (_ : int) = probe cts.Token + return () + }), + "cancellableTask should propagate an exception raised after an await to its awaiter." + ) + + Assert.AreEqual ( + ProbeExpectations.RaisedMessage, + ex.Message, + "cancellableTask should propagate the exception its body raised, not another one." + ) + + CollectionAssert.AreEqual ( + ProbeExpectations.exceptions, + log.ToArray (), + "cancellableTask should run a handler only for a failure, catch the awaited failure, then dispose each resource and run finally exactly once while the raised exception propagates." + ) + } + + [] + member _.``cancellableTask does not start its body with an already cancelled token`` () : Task = task { + let log = ResizeArray() + + let probe = cancellableTask { + log.Add "started" + do! Task.Yield () + log.Add "finished" + } + + use cts = new CancellationTokenSource () + cts.Cancel () + + let! ex = + Assert.ThrowsAsync( + Func(fun () -> task { do! probe cts.Token }), + "cancellableTask should observe a token that is already cancelled when it starts." + ) + + Assert.AreEqual (cts.Token, ex.CancellationToken, "cancellableTask should report the token it was started with.") + Assert.IsEmpty (log, "cancellableTask should not run any of its body with an already cancelled token.") + } + + [] + member _.``cancellableTask observes cancellation before its next bind`` () : Task = task { + let log = ResizeArray() + use cts = new CancellationTokenSource () + + let probe = cancellableTask { + try + let! first = ProbeAwaitables.yielded 1 + log.Add $"first: %d{first}" + cts.Cancel () + let! second = ProbeAwaitables.yielded 2 + log.Add $"second: %d{second}" + finally + log.Add "finally" + } + + let! ex = + Assert.ThrowsAsync( + Func(fun () -> task { do! probe cts.Token }), + "cancellableTask should observe a token cancelled during its body at the next bind." + ) + + Assert.AreEqual (cts.Token, ex.CancellationToken, "cancellableTask should report the token it was started with.") + + CollectionAssert.AreEqual ( + [| "first: 1"; "finally" |], + log.ToArray (), + "cancellableTask should stop before the bind that follows the cancellation and still run finally exactly once." + ) + } + + [] + member _.``cancellableTask passes its token to every nested bind`` () : Task = task { + // Built by a function for every bind, so that this probe checks only how the token flows and does not depend on + // how one value behaves when started more than once, which the multi-start probes check. + let nestedCancellableTask () = cancellableTask { + do! Task.Yield () + return! CancellableTask.getCancellationToken () + } + + let nestedCancellableValueTask () = cancellableValueTask { + do! Task.Yield () + return! CancellableValueTask.getCancellationToken () + } + + let probe = cancellableTask { + let! own = CancellableTask.getCancellationToken () + let! fromCancellableTask = nestedCancellableTask () + let! fromCancellableValueTask = nestedCancellableValueTask () + let! fromFunction = fun (token : CancellationToken) -> Task.FromResult token + + let! fromTwoLevelsDown = cancellableTask { + let! fromInner = nestedCancellableTask () + return fromInner + } + + return [| own; fromCancellableTask; fromCancellableValueTask; fromFunction; fromTwoLevelsDown |] + } + + use cts = new CancellationTokenSource () + let! tokens = probe cts.Token + + CollectionAssert.AreEqual ( + Array.create 5 cts.Token, + tokens, + "cancellableTask should pass its token to itself, a nested cancellableTask, a nested cancellableValueTask, a CancellationToken -> Task function and a cancellableTask nested two levels down." + ) + } + + [] + member _.``cancellableTask runs again from the start when started a second time`` () : Task = task { + let log = ResizeArray() + + let probe = cancellableTask { + log.Add "started" + let collected = ResizeArray() + + for candidate in 1..3 do + let! value = ProbeAwaitables.yielded candidate + collected.Add value + + log.Add "finished" + return collected.ToArray () + } + + use cts = new CancellationTokenSource () + let! first = probe cts.Token + let! second = probe cts.Token + + CollectionAssert.AreEqual ([| 1; 2; 3 |], first, "cancellableTask should collect every value on its first run.") + + CollectionAssert.AreEqual ( + [| 1; 2; 3 |], + second, + "cancellableTask should run again from the start, with fresh state, when started a second time." + ) + + CollectionAssert.AreEqual ( + ProbeExpectations.twoStarts, + log.ToArray (), + "cancellableTask should run its whole body, from the first statement, on every start." + ) + } + + [] + member _.``cancellableTask runs overlapping starts of the same value independently`` () : Task = task { + let gate = TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously) + + let probe = cancellableTask { + let collected = ResizeArray() + collected.Add 1 + do! gate.Task + let! value = ProbeAwaitables.yielded 2 + collected.Add value + let! token = CancellableTask.getCancellationToken () + return struct (token, collected.ToArray ()) + } + + use firstSource = new CancellationTokenSource () + use secondSource = new CancellationTokenSource () + + // Both starts run on a thread pool thread under a time limit: a builder that shares one state machine between + // the starts of a value blocks the second start on the first one's pending await, which would hang the run. + let starting = Task.Run (Func<_>(fun () -> struct (probe firstSource.Token, probe secondSource.Token))) + + try + let! returned = Task.WhenAny (starting, Task.Delay ProbeExpectations.startTimeLimit) + + Assert.AreSame ( + starting :> Task, + returned, + "cancellableTask should return from a second start while the first start is still suspended." + ) + + let! struct (firstRun, secondRun) = starting + gate.SetResult () + let! struct (firstToken, firstCollected) = firstRun + let! struct (secondToken, secondCollected) = secondRun + + Assert.AreEqual (firstSource.Token, firstToken, "The first start of cancellableTask should observe its own token.") + Assert.AreEqual (secondSource.Token, secondToken, "The second start of cancellableTask should observe its own token.") + CollectionAssert.AreEqual ( + [| 1; 2 |], + firstCollected, + "The first start of cancellableTask should collect its own values." + ) + + CollectionAssert.AreEqual ( + [| 1; 2 |], + secondCollected, + "The second start of cancellableTask should collect its own values." + ) + finally + // Lets a start blocked on the gate finish after a failed assertion. + gate.TrySetResult () |> ignore + } + + // cancellableValueTask + + [] + member _.``cancellableValueTask collects every value awaited in sequence and in loops`` () : Task = task { + let probe = cancellableValueTask { + let collected = ResizeArray() + let! first = ProbeAwaitables.yielded 1 + collected.Add first + do! Task.Yield () + let! second = ProbeAwaitables.yielded 2 + collected.Add second + + for candidate in 3..5 do + let! value = ProbeAwaitables.yielded candidate + collected.Add value + + let mutable next = 6 + + while next <= 8 do + do! Task.Yield () + let! value = ProbeAwaitables.yielded next + collected.Add value + next <- next + 1 + + return collected.ToArray () + } + + use cts = new CancellationTokenSource () + let! collected = probe cts.Token + + CollectionAssert.AreEqual ( + ProbeExpectations.collected, + collected, + "cancellableValueTask should collect every value awaited in sequence, in a for loop and in a while loop, in order." + ) + } + + [] + member _.``cancellableValueTask resumes with the value of each awaitable kind`` () : Task = task { + let awaitables = PendingAwaitables () + + let probe = cancellableValueTask { + let log = ResizeArray() + do! awaitables.Task () + log.Add "Task" + let! fromTaskOfInt = awaitables.TaskOfInt () + log.Add $"Task: %d{fromTaskOfInt}" + do! awaitables.ValueTask () + log.Add "ValueTask" + let! fromValueTaskOfInt = awaitables.ValueTaskOfInt () + log.Add $"ValueTask: %d{fromValueTaskOfInt}" + do! Task.CompletedTask + log.Add "completed Task" + let! fromCompletedTaskOfInt = Task.FromResult 3 + log.Add $"completed Task: %d{fromCompletedTaskOfInt}" + do! ValueTask.CompletedTask + log.Add "completed ValueTask" + let! fromCompletedValueTaskOfInt = ValueTask 4 + log.Add $"completed ValueTask: %d{fromCompletedValueTaskOfInt}" + return log.ToArray () + } + + use cts = new CancellationTokenSource () + let running = probe cts.Token + awaitables.ReleaseInOrder ("cancellableValueTask", fun () -> running.IsCompleted) + let! log = running + + CollectionAssert.AreEqual ( + ProbeExpectations.awaitableKinds, + log, + "cancellableValueTask should resume after a pending and an already completed Task, Task<'T>, ValueTask and ValueTask<'T> with each one's value, in order." + ) + } + + [] + member _.``cancellableValueTask runs each finally block exactly once and in order`` () : Task = task { + let log = ResizeArray() + + let probe = cancellableValueTask { + try + try + log.Add "inner try" + do! Task.Yield () + finally + log.Add "inner finally" + + let! value = ProbeAwaitables.yielded 1 + log.Add $"outer try awaited %d{value}" + + for iteration in 1..2 do + try + let! awaited = ProbeAwaitables.yielded iteration + log.Add $"loop try awaited %d{awaited}" + finally + log.Add $"loop finally %d{iteration}" + finally + log.Add "outer finally" + + log.Add "after try/finally" + } + + use cts = new CancellationTokenSource () + do! probe cts.Token + + CollectionAssert.AreEqual ( + ProbeExpectations.tryFinally, + log.ToArray (), + "cancellableValueTask should run each finally block exactly once, right after its try block, and once per iteration inside a loop." + ) + } + + [] + member _.``cancellableValueTask disposes every used resource exactly once after the body`` () : Task = task { + let log = ResizeArray() + + let probe = cancellableValueTask { + use syncResource = new DisposalRecorder (log, "use IDisposable") + use asyncResource = new AsyncDisposalRecorder (log, "use IAsyncDisposable") + use! awaitedSyncResource = ProbeAwaitables.yielded (new DisposalRecorder (log, "use! IDisposable")) + use! awaitedAsyncResource = + ProbeAwaitables.yielded (new AsyncDisposalRecorder (log, "use! IAsyncDisposable")) + log.Add "body started" + let! value = ProbeAwaitables.yielded 1 + log.Add $"body awaited %d{value}" + } + + use cts = new CancellationTokenSource () + do! probe cts.Token + + CollectionAssert.AreEqual ( + ProbeExpectations.disposal, + log.ToArray (), + "cancellableValueTask should dispose each IDisposable and IAsyncDisposable bound with use or use! exactly once, after the body, in reverse order." + ) + } + + [] + member _.``cancellableValueTask propagates exceptions through try/with and try/finally`` () : Task = task { + let log = ResizeArray() + + let probe = cancellableValueTask { + try + try + let! value = ProbeAwaitables.yielded 1 + log.Add $"no failure: %d{value}" + with _ -> + log.Add "handler ran without a failure" + + try + let! _ = ProbeAwaitables.failsAfterYield "awaited failure" + log.Add "after the failed await" + with :? InvalidOperationException as ex -> + log.Add $"caught: %s{ex.Message}" + + use syncResource = new DisposalRecorder (log, "use IDisposable") + use asyncResource = new AsyncDisposalRecorder (log, "use IAsyncDisposable") + do! Task.Yield () + log.Add "raising" + return raise (ArgumentException ProbeExpectations.RaisedMessage) + finally + log.Add "finally" + } + + use cts = new CancellationTokenSource () + + let! ex = + Assert.ThrowsExactlyAsync( + Func(fun () -> task { + let! (_ : int) = probe cts.Token + return () + }), + "cancellableValueTask should propagate an exception raised after an await to its awaiter." + ) + + Assert.AreEqual ( + ProbeExpectations.RaisedMessage, + ex.Message, + "cancellableValueTask should propagate the exception its body raised, not another one." + ) + + CollectionAssert.AreEqual ( + ProbeExpectations.exceptions, + log.ToArray (), + "cancellableValueTask should run a handler only for a failure, catch the awaited failure, then dispose each resource and run finally exactly once while the raised exception propagates." + ) + } + + [] + member _.``cancellableValueTask does not start its body with an already cancelled token`` () : Task = task { + let log = ResizeArray() + + let probe = cancellableValueTask { + log.Add "started" + do! Task.Yield () + log.Add "finished" + } + + use cts = new CancellationTokenSource () + cts.Cancel () + + let! ex = + Assert.ThrowsAsync( + Func(fun () -> task { do! probe cts.Token }), + "cancellableValueTask should observe a token that is already cancelled when it starts." + ) + + Assert.AreEqual (cts.Token, ex.CancellationToken, "cancellableValueTask should report the token it was started with.") + Assert.IsEmpty (log, "cancellableValueTask should not run any of its body with an already cancelled token.") + } + + [] + member _.``cancellableValueTask observes cancellation before its next bind`` () : Task = task { + let log = ResizeArray() + use cts = new CancellationTokenSource () + + let probe = cancellableValueTask { + try + let! first = ProbeAwaitables.yielded 1 + log.Add $"first: %d{first}" + cts.Cancel () + let! second = ProbeAwaitables.yielded 2 + log.Add $"second: %d{second}" + finally + log.Add "finally" + } + + let! ex = + Assert.ThrowsAsync( + Func(fun () -> task { do! probe cts.Token }), + "cancellableValueTask should observe a token cancelled during its body at the next bind." + ) + + Assert.AreEqual (cts.Token, ex.CancellationToken, "cancellableValueTask should report the token it was started with.") + + CollectionAssert.AreEqual ( + [| "first: 1"; "finally" |], + log.ToArray (), + "cancellableValueTask should stop before the bind that follows the cancellation and still run finally exactly once." + ) + } + + [] + member _.``cancellableValueTask passes its token to every nested bind`` () : Task = task { + // Built by a function for every bind, so that this probe checks only how the token flows and does not depend on + // how one value behaves when started more than once, which the multi-start probes check. + let nestedCancellableTask () = cancellableTask { + do! Task.Yield () + return! CancellableTask.getCancellationToken () + } + + let nestedCancellableValueTask () = cancellableValueTask { + do! Task.Yield () + return! CancellableValueTask.getCancellationToken () + } + + let probe = cancellableValueTask { + let! own = CancellableValueTask.getCancellationToken () + let! fromCancellableTask = nestedCancellableTask () + let! fromCancellableValueTask = nestedCancellableValueTask () + let! fromFunction = fun (token : CancellationToken) -> Task.FromResult token + + let! fromTwoLevelsDown = cancellableValueTask { + let! fromInner = nestedCancellableValueTask () + return fromInner + } + + return [| own; fromCancellableTask; fromCancellableValueTask; fromFunction; fromTwoLevelsDown |] + } + + use cts = new CancellationTokenSource () + let! tokens = probe cts.Token + + CollectionAssert.AreEqual ( + Array.create 5 cts.Token, + tokens, + "cancellableValueTask should pass its token to itself, a nested cancellableTask, a nested cancellableValueTask, a CancellationToken -> Task function and a cancellableValueTask nested two levels down." + ) + } + + [] + member _.``cancellableValueTask runs again from the start when started a second time`` () : Task = task { + let log = ResizeArray() + + let probe = cancellableValueTask { + log.Add "started" + let collected = ResizeArray() + + for candidate in 1..3 do + let! value = ProbeAwaitables.yielded candidate + collected.Add value + + log.Add "finished" + return collected.ToArray () + } + + use cts = new CancellationTokenSource () + let! first = probe cts.Token + let! second = probe cts.Token + + CollectionAssert.AreEqual ([| 1; 2; 3 |], first, "cancellableValueTask should collect every value on its first run.") + + CollectionAssert.AreEqual ( + [| 1; 2; 3 |], + second, + "cancellableValueTask should run again from the start, with fresh state, when started a second time." + ) + + CollectionAssert.AreEqual ( + ProbeExpectations.twoStarts, + log.ToArray (), + "cancellableValueTask should run its whole body, from the first statement, on every start." + ) + } + + [] + member _.``cancellableValueTask runs overlapping starts of the same value independently`` () : Task = task { + let gate = TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously) + + let probe = cancellableValueTask { + let collected = ResizeArray() + collected.Add 1 + do! gate.Task + let! value = ProbeAwaitables.yielded 2 + collected.Add value + let! token = CancellableValueTask.getCancellationToken () + return struct (token, collected.ToArray ()) + } + + use firstSource = new CancellationTokenSource () + use secondSource = new CancellationTokenSource () + + // Both starts run on a thread pool thread under a time limit: a builder that shares one state machine between + // the starts of a value blocks the second start on the first one's pending await, which would hang the run. + let starting = Task.Run (Func<_>(fun () -> struct (probe firstSource.Token, probe secondSource.Token))) + + try + let! returned = Task.WhenAny (starting, Task.Delay ProbeExpectations.startTimeLimit) + + Assert.AreSame ( + starting :> Task, + returned, + "cancellableValueTask should return from a second start while the first start is still suspended." + ) + + let! struct (firstRun, secondRun) = starting + gate.SetResult () + let! struct (firstToken, firstCollected) = firstRun + let! struct (secondToken, secondCollected) = secondRun + + Assert.AreEqual ( + firstSource.Token, + firstToken, + "The first start of cancellableValueTask should observe its own token." + ) + Assert.AreEqual ( + secondSource.Token, + secondToken, + "The second start of cancellableValueTask should observe its own token." + ) + + CollectionAssert.AreEqual ( + [| 1; 2 |], + firstCollected, + "The first start of cancellableValueTask should collect its own values." + ) + + CollectionAssert.AreEqual ( + [| 1; 2 |], + secondCollected, + "The second start of cancellableValueTask should collect its own values." + ) + finally + // Lets a start blocked on the gate finish after a failed assertion. + gate.TrySetResult () |> ignore + } diff --git a/tests/Cosmos.Tests/TestCategories.fs b/tests/Cosmos.Tests/TestCategories.fs index faf9385d..2b733741 100644 --- a/tests/Cosmos.Tests/TestCategories.fs +++ b/tests/Cosmos.Tests/TestCategories.fs @@ -56,6 +56,14 @@ type ValidationTestCategoryAttribute () = inherit TestCategoryBaseAttribute () override _.TestCategories = [| "Validation" |] :> IList +/// +/// Categorizes as both a fast, emulator-free unit test and the probe that gates the +/// use of IcedTasks computation expressions in library code. +/// +type IcedTasksProbeTestCategoryAttribute () = + inherit TestCategoryBaseAttribute () + override _.TestCategories = [| "Unit"; "IcedTasksProbe" |] :> IList + /// /// Categorizes as both a fast, emulator-free unit test and coverage for the /// iteration extensions: and From f57bb2b8cd210896cbea625ed96d0431516d80a6 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Fri, 2 Oct 2026 23:26:29 +0200 Subject: [PATCH 2/2] docs: gate IcedTasks builders in library code on their probes The maintainers confirmed the IcedTasks dependency on 2026-09-27. Debug builds on the pinned SDK run every resumable computation expression through its builder's dynamic implementation (dotnet/fsharp#20466), so the instructions let library code use an IcedTasks builder only once every one of its probes in IcedTasksProbeTests passes in both Debug and Release with none skipped. With IcedTasks 0.11.10-beta001, which starts every cold and cancellable value with a state machine of its own (TheAngryByrd/IcedTasks#67), that holds for valueTask, valueTaskUnit, cancellableTask and cancellableValueTask; any other builder needs probes first. The rule keeps the note that a library reference makes IcedTasks a transitive dependency for every consumer. Co-Authored-By: Claude Opus 5.5 --- .github/copilot-instructions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 6db9d737..49bc09a8 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -65,7 +65,7 @@ Agents discover servers from #file:'.mcp.json'; these are only hints on when to * When a method must return non-generic `Task`, annotate the return type explicitly: `member _.MyMethod (...) : Task = task { ... }`. Never cast through pipelines. * `task` CE can await `ValueTask` APIs directly. * Curried functions take the `CancellationToken` as their **first** parameter, so that it can be partially applied. -* Never hand-roll wrappers such as `ValueTask (task { ... })`, `.AsTask ()` round-trips or manual `unit -> Task` thunks. Use the matching [`IcedTasks`](https://github.com/TheAngryByrd/IcedTasks) CE instead (`valueTask`, `valueTaskUnit`, `taskUnit`, `coldTask`, `cancellableTask`, `cancellableValueTask`, `backgroundTask`, …) and always use the full CE names, not the short aliases (`vTask`, `pvTask`, …). This library does not reference `IcedTasks` yet – adding it introduces a transitive dependency for every consumer, so confirm with the maintainers first. +* Never hand-roll wrappers such as `ValueTask (task { ... })`, `.AsTask ()` round-trips or manual `unit -> Task` thunks. Use the matching [`IcedTasks`](https://github.com/TheAngryByrd/IcedTasks) CE instead (`valueTask`, `valueTaskUnit`, `taskUnit`, `coldTask`, `cancellableTask`, `cancellableValueTask`, `backgroundTask`, …) and always use the full CE names, not the short aliases (`vTask`, `pvTask`, …). The maintainers confirmed the `IcedTasks` dependency on 2026-09-27. Debug builds on the pinned SDK run every resumable computation expression through its builder's dynamic implementation (dotnet/fsharp#20466), so library code uses a builder only once every one of its probes in `IcedTasksProbeTests` passes in both Debug and Release, none skipped. With `IcedTasks` 0.11.10-beta001, the first release whose dynamic implementation starts every cold and cancellable value with a state machine of its own (TheAngryByrd/IcedTasks#67), that holds for `valueTask`, `valueTaskUnit`, `cancellableTask` and `cancellableValueTask`; every other builder needs probes first. Referencing `IcedTasks` from the library makes it a transitive dependency for every consumer. ### Values and Collections