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
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