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