Skip to content

Commit d6a0470

Browse files
committed
Doc polish / characterization tests
1 parent 41f5f13 commit d6a0470

2 files changed

Lines changed: 143 additions & 31 deletions

File tree

src/FSharp.Core/async.fsi

Lines changed: 38 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -47,29 +47,27 @@ namespace Microsoft.FSharp.Control
4747
[<CompiledName("FSharpAsync")>]
4848
type Async =
4949

50-
/// <summary>Runs the asynchronous computation and await its result.</summary>
51-
///
52-
/// <remarks>If an exception occurs in the asynchronous computation then it will be propagated to the caller.
53-
///
54-
/// If no cancellation token is provided then the default cancellation token is used.
55-
///
56-
/// The computation is started on the current thread if <see cref="P:System.Threading.SynchronizationContext.Current"/> is null,
57-
/// <see cref="P:System.Threading.Thread.CurrentThread"/> has <see cref="P:System.Threading.Thread.IsThreadPoolThread"/>
58-
/// of <c>true</c>, and no timeout is specified.<br/>
59-
///
60-
/// Otherwise the computation is offloaded to the thread pool,
61-
/// and the current thread is blocked awaiting the completion of the computation. Note in this case the stacktrace will be incomplete.<br/>
62-
///
63-
/// The timeout parameter is given in milliseconds. A value of -1 is equivalent to
64-
/// <see cref="F:System.Threading.Timeout.Infinite"/>.
50+
/// <summary><p>Runs the asynchronous computation in a background context threadpool thread and awaits its result,
51+
/// blocking the calling thread.</p><p>Any exception raised by the computation is propagated to the caller, with a potentially truncated stack-trace.</p></summary>
52+
/// <remarks>
53+
/// <p>The computation runs on the current thread when
54+
/// <see cref="P:System.Threading.SynchronizationContext.Current"/> is <c>null</c>,
55+
/// <see cref="P:System.Threading.Thread.IsThreadPoolThread"/> is <c>true</c>, and no timeout is specified.
56+
/// Otherwise — which includes F# interactive sessions and GUI threads — it is offloaded to the thread
57+
/// pool while the calling thread blocks; in that case exception stack traces will be incomplete,
58+
/// showing only thread-pool frames and omitting the caller's frame.</p>
59+
/// <p>For F# interactive, F# scripts, and unit tests where complete exception stack traces are desired,
60+
/// prefer <see cref="M:Microsoft.FSharp.Control.FSharpAsync.RunSynchronouslyImmediate``1"/>, which
61+
/// always starts on the calling thread. (Note that overload does not support a timeout, and blocking the foreground thread can lead to deadlock).
62+
/// </p>
6563
/// </remarks>
6664
///
6765
/// <param name="computation">The computation to run.</param>
6866
/// <param name="timeout">The number of milliseconds to wait for the result of the
6967
/// computation before raising a <see cref="T:System.TimeoutException"/>. If no value or -1 is provided
7068
/// the timeout will be <see cref="F:System.Threading.Timeout.Infinite"/>.</param>
7169
/// <param name="cancellationToken">The cancellation token to be associated with the computation.
72-
/// If one is not supplied, the default cancellation token is used.</param>
70+
/// If omitted, <c>Async.DefaultCancellationToken</c> is used.</param>
7371
///
7472
/// <returns>The result of the computation.</returns>
7573
///
@@ -92,25 +90,33 @@ namespace Microsoft.FSharp.Control
9290
/// </example>
9391
static member RunSynchronously : computation:Async<'T> * ?timeout : int * ?cancellationToken:CancellationToken-> 'T
9492

95-
/// <summary>Runs the asynchronous computation synchronously on the current thread, yielding its result.</summary>
96-
///
97-
/// <remarks>Unlike <see cref="M:Microsoft.FSharp.Control.FSharpAsync.RunSynchronously``1"/>, execution
98-
/// always starts immediately on the calling thread even if
99-
/// <see cref="P:System.Threading.SynchronizationContext.Current"/> being non-<c>null</c> or
100-
/// <see cref="P:System.Threading.Thread.IsThreadPoolThread"/> being <c>false</c> would normally dictate offloading to a threadpool thread.
101-
/// This key benefit is that this preserves call-stack context in the case of an exception.
102-
///
103-
/// If an exception occurs in the asynchronous computation then it will be propagated to the caller.
104-
///
105-
/// If no cancellation token is provided then the default cancellation token is used.
93+
/// <summary><p>Runs the asynchronous computation synchronously, always starting and blocking on the
94+
/// calling thread regardless of <see cref="P:System.Threading.SynchronizationContext.Current"/> being non-
95+
/// <c>null</c> or <see cref="P:System.Threading.Thread.IsThreadPoolThread"/> being <c>false</c>.</p>
96+
/// <p>Any exception raised by the computation is propagated to the caller, with a complete stack-trace.</p>
97+
/// <p>Warning: may cause deadlock if called on a UI thread.</p>
98+
/// </summary>
10699
///
107-
/// This overload does not support a timeout; see <see cref="M:Microsoft.FSharp.Control.FSharpAsync.RunSynchronously``1"/>
108-
/// if a timeout is required.
100+
/// <remarks>
101+
/// <p>Warning: this method hard-blocks the calling thread for the duration of the computation,
102+
/// including threads that have a non-<c>null</c>
103+
/// <see cref="P:System.Threading.SynchronizationContext.Current"/> such as UI threads. Calling it
104+
/// from a UI thread will make the UI unresponsive and risks deadlock if any continuation in the
105+
/// computation needs to be dispatched back to that context.
106+
/// </p>
107+
/// <p>Unlike <see cref="M:Microsoft.FSharp.Control.FSharpAsync.RunSynchronously``1"/>, this
108+
/// method never offloads to the thread pool and/or a background context, so exception stack traces always include
109+
/// the full call chain from the invocation site. This makes it the preferred mechanism for interactive use in
110+
/// F# scripts and F# interactive (FSI), and for unit tests.
111+
/// </p>
112+
/// <p>This overload does not support a timeout; see
113+
/// <see cref="M:Microsoft.FSharp.Control.FSharpAsync.RunSynchronously``1"/> if a timeout is required.
114+
/// </p>
109115
/// </remarks>
110116
///
111117
/// <param name="computation">The computation to run.</param>
112118
/// <param name="cancellationToken">The cancellation token to be associated with the computation.
113-
/// If one is not supplied, the default cancellation token is used.</param>
119+
/// If omitted, <c>Async.DefaultCancellationToken</c> is used.</param>
114120
/// <returns>The result of the computation.</returns>
115121
/// <category index="0">Starting Async Computations</category>
116122
///
@@ -127,7 +133,8 @@ namespace Microsoft.FSharp.Control
127133
///
128134
/// printfn "D"
129135
/// </code>
130-
/// Prints "A", "B" immediately (on the calling thread), then "C" (from a thread-pool thread), then "D" in 1 second (on the calling thread). Yields <c>result = 17</c>.
136+
/// Prints "A", "B" immediately (on the calling thread), then, after one second, "C" (from a thread-pool thread),
137+
/// quickly followed by "D" (on the calling thread). Yields <c>result = 17</c>.
131138
/// </example>
132139
static member RunSynchronouslyImmediate : computation:Async<'T> * ?cancellationToken:CancellationToken -> 'T
133140

tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Control/AsyncModule.fs

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,111 @@ type AsyncModule() =
447447
}
448448
|> Async.RunSynchronously
449449

450+
// ---- RunSynchronouslyImmediate: basic functionality ----
451+
452+
[<Fact>]
453+
member _.``RunSynchronouslyImmediate returns value``() =
454+
let result = async { return 42 } |> Async.RunSynchronouslyImmediate
455+
Assert.Equal(42, result)
456+
457+
[<Fact>]
458+
member _.``RunSynchronouslyImmediate propagates exception``() =
459+
Assert.Throws<InvalidOperationException>(fun () ->
460+
async { return invalidOp "test" }
461+
|> Async.RunSynchronouslyImmediate
462+
|> ignore
463+
) |> ignore
464+
465+
[<Fact>]
466+
member _.``RunSynchronouslyImmediate respects pre-cancelled token``() =
467+
use cts = new CancellationTokenSource()
468+
cts.Cancel()
469+
Assert.Throws<OperationCanceledException>(fun () ->
470+
Async.RunSynchronouslyImmediate(async { return 1 }, cancellationToken = cts.Token)
471+
|> ignore
472+
) |> ignore
473+
474+
[<Fact>]
475+
member _.``RunSynchronouslyImmediate works with Sleep``() =
476+
let result =
477+
async {
478+
do! Async.Sleep 10
479+
return 17
480+
}
481+
|> Async.RunSynchronouslyImmediate
482+
Assert.AreEqual(17, result)
483+
484+
// ---- RunSynchronouslyImmediate: differences from RunSynchronously ----
485+
//
486+
// RunSynchronously will offload to the thread pool when SynchronizationContext.Current is
487+
// non-null or Thread.IsThreadPoolThread is false (e.g. FSI, GUI threads, dedicated test threads).
488+
// In those cases the computation commences on a different thread and exception stack traces are
489+
// incomplete. RunSynchronouslyImmediate always executes the first step on the calling thread,
490+
// giving a complete call stack that is much more useful during interactive testing.
491+
492+
[<Fact>]
493+
// RunSynchronously offloads to the thread pool when SynchronizationContext.Current is non-null
494+
// (see RunSynchronously.ThreadJump.IfSyncCtxtNonNull).
495+
// and/or the caller is not a threadpool thread
496+
// RunSynchronouslyImmediate always starts on the calling thread regardless.
497+
member _.``RunSynchronouslyImmediate.StartsOnCallingThread WhenSyncContextNonNull``() =
498+
let t = Thread(fun () ->
499+
Assert.False(Thread.CurrentThread.IsThreadPoolThread)
500+
let old = SynchronizationContext.Current
501+
SynchronizationContext.SetSynchronizationContext(SynchronizationContext())
502+
Assert.NotNull(SynchronizationContext.Current)
503+
let mutable startThreadId = -1
504+
async { startThreadId <- Thread.CurrentThread.ManagedThreadId }
505+
|> Async.RunSynchronouslyImmediate
506+
SynchronizationContext.SetSynchronizationContext(old)
507+
Assert.Equal(Thread.CurrentThread.ManagedThreadId, startThreadId) )
508+
t.Start()
509+
t.Join()
510+
511+
[<Fact>]
512+
// Demonstrates the key difference in starting-thread identity between the two methods when called
513+
// from a non-thread-pool thread (e.g. FSI, a test runner's main thread, or a dedicated thread):
514+
// RunSynchronously offloads the computation to a thread-pool thread (different thread ID),
515+
// while RunSynchronouslyImmediate keeps it on the calling thread (same thread ID).
516+
// The latter ensures that exception stack traces include frames from the caller's thread,
517+
// making failures much easier to diagnose during interactive testing.
518+
member _.``RunSynchronouslyImmediate.vs.RunSynchronously.CallerThreadIdentity``() =
519+
let mutable runSyncThreadId = -1
520+
let mutable immThreadId = -1
521+
let mutable callerThreadId = -1
522+
let t = Thread(fun () ->
523+
callerThreadId <- Thread.CurrentThread.ManagedThreadId
524+
async { runSyncThreadId <- Thread.CurrentThread.ManagedThreadId }
525+
|> Async.RunSynchronously
526+
async { immThreadId <- Thread.CurrentThread.ManagedThreadId }
527+
|> Async.RunSynchronouslyImmediate)
528+
t.Start()
529+
t.Join()
530+
Assert.NotEqual(callerThreadId, runSyncThreadId)
531+
Assert.AreEqual(callerThreadId, immThreadId)
532+
533+
[<Fact>]
534+
// Because RunSynchronouslyImmediate starts on the calling thread, an exception thrown before
535+
// any do! in the computation is captured on that thread. When re-raised to the caller the
536+
// exception stack trace therefore includes the caller's thread frames, whereas RunSynchronously
537+
// (when it offloads) only carries thread-pool frames in the original portion of the trace.
538+
member _.``RunSynchronouslyImmediate.ExceptionOriginatesOnCallingThread``() =
539+
let mutable callerThreadId = -1
540+
let mutable exceptionOriginThreadId = -1
541+
let t = Thread(fun () ->
542+
callerThreadId <- Thread.CurrentThread.ManagedThreadId
543+
try async {
544+
exceptionOriginThreadId <- Thread.CurrentThread.ManagedThreadId
545+
failwith "boom"
546+
}
547+
|> Async.RunSynchronouslyImmediate
548+
with e ->
549+
printfn $"STACKTRACE ===\n{e.StackTrace}\n==="
550+
())
551+
t.Start()
552+
t.Join()
553+
Assert.AreEqual(callerThreadId, exceptionOriginThreadId)
554+
450555
[<Fact>]
451556
member _.``RaceBetweenCancellationAndError.AwaitWaitHandle``() =
452557
let disposedEvent = new System.Threading.ManualResetEvent(false)

0 commit comments

Comments
 (0)