From 98f52ed035310bc91621d7152a9069260cd241f7 Mon Sep 17 00:00:00 2001 From: Smaug123 <3138005+Smaug123@users.noreply.github.com> Date: Wed, 27 Aug 2025 12:15:02 +0100 Subject: [PATCH 1/6] Scaffold new analyzer --- docs/analyzers/SyncBlockingAnalyzer.md | 20 +++++ src/FSharp.Analyzers/FSharp.Analyzers.fsproj | 3 +- src/FSharp.Analyzers/SyncBlockingAnalyzer.fs | 14 ++++ .../FSharp.Analyzers.Tests.fsproj | 77 ++++++++++--------- .../SyncBlockingAnalyzerTests.fs | 56 ++++++++++++++ .../data/syncBlocking/Sample.fs | 2 + 6 files changed, 133 insertions(+), 39 deletions(-) create mode 100644 docs/analyzers/SyncBlockingAnalyzer.md create mode 100644 src/FSharp.Analyzers/SyncBlockingAnalyzer.fs create mode 100644 tests/FSharp.Analyzers.Tests/SyncBlockingAnalyzerTests.fs create mode 100644 tests/FSharp.Analyzers.Tests/data/syncBlocking/Sample.fs diff --git a/docs/analyzers/SyncBlockingAnalyzer.md b/docs/analyzers/SyncBlockingAnalyzer.md new file mode 100644 index 0000000..241ebc2 --- /dev/null +++ b/docs/analyzers/SyncBlockingAnalyzer.md @@ -0,0 +1,20 @@ +--- +title: SyncBlocking Analyzer +category: analyzers +categoryindex: 1 +index: +--- + +# SyncBlocking Analyzer + +## Problem + +```fsharp + +``` + +## Fix + +```fsharp + +``` diff --git a/src/FSharp.Analyzers/FSharp.Analyzers.fsproj b/src/FSharp.Analyzers/FSharp.Analyzers.fsproj index d648b31..8a51ed5 100644 --- a/src/FSharp.Analyzers/FSharp.Analyzers.fsproj +++ b/src/FSharp.Analyzers/FSharp.Analyzers.fsproj @@ -29,6 +29,7 @@ + @@ -44,4 +45,4 @@ - + \ No newline at end of file diff --git a/src/FSharp.Analyzers/SyncBlockingAnalyzer.fs b/src/FSharp.Analyzers/SyncBlockingAnalyzer.fs new file mode 100644 index 0000000..1845168 --- /dev/null +++ b/src/FSharp.Analyzers/SyncBlockingAnalyzer.fs @@ -0,0 +1,14 @@ +module GR.FSharp.Analyzers.SyncBlockingAnalyzer + +open System +open FSharp.Analyzers.SDK +open FSharp.Analyzers.SDK.TASTCollecting +open FSharp.Compiler.Symbols +open FSharp.Compiler.Syntax +open FSharp.Compiler.Text + +[] +let syncBlockingAnalyzer : Analyzer = + fun (ctx : CliContext) -> async { return List.empty } diff --git a/tests/FSharp.Analyzers.Tests/FSharp.Analyzers.Tests.fsproj b/tests/FSharp.Analyzers.Tests/FSharp.Analyzers.Tests.fsproj index a3ce1a4..4f0bd18 100644 --- a/tests/FSharp.Analyzers.Tests/FSharp.Analyzers.Tests.fsproj +++ b/tests/FSharp.Analyzers.Tests/FSharp.Analyzers.Tests.fsproj @@ -1,41 +1,42 @@  - - net8.0 - false - false - true - G-Research.FSharp.Analyzers.Tests - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + net8.0 + false + false + true + G-Research.FSharp.Analyzers.Tests + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/FSharp.Analyzers.Tests/SyncBlockingAnalyzerTests.fs b/tests/FSharp.Analyzers.Tests/SyncBlockingAnalyzerTests.fs new file mode 100644 index 0000000..e65d9fa --- /dev/null +++ b/tests/FSharp.Analyzers.Tests/SyncBlockingAnalyzerTests.fs @@ -0,0 +1,56 @@ +module GR.FSharp.Analyzers.Tests.SyncBlockingAnalyzerTests + +open System.Collections +open System.IO +open NUnit.Framework +open FSharp.Compiler.CodeAnalysis +open FSharp.Analyzers.SDK.Testing +open GR.FSharp.Analyzers +open GR.FSharp.Analyzers.Tests.Common + +let mutable projectOptions : FSharpProjectOptions = FSharpProjectOptions.zero + +[] +let Setup () = + task { + let! options = mkOptionsFromProject "net7.0" [] + projectOptions <- options + } + +type TestCases() = + + interface IEnumerable with + member _.GetEnumerator () : IEnumerator = + constructTestCaseEnumerator [| "syncBlocking" |] + +[)>] +let SyncBlockingAnalyzerTests (fileName : string) = + task { + let fileName = Path.Combine (dataFolder, fileName) + + let! messages = + File.ReadAllText fileName + |> getContext projectOptions + |> SyncBlockingAnalyzer.syncBlockingAnalyzer + + do! assertExpected fileName messages + } + +type NegativeTestCases() = + + interface IEnumerable with + member _.GetEnumerator () : IEnumerator = + constructTestCaseEnumerator [| "syncBlocking" ; "negative" |] + +[)>] +let NegativeTests (fileName : string) = + task { + let fileName = Path.Combine (dataFolder, fileName) + + let! messages = + File.ReadAllText fileName + |> getContext projectOptions + |> SyncBlockingAnalyzer.syncBlockingAnalyzer + + Assert.That (messages, Is.Empty) + } diff --git a/tests/FSharp.Analyzers.Tests/data/syncBlocking/Sample.fs b/tests/FSharp.Analyzers.Tests/data/syncBlocking/Sample.fs new file mode 100644 index 0000000..a1562db --- /dev/null +++ b/tests/FSharp.Analyzers.Tests/data/syncBlocking/Sample.fs @@ -0,0 +1,2 @@ +module Sample + From 6f870e8f41996ea73d6c989ac4784aba33108c8e Mon Sep 17 00:00:00 2001 From: Smaug123 <3138005+Smaug123@users.noreply.github.com> Date: Wed, 27 Aug 2025 12:34:22 +0100 Subject: [PATCH 2/6] Initial implementation --- src/FSharp.Analyzers/SyncBlockingAnalyzer.fs | 138 +++++++++++++++++- .../SyncBlockingAnalyzerTests.fs | 4 +- 2 files changed, 135 insertions(+), 7 deletions(-) diff --git a/src/FSharp.Analyzers/SyncBlockingAnalyzer.fs b/src/FSharp.Analyzers/SyncBlockingAnalyzer.fs index 1845168..80b661b 100644 --- a/src/FSharp.Analyzers/SyncBlockingAnalyzer.fs +++ b/src/FSharp.Analyzers/SyncBlockingAnalyzer.fs @@ -3,12 +3,140 @@ module GR.FSharp.Analyzers.SyncBlockingAnalyzer open System open FSharp.Analyzers.SDK open FSharp.Analyzers.SDK.TASTCollecting +open FSharp.Compiler.CodeAnalysis open FSharp.Compiler.Symbols open FSharp.Compiler.Syntax +open FSharp.Compiler.SyntaxTrivia open FSharp.Compiler.Text -[] -let syncBlockingAnalyzer : Analyzer = - fun (ctx : CliContext) -> async { return List.empty } +[] +let Code = "GRA-SYNCBLOCK-001" + +[] +let SwitchOffComment = "synchronous blocking call allowed" + +let problematicMethods = + [ + "Microsoft.FSharp.Control.Async.RunSynchronously" + "Microsoft.FSharp.Control.FSharpAsync.RunSynchronously" + "System.Threading.Tasks.Task.Wait" + "System.Threading.Tasks.Task.WaitAll" + "System.Threading.Tasks.Task.WaitAny" + "System.Runtime.CompilerServices.TaskAwaiter.GetResult" + "System.Runtime.CompilerServices.TaskAwaiter`1.GetResult" + "System.Runtime.CompilerServices.ValueTaskAwaiter.GetResult" + "System.Runtime.CompilerServices.ValueTaskAwaiter`1.GetResult" + ] + |> Set.ofList + +let problematicProperties = + [ + "System.Threading.Tasks.Task`1.Result" + "System.Threading.Tasks.ValueTask`1.Result" + ] + |> Set.ofList + +let isSwitchedOffPerComment (sourceText: ISourceText) (comments: CommentTrivia list) (range: Range) = + comments + |> List.exists (fun c -> + match c with + | CommentTrivia.LineComment r -> + // Check same line or line above + if r.StartLine = range.StartLine || r.StartLine = range.StartLine - 1 then + let lineOfComment = sourceText.GetLineString(r.StartLine - 1) // 0-based + lineOfComment.Contains(SwitchOffComment, StringComparison.OrdinalIgnoreCase) + else + false + | CommentTrivia.BlockComment r -> + // Check if block comment is on same line or line above + if r.StartLine = range.StartLine || r.EndLine = range.StartLine - 1 then + let startLine = sourceText.GetLineString(r.StartLine - 1) + startLine.Contains(SwitchOffComment, StringComparison.OrdinalIgnoreCase) + else + false + ) + +let analyze (sourceText: ISourceText) (ast: ParsedInput) (checkFileResults: FSharpCheckFileResults) = + let comments = + match ast with + | ParsedInput.ImplFile parsedImplFileInput -> parsedImplFileInput.Trivia.CodeComments + | _ -> [] + + let violations = ResizeArray() + + let walker = + { new TypedTreeCollectorBase() with + override _.WalkCall _ (mfv: FSharpMemberOrFunctionOrValue) _ _ _ (m: range) = + if problematicMethods.Contains mfv.FullName then + if not (isSwitchedOffPerComment sourceText comments m) then + let methodName = + if mfv.DisplayName.Contains(".") then + mfv.DisplayName + else + // Get more context for better error messages + let parts = mfv.FullName.Split('.') + if parts.Length >= 2 then + $"{parts.[parts.Length - 2]}.{parts.[parts.Length - 1]}" + else + mfv.DisplayName + violations.Add(m, "method", methodName) + + override _.WalkFSharpFieldGet _ _ (field: FSharpField) = + // Handle Result property access + let fullName = + match field.DeclaringEntity with + | Some entity -> $"{entity.FullName}.{field.Name}" + | None -> field.Name + + if problematicProperties.Contains fullName then + let range = field.DeclarationLocation + if not (isSwitchedOffPerComment sourceText comments range) then + violations.Add(range, "property", field.Name) + } + + match checkFileResults.ImplementationFile with + | Some typedTree -> walkTast walker typedTree + | None -> () + + violations + |> Seq.map (fun (range, _kind, name) -> + { + Type = "SyncBlockingAnalyzer" + Message = + $"Synchronous blocking call '%s{name}' should be avoided. " + + "This can cause deadlocks and thread pool starvation. " + + "Consider using `let!` in a `task` or `async` computation expression. " + + "Suppress with comment including text 'synchronous blocking call allowed'." + Code = Code + Severity = Severity.Warning + Range = range + Fixes = [] + } + ) + |> Seq.toList + +[] +let Name = "SyncBlockingAnalyzer" + +[] +let ShortDescription = + "Bans synchronous blocking operations like Task.Result and Async.RunSynchronously" + +[] +let HelpUri = + "https://g-research.github.io/fsharp-analyzers/analyzers/SyncBlockingAnalyzer.html" + +[] +let syncBlockingCliAnalyzer: Analyzer = + fun (ctx: CliContext) -> + async { return analyze ctx.SourceText ctx.ParseFileResults.ParseTree ctx.CheckFileResults } + +[] +let syncBlockingEditorAnalyzer: Analyzer = + fun (ctx: EditorContext) -> + async { + return + ctx.CheckFileResults + |> Option.map (analyze ctx.SourceText ctx.ParseFileResults.ParseTree) + |> Option.defaultValue [] + } \ No newline at end of file diff --git a/tests/FSharp.Analyzers.Tests/SyncBlockingAnalyzerTests.fs b/tests/FSharp.Analyzers.Tests/SyncBlockingAnalyzerTests.fs index e65d9fa..ffc5563 100644 --- a/tests/FSharp.Analyzers.Tests/SyncBlockingAnalyzerTests.fs +++ b/tests/FSharp.Analyzers.Tests/SyncBlockingAnalyzerTests.fs @@ -31,7 +31,7 @@ let SyncBlockingAnalyzerTests (fileName : string) = let! messages = File.ReadAllText fileName |> getContext projectOptions - |> SyncBlockingAnalyzer.syncBlockingAnalyzer + |> SyncBlockingAnalyzer.syncBlockingCliAnalyzer do! assertExpected fileName messages } @@ -50,7 +50,7 @@ let NegativeTests (fileName : string) = let! messages = File.ReadAllText fileName |> getContext projectOptions - |> SyncBlockingAnalyzer.syncBlockingAnalyzer + |> SyncBlockingAnalyzer.syncBlockingCliAnalyzer Assert.That (messages, Is.Empty) } From c3c4e56f1e1c53eb394c20e02671c9f953a0330f Mon Sep 17 00:00:00 2001 From: Smaug123 <3138005+Smaug123@users.noreply.github.com> Date: Wed, 27 Aug 2025 16:56:06 +0100 Subject: [PATCH 3/6] Tests --- src/FSharp.Analyzers/SyncBlockingAnalyzer.fs | 39 ++++++++++--------- .../SyncBlockingAnalyzerTests.fs | 2 +- .../syncBlocking/Async RunSynchronously.fs | 18 +++++++++ .../Async RunSynchronously.fs.expected | 3 ++ .../data/syncBlocking/GetResult methods.fs | 26 +++++++++++++ .../GetResult methods.fs.expected | 4 ++ .../data/syncBlocking/Sample.fs | 2 - .../data/syncBlocking/Task Result property.fs | 21 ++++++++++ .../Task Result property.fs.expected | 4 ++ .../data/syncBlocking/Task Wait methods.fs | 23 +++++++++++ .../Task Wait methods.fs.expected | 5 +++ .../syncBlocking/negative/Async operations.fs | 35 +++++++++++++++++ .../negative/Async operations.fs.expected | 0 .../negative/Non async methods.fs | 31 +++++++++++++++ .../negative/Non async methods.fs.expected | 0 .../negative/With suppression comment.fs | 29 ++++++++++++++ .../With suppression comment.fs.expected | 0 17 files changed, 220 insertions(+), 22 deletions(-) create mode 100644 tests/FSharp.Analyzers.Tests/data/syncBlocking/Async RunSynchronously.fs create mode 100644 tests/FSharp.Analyzers.Tests/data/syncBlocking/Async RunSynchronously.fs.expected create mode 100644 tests/FSharp.Analyzers.Tests/data/syncBlocking/GetResult methods.fs create mode 100644 tests/FSharp.Analyzers.Tests/data/syncBlocking/GetResult methods.fs.expected delete mode 100644 tests/FSharp.Analyzers.Tests/data/syncBlocking/Sample.fs create mode 100644 tests/FSharp.Analyzers.Tests/data/syncBlocking/Task Result property.fs create mode 100644 tests/FSharp.Analyzers.Tests/data/syncBlocking/Task Result property.fs.expected create mode 100644 tests/FSharp.Analyzers.Tests/data/syncBlocking/Task Wait methods.fs create mode 100644 tests/FSharp.Analyzers.Tests/data/syncBlocking/Task Wait methods.fs.expected create mode 100644 tests/FSharp.Analyzers.Tests/data/syncBlocking/negative/Async operations.fs create mode 100644 tests/FSharp.Analyzers.Tests/data/syncBlocking/negative/Async operations.fs.expected create mode 100644 tests/FSharp.Analyzers.Tests/data/syncBlocking/negative/Non async methods.fs create mode 100644 tests/FSharp.Analyzers.Tests/data/syncBlocking/negative/Non async methods.fs.expected create mode 100644 tests/FSharp.Analyzers.Tests/data/syncBlocking/negative/With suppression comment.fs create mode 100644 tests/FSharp.Analyzers.Tests/data/syncBlocking/negative/With suppression comment.fs.expected diff --git a/src/FSharp.Analyzers/SyncBlockingAnalyzer.fs b/src/FSharp.Analyzers/SyncBlockingAnalyzer.fs index 80b661b..42995a9 100644 --- a/src/FSharp.Analyzers/SyncBlockingAnalyzer.fs +++ b/src/FSharp.Analyzers/SyncBlockingAnalyzer.fs @@ -17,6 +17,7 @@ let SwitchOffComment = "synchronous blocking call allowed" let problematicMethods = [ + "Microsoft.FSharp.Control.RunSynchronously" // This is how it appears in the TAST "Microsoft.FSharp.Control.Async.RunSynchronously" "Microsoft.FSharp.Control.FSharpAsync.RunSynchronously" "System.Threading.Tasks.Task.Wait" @@ -31,8 +32,8 @@ let problematicMethods = let problematicProperties = [ - "System.Threading.Tasks.Task`1.Result" - "System.Threading.Tasks.ValueTask`1.Result" + "System.Threading.Tasks.Task.get_Result" // Note: F# doesn't include `1 in FullName for generic type property getters + "System.Threading.Tasks.ValueTask.get_Result" // Same here ] |> Set.ofList @@ -67,31 +68,31 @@ let analyze (sourceText: ISourceText) (ast: ParsedInput) (checkFileResults: FSha let walker = { new TypedTreeCollectorBase() with override _.WalkCall _ (mfv: FSharpMemberOrFunctionOrValue) _ _ _ (m: range) = + + // Check for regular method calls if problematicMethods.Contains mfv.FullName then if not (isSwitchedOffPerComment sourceText comments m) then let methodName = if mfv.DisplayName.Contains(".") then mfv.DisplayName else - // Get more context for better error messages - let parts = mfv.FullName.Split('.') - if parts.Length >= 2 then - $"{parts.[parts.Length - 2]}.{parts.[parts.Length - 1]}" + // Special handling for Async.RunSynchronously + if mfv.FullName = "Microsoft.FSharp.Control.RunSynchronously" then + "Async.RunSynchronously" else - mfv.DisplayName + // Get more context for better error messages + let parts = mfv.FullName.Split('.') + if parts.Length >= 2 then + $"{parts.[parts.Length - 2]}.{parts.[parts.Length - 1]}" + else + mfv.DisplayName violations.Add(m, "method", methodName) - - override _.WalkFSharpFieldGet _ _ (field: FSharpField) = - // Handle Result property access - let fullName = - match field.DeclaringEntity with - | Some entity -> $"{entity.FullName}.{field.Name}" - | None -> field.Name - - if problematicProperties.Contains fullName then - let range = field.DeclarationLocation - if not (isSwitchedOffPerComment sourceText comments range) then - violations.Add(range, "property", field.Name) + + // Check for property getters (Result property access) + elif problematicProperties.Contains mfv.FullName then + if not (isSwitchedOffPerComment sourceText comments m) then + // For property getters, use just the property name + violations.Add(m, "property", "Result") } match checkFileResults.ImplementationFile with diff --git a/tests/FSharp.Analyzers.Tests/SyncBlockingAnalyzerTests.fs b/tests/FSharp.Analyzers.Tests/SyncBlockingAnalyzerTests.fs index ffc5563..2c4b90e 100644 --- a/tests/FSharp.Analyzers.Tests/SyncBlockingAnalyzerTests.fs +++ b/tests/FSharp.Analyzers.Tests/SyncBlockingAnalyzerTests.fs @@ -13,7 +13,7 @@ let mutable projectOptions : FSharpProjectOptions = FSharpProjectOptions.zero [] let Setup () = task { - let! options = mkOptionsFromProject "net7.0" [] + let! options = mkOptionsFromProject framework [] projectOptions <- options } diff --git a/tests/FSharp.Analyzers.Tests/data/syncBlocking/Async RunSynchronously.fs b/tests/FSharp.Analyzers.Tests/data/syncBlocking/Async RunSynchronously.fs new file mode 100644 index 0000000..707f57c --- /dev/null +++ b/tests/FSharp.Analyzers.Tests/data/syncBlocking/Async RunSynchronously.fs @@ -0,0 +1,18 @@ +module AsyncRunSynchronously + +open System.Threading.Tasks + +let testAsyncRunSynchronously () = + let computation = async { return 42 } + let result = Async.RunSynchronously computation + result + +let testAsyncRunSynchronouslyWithTimeout () = + let computation = async { return "hello" } + let result = Async.RunSynchronously(computation, 1000) + result + +let testAsyncRunSynchronouslyQualified () = + let computation = async { return true } + let result = Microsoft.FSharp.Control.Async.RunSynchronously computation + result \ No newline at end of file diff --git a/tests/FSharp.Analyzers.Tests/data/syncBlocking/Async RunSynchronously.fs.expected b/tests/FSharp.Analyzers.Tests/data/syncBlocking/Async RunSynchronously.fs.expected new file mode 100644 index 0000000..6821589 --- /dev/null +++ b/tests/FSharp.Analyzers.Tests/data/syncBlocking/Async RunSynchronously.fs.expected @@ -0,0 +1,3 @@ +GRA-SYNCBLOCK-001 | Warning | (7,17 - 7,51) | Synchronous blocking call 'Async.RunSynchronously' should be avoided. This can cause deadlocks and thread pool starvation. Consider using `let!` in a `task` or `async` computation expression. Suppress with comment including text 'synchronous blocking call allowed'. | [] +GRA-SYNCBLOCK-001 | Warning | (12,17 - 12,58) | Synchronous blocking call 'Async.RunSynchronously' should be avoided. This can cause deadlocks and thread pool starvation. Consider using `let!` in a `task` or `async` computation expression. Suppress with comment including text 'synchronous blocking call allowed'. | [] +GRA-SYNCBLOCK-001 | Warning | (17,17 - 17,76) | Synchronous blocking call 'Async.RunSynchronously' should be avoided. This can cause deadlocks and thread pool starvation. Consider using `let!` in a `task` or `async` computation expression. Suppress with comment including text 'synchronous blocking call allowed'. | [] diff --git a/tests/FSharp.Analyzers.Tests/data/syncBlocking/GetResult methods.fs b/tests/FSharp.Analyzers.Tests/data/syncBlocking/GetResult methods.fs new file mode 100644 index 0000000..1280466 --- /dev/null +++ b/tests/FSharp.Analyzers.Tests/data/syncBlocking/GetResult methods.fs @@ -0,0 +1,26 @@ +module GetResultMethods + +open System.Threading.Tasks +open System.Runtime.CompilerServices + +let testTaskAwaiterGetResult () = + let t = Task.Run(fun () -> 42) + let awaiter = t.GetAwaiter() + let result = awaiter.GetResult() + result + +let testTaskAwaiterGetResultGeneric () = + let t = Task.Run(fun () -> "hello") + let awaiter = t.GetAwaiter() + awaiter.GetResult() + +let testValueTaskAwaiterGetResult () = + let vt = ValueTask.FromResult 42 + let awaiter = vt.GetAwaiter() + awaiter.GetResult() + +let testValueTaskAwaiterGetResultGeneric () = + let vt = ValueTask("hello") + let awaiter = vt.GetAwaiter() + let result = awaiter.GetResult() + result \ No newline at end of file diff --git a/tests/FSharp.Analyzers.Tests/data/syncBlocking/GetResult methods.fs.expected b/tests/FSharp.Analyzers.Tests/data/syncBlocking/GetResult methods.fs.expected new file mode 100644 index 0000000..ce32daa --- /dev/null +++ b/tests/FSharp.Analyzers.Tests/data/syncBlocking/GetResult methods.fs.expected @@ -0,0 +1,4 @@ +GRA-SYNCBLOCK-001 | Warning | (9,17 - 9,36) | Synchronous blocking call 'TaskAwaiter.GetResult' should be avoided. This can cause deadlocks and thread pool starvation. Consider using `let!` in a `task` or `async` computation expression. Suppress with comment including text 'synchronous blocking call allowed'. | [] +GRA-SYNCBLOCK-001 | Warning | (15,4 - 15,23) | Synchronous blocking call 'TaskAwaiter.GetResult' should be avoided. This can cause deadlocks and thread pool starvation. Consider using `let!` in a `task` or `async` computation expression. Suppress with comment including text 'synchronous blocking call allowed'. | [] +GRA-SYNCBLOCK-001 | Warning | (20,4 - 20,23) | Synchronous blocking call 'ValueTaskAwaiter.GetResult' should be avoided. This can cause deadlocks and thread pool starvation. Consider using `let!` in a `task` or `async` computation expression. Suppress with comment including text 'synchronous blocking call allowed'. | [] +GRA-SYNCBLOCK-001 | Warning | (25,17 - 25,36) | Synchronous blocking call 'ValueTaskAwaiter.GetResult' should be avoided. This can cause deadlocks and thread pool starvation. Consider using `let!` in a `task` or `async` computation expression. Suppress with comment including text 'synchronous blocking call allowed'. | [] diff --git a/tests/FSharp.Analyzers.Tests/data/syncBlocking/Sample.fs b/tests/FSharp.Analyzers.Tests/data/syncBlocking/Sample.fs deleted file mode 100644 index a1562db..0000000 --- a/tests/FSharp.Analyzers.Tests/data/syncBlocking/Sample.fs +++ /dev/null @@ -1,2 +0,0 @@ -module Sample - diff --git a/tests/FSharp.Analyzers.Tests/data/syncBlocking/Task Result property.fs b/tests/FSharp.Analyzers.Tests/data/syncBlocking/Task Result property.fs new file mode 100644 index 0000000..e6b7253 --- /dev/null +++ b/tests/FSharp.Analyzers.Tests/data/syncBlocking/Task Result property.fs @@ -0,0 +1,21 @@ +module TaskResultProperty + +open System.Threading.Tasks + +let testTaskResult () = + let t = Task.Run(fun () -> 42) + let result = t.Result + result + +let testTaskOfTResult () = + let t : Task = Task.Run(fun () -> "hello") + t.Result + +let testChainedResult () = + let result = Task.Run(fun () -> 42).Result + result + +let testValueTaskResult () = + let vt = ValueTask(42) + let result = vt.Result + result \ No newline at end of file diff --git a/tests/FSharp.Analyzers.Tests/data/syncBlocking/Task Result property.fs.expected b/tests/FSharp.Analyzers.Tests/data/syncBlocking/Task Result property.fs.expected new file mode 100644 index 0000000..02bfcfd --- /dev/null +++ b/tests/FSharp.Analyzers.Tests/data/syncBlocking/Task Result property.fs.expected @@ -0,0 +1,4 @@ +GRA-SYNCBLOCK-001 | Warning | (7,17 - 7,25) | Synchronous blocking call 'Result' should be avoided. This can cause deadlocks and thread pool starvation. Consider using `let!` in a `task` or `async` computation expression. Suppress with comment including text 'synchronous blocking call allowed'. | [] +GRA-SYNCBLOCK-001 | Warning | (12,4 - 12,12) | Synchronous blocking call 'Result' should be avoided. This can cause deadlocks and thread pool starvation. Consider using `let!` in a `task` or `async` computation expression. Suppress with comment including text 'synchronous blocking call allowed'. | [] +GRA-SYNCBLOCK-001 | Warning | (15,17 - 15,46) | Synchronous blocking call 'Result' should be avoided. This can cause deadlocks and thread pool starvation. Consider using `let!` in a `task` or `async` computation expression. Suppress with comment including text 'synchronous blocking call allowed'. | [] +GRA-SYNCBLOCK-001 | Warning | (20,17 - 20,26) | Synchronous blocking call 'Result' should be avoided. This can cause deadlocks and thread pool starvation. Consider using `let!` in a `task` or `async` computation expression. Suppress with comment including text 'synchronous blocking call allowed'. | [] diff --git a/tests/FSharp.Analyzers.Tests/data/syncBlocking/Task Wait methods.fs b/tests/FSharp.Analyzers.Tests/data/syncBlocking/Task Wait methods.fs new file mode 100644 index 0000000..2133ad4 --- /dev/null +++ b/tests/FSharp.Analyzers.Tests/data/syncBlocking/Task Wait methods.fs @@ -0,0 +1,23 @@ +module TaskWaitMethods + +open System.Threading.Tasks + +let testTaskWait () = + let t = Task.Run(fun () -> 42) + t.Wait() + t.Result + +let testTaskWaitWithTimeout () = + let t = Task.Delay(100) + t.Wait(1000) + +let testTaskWaitAll () = + let t1 = Task.Run(fun () -> 1) + let t2 = Task.Run(fun () -> 2) + Task.WaitAll(t1, t2) + +let testTaskWaitAny () = + let t1 = Task.Delay(100) + let t2 = Task.Delay(200) + let index = Task.WaitAny(t1, t2) + index \ No newline at end of file diff --git a/tests/FSharp.Analyzers.Tests/data/syncBlocking/Task Wait methods.fs.expected b/tests/FSharp.Analyzers.Tests/data/syncBlocking/Task Wait methods.fs.expected new file mode 100644 index 0000000..e2092a2 --- /dev/null +++ b/tests/FSharp.Analyzers.Tests/data/syncBlocking/Task Wait methods.fs.expected @@ -0,0 +1,5 @@ +GRA-SYNCBLOCK-001 | Warning | (7,4 - 7,12) | Synchronous blocking call 'Task.Wait' should be avoided. This can cause deadlocks and thread pool starvation. Consider using `let!` in a `task` or `async` computation expression. Suppress with comment including text 'synchronous blocking call allowed'. | [] +GRA-SYNCBLOCK-001 | Warning | (8,4 - 8,12) | Synchronous blocking call 'Result' should be avoided. This can cause deadlocks and thread pool starvation. Consider using `let!` in a `task` or `async` computation expression. Suppress with comment including text 'synchronous blocking call allowed'. | [] +GRA-SYNCBLOCK-001 | Warning | (12,4 - 12,16) | Synchronous blocking call 'Task.Wait' should be avoided. This can cause deadlocks and thread pool starvation. Consider using `let!` in a `task` or `async` computation expression. Suppress with comment including text 'synchronous blocking call allowed'. | [] +GRA-SYNCBLOCK-001 | Warning | (17,4 - 17,24) | Synchronous blocking call 'Task.WaitAll' should be avoided. This can cause deadlocks and thread pool starvation. Consider using `let!` in a `task` or `async` computation expression. Suppress with comment including text 'synchronous blocking call allowed'. | [] +GRA-SYNCBLOCK-001 | Warning | (22,16 - 22,36) | Synchronous blocking call 'Task.WaitAny' should be avoided. This can cause deadlocks and thread pool starvation. Consider using `let!` in a `task` or `async` computation expression. Suppress with comment including text 'synchronous blocking call allowed'. | [] diff --git a/tests/FSharp.Analyzers.Tests/data/syncBlocking/negative/Async operations.fs b/tests/FSharp.Analyzers.Tests/data/syncBlocking/negative/Async operations.fs new file mode 100644 index 0000000..bf518aa --- /dev/null +++ b/tests/FSharp.Analyzers.Tests/data/syncBlocking/negative/Async operations.fs @@ -0,0 +1,35 @@ +module AsyncOperations + +open System.Threading.Tasks + +let testAsyncComputation () = async { + let! result = async { return 42 } + return result +} + +let testTaskComputation () = task { + let! result = Task.Run(fun () -> 42) + return result +} + +let testAsyncStartWithContinuation () = + let computation = async { return 42 } + Async.StartWithContinuations( + computation, + (fun result -> printfn $"Result: %d{result}"), + (fun exn -> printfn $"Error: %O{exn}"), + (fun cancel -> printfn "Cancelled") + ) + +let testAsyncStartAsTask () = + let computation = async { return 42 } + let task = Async.StartAsTask computation + task + +let testTaskRun () = + let t = Task.Run(fun () -> 42) + t + +let testValueTask () = + let vt = ValueTask(42) + vt \ No newline at end of file diff --git a/tests/FSharp.Analyzers.Tests/data/syncBlocking/negative/Async operations.fs.expected b/tests/FSharp.Analyzers.Tests/data/syncBlocking/negative/Async operations.fs.expected new file mode 100644 index 0000000..e69de29 diff --git a/tests/FSharp.Analyzers.Tests/data/syncBlocking/negative/Non async methods.fs b/tests/FSharp.Analyzers.Tests/data/syncBlocking/negative/Non async methods.fs new file mode 100644 index 0000000..c9cd6de --- /dev/null +++ b/tests/FSharp.Analyzers.Tests/data/syncBlocking/negative/Non async methods.fs @@ -0,0 +1,31 @@ +module NonAsyncMethods + +open System +open System.Collections.Generic + +let testRegularMethods () = + let list = List() + list.Add(42) + let count = list.Count + let str = "hello" + let result = str.ToUpper() + let parsed = Int32.Parse("123") + parsed + +type MyType() = + member _.Result = 42 + member _.GetResult() = "hello" + member _.Wait() = () + +let testCustomTypeMethods () = + let obj = MyType() + let r1 = obj.Result + let r2 = obj.GetResult() + obj.Wait() + r1 + r2.Length + +let testUnrelatedAsync () = + let asyncValue = Some 42 + match asyncValue with + | Some v -> v + | None -> 0 \ No newline at end of file diff --git a/tests/FSharp.Analyzers.Tests/data/syncBlocking/negative/Non async methods.fs.expected b/tests/FSharp.Analyzers.Tests/data/syncBlocking/negative/Non async methods.fs.expected new file mode 100644 index 0000000..e69de29 diff --git a/tests/FSharp.Analyzers.Tests/data/syncBlocking/negative/With suppression comment.fs b/tests/FSharp.Analyzers.Tests/data/syncBlocking/negative/With suppression comment.fs new file mode 100644 index 0000000..c7efb40 --- /dev/null +++ b/tests/FSharp.Analyzers.Tests/data/syncBlocking/negative/With suppression comment.fs @@ -0,0 +1,29 @@ +module WithSuppressionComment + +open System.Threading.Tasks + +let testAsyncRunSynchronouslyWithComment () = + let computation = async { return 42 } + // synchronous blocking call allowed + let result = Async.RunSynchronously computation + result + +let testTaskWaitWithComment () = + let t = Task.Run(fun () -> 42) + t.Wait() // synchronous blocking call allowed for testing + t.Result + +let testTaskResultWithCommentAbove () = + let t = Task.Run(fun () -> "hello") + // Synchronous Blocking Call Allowed (case insensitive) + t.Result + +let testWithBlockComment () = + let t = Task.Run(fun () -> 42) + (* synchronous blocking call allowed *) + t.Wait() + +let testGetResultWithComment () = + let t = Task.Run(fun () -> 42) + let awaiter = t.GetAwaiter() + awaiter.GetResult() // synchronous blocking call allowed in main \ No newline at end of file diff --git a/tests/FSharp.Analyzers.Tests/data/syncBlocking/negative/With suppression comment.fs.expected b/tests/FSharp.Analyzers.Tests/data/syncBlocking/negative/With suppression comment.fs.expected new file mode 100644 index 0000000..e69de29 From a311901099112e179dd213ca0b90f5a77ecf1ae0 Mon Sep 17 00:00:00 2001 From: Smaug123 <3138005+Smaug123@users.noreply.github.com> Date: Wed, 27 Aug 2025 16:57:32 +0100 Subject: [PATCH 4/6] Changelog --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 736b246..afafde7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.19.0 - 2025-08-27 + +### Added +* New analyzer, SyncBlockingAnalyzer, which bans the use of synchronous blocking methods like `Task.Wait`, unless specifically permitted per-line with a magic comment. + ## 0.18.0 - 2025-08-27 ### Changed From 5fd36adcf4e285300f329471b035faa7f5559bd1 Mon Sep 17 00:00:00 2001 From: Smaug123 <3138005+Smaug123@users.noreply.github.com> Date: Wed, 27 Aug 2025 17:04:15 +0100 Subject: [PATCH 5/6] Markups --- CHANGELOG.md | 2 +- src/FSharp.Analyzers/Comments.fs | 2 +- src/FSharp.Analyzers/SyncBlockingAnalyzer.fs | 86 ++++++++----------- .../negative/With suppression comment.fs | 8 +- 4 files changed, 43 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index afafde7..2b91dab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ## 0.19.0 - 2025-08-27 ### Added -* New analyzer, SyncBlockingAnalyzer, which bans the use of synchronous blocking methods like `Task.Wait`, unless specifically permitted per-line with a magic comment. +* New analyzer, SyncBlockingAnalyzer, which bans the use of synchronous blocking methods like `Task.Wait`, unless specifically permitted per-line with a magic comment. [#96](https://github.com/G-Research/fsharp-analyzers/pull/96) ## 0.18.0 - 2025-08-27 diff --git a/src/FSharp.Analyzers/Comments.fs b/src/FSharp.Analyzers/Comments.fs index 4e76f9e..80f45e2 100644 --- a/src/FSharp.Analyzers/Comments.fs +++ b/src/FSharp.Analyzers/Comments.fs @@ -17,6 +17,7 @@ let isSwitchedOffPerComment comments |> List.exists (fun c -> match c with + | CommentTrivia.BlockComment r | CommentTrivia.LineComment r -> if r.StartLine <> analyzerTriggeredOn.StartLine - 1 then false @@ -24,5 +25,4 @@ let isSwitchedOffPerComment let lineOfComment = sourceText.GetLineString (r.StartLine - 1) // 0-based lineOfComment.Contains (magicComment, StringComparison.OrdinalIgnoreCase) - | _ -> false ) diff --git a/src/FSharp.Analyzers/SyncBlockingAnalyzer.fs b/src/FSharp.Analyzers/SyncBlockingAnalyzer.fs index 42995a9..907fcde 100644 --- a/src/FSharp.Analyzers/SyncBlockingAnalyzer.fs +++ b/src/FSharp.Analyzers/SyncBlockingAnalyzer.fs @@ -1,6 +1,7 @@ module GR.FSharp.Analyzers.SyncBlockingAnalyzer open System +open FSharp.Analyzers.Comments open FSharp.Analyzers.SDK open FSharp.Analyzers.SDK.TASTCollecting open FSharp.Compiler.CodeAnalysis @@ -17,7 +18,7 @@ let SwitchOffComment = "synchronous blocking call allowed" let problematicMethods = [ - "Microsoft.FSharp.Control.RunSynchronously" // This is how it appears in the TAST + "Microsoft.FSharp.Control.RunSynchronously" // This is how it appears in the TAST "Microsoft.FSharp.Control.Async.RunSynchronously" "Microsoft.FSharp.Control.FSharpAsync.RunSynchronously" "System.Threading.Tasks.Task.Wait" @@ -32,67 +33,50 @@ let problematicMethods = let problematicProperties = [ - "System.Threading.Tasks.Task.get_Result" // Note: F# doesn't include `1 in FullName for generic type property getters - "System.Threading.Tasks.ValueTask.get_Result" // Same here + "System.Threading.Tasks.Task.get_Result" // Note: F# doesn't include `1 in FullName for generic type property getters + "System.Threading.Tasks.ValueTask.get_Result" // Same here ] |> Set.ofList -let isSwitchedOffPerComment (sourceText: ISourceText) (comments: CommentTrivia list) (range: Range) = - comments - |> List.exists (fun c -> - match c with - | CommentTrivia.LineComment r -> - // Check same line or line above - if r.StartLine = range.StartLine || r.StartLine = range.StartLine - 1 then - let lineOfComment = sourceText.GetLineString(r.StartLine - 1) // 0-based - lineOfComment.Contains(SwitchOffComment, StringComparison.OrdinalIgnoreCase) - else - false - | CommentTrivia.BlockComment r -> - // Check if block comment is on same line or line above - if r.StartLine = range.StartLine || r.EndLine = range.StartLine - 1 then - let startLine = sourceText.GetLineString(r.StartLine - 1) - startLine.Contains(SwitchOffComment, StringComparison.OrdinalIgnoreCase) - else - false - ) - -let analyze (sourceText: ISourceText) (ast: ParsedInput) (checkFileResults: FSharpCheckFileResults) = +let analyze (sourceText : ISourceText) (ast : ParsedInput) (checkFileResults : FSharpCheckFileResults) = let comments = match ast with | ParsedInput.ImplFile parsedImplFileInput -> parsedImplFileInput.Trivia.CodeComments | _ -> [] - let violations = ResizeArray() + let violations = ResizeArray () let walker = { new TypedTreeCollectorBase() with - override _.WalkCall _ (mfv: FSharpMemberOrFunctionOrValue) _ _ _ (m: range) = - + override _.WalkCall _ (mfv : FSharpMemberOrFunctionOrValue) _ _ _ (m : range) = + // Check for regular method calls if problematicMethods.Contains mfv.FullName then - if not (isSwitchedOffPerComment sourceText comments m) then + if not (isSwitchedOffPerComment SwitchOffComment comments sourceText m) then let methodName = - if mfv.DisplayName.Contains(".") then + if mfv.DisplayName.Contains '.' then mfv.DisplayName - else + else if // Special handling for Async.RunSynchronously - if mfv.FullName = "Microsoft.FSharp.Control.RunSynchronously" then - "Async.RunSynchronously" + mfv.FullName = "Microsoft.FSharp.Control.RunSynchronously" + then + "Async.RunSynchronously" + else + // Get more context for better error messages + let parts = mfv.FullName.Split '.' + + if parts.Length >= 2 then + $"{parts.[parts.Length - 2]}.{parts.[parts.Length - 1]}" else - // Get more context for better error messages - let parts = mfv.FullName.Split('.') - if parts.Length >= 2 then - $"{parts.[parts.Length - 2]}.{parts.[parts.Length - 1]}" - else - mfv.DisplayName - violations.Add(m, "method", methodName) - + mfv.DisplayName + + violations.Add (m, "method", methodName) + // Check for property getters (Result property access) elif problematicProperties.Contains mfv.FullName then - if not (isSwitchedOffPerComment sourceText comments m) then + if not (isSwitchedOffPerComment SwitchOffComment comments sourceText m) then // For property getters, use just the property name - violations.Add(m, "property", "Result") + violations.Add (m, "property", "Result") } match checkFileResults.ImplementationFile with @@ -104,10 +88,10 @@ let analyze (sourceText: ISourceText) (ast: ParsedInput) (checkFileResults: FSha { Type = "SyncBlockingAnalyzer" Message = - $"Synchronous blocking call '%s{name}' should be avoided. " + - "This can cause deadlocks and thread pool starvation. " + - "Consider using `let!` in a `task` or `async` computation expression. " + - "Suppress with comment including text 'synchronous blocking call allowed'." + $"Synchronous blocking call '%s{name}' should be avoided. " + + "This can cause deadlocks and thread pool starvation. " + + "Consider using `let!` in a `task` or `async` computation expression. " + + "Suppress with comment including text 'synchronous blocking call allowed'." Code = Code Severity = Severity.Warning Range = range @@ -128,16 +112,16 @@ let HelpUri = "https://g-research.github.io/fsharp-analyzers/analyzers/SyncBlockingAnalyzer.html" [] -let syncBlockingCliAnalyzer: Analyzer = - fun (ctx: CliContext) -> +let syncBlockingCliAnalyzer : Analyzer = + fun (ctx : CliContext) -> async { return analyze ctx.SourceText ctx.ParseFileResults.ParseTree ctx.CheckFileResults } [] -let syncBlockingEditorAnalyzer: Analyzer = - fun (ctx: EditorContext) -> +let syncBlockingEditorAnalyzer : Analyzer = + fun (ctx : EditorContext) -> async { return ctx.CheckFileResults |> Option.map (analyze ctx.SourceText ctx.ParseFileResults.ParseTree) |> Option.defaultValue [] - } \ No newline at end of file + } diff --git a/tests/FSharp.Analyzers.Tests/data/syncBlocking/negative/With suppression comment.fs b/tests/FSharp.Analyzers.Tests/data/syncBlocking/negative/With suppression comment.fs index c7efb40..53e92e4 100644 --- a/tests/FSharp.Analyzers.Tests/data/syncBlocking/negative/With suppression comment.fs +++ b/tests/FSharp.Analyzers.Tests/data/syncBlocking/negative/With suppression comment.fs @@ -10,7 +10,10 @@ let testAsyncRunSynchronouslyWithComment () = let testTaskWaitWithComment () = let t = Task.Run(fun () -> 42) - t.Wait() // synchronous blocking call allowed for testing + t + // ANALYZER: synchronous blocking call allowed for testing + |> fun x -> x.Wait() + // ANALYZER: synchronous blocking call allowed for testing t.Result let testTaskResultWithCommentAbove () = @@ -26,4 +29,5 @@ let testWithBlockComment () = let testGetResultWithComment () = let t = Task.Run(fun () -> 42) let awaiter = t.GetAwaiter() - awaiter.GetResult() // synchronous blocking call allowed in main \ No newline at end of file + // synchronous blocking call allowed in main + awaiter.GetResult() \ No newline at end of file From c41afa57cfda221549766e49d3dc1dd27cd3f551 Mon Sep 17 00:00:00 2001 From: Smaug123 <3138005+Smaug123@users.noreply.github.com> Date: Wed, 27 Aug 2025 17:30:02 +0100 Subject: [PATCH 6/6] Docs --- docs/analyzers/SyncBlockingAnalyzer.md | 61 +++++++++++++++++++- src/FSharp.Analyzers/SyncBlockingAnalyzer.fs | 8 +-- 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/docs/analyzers/SyncBlockingAnalyzer.md b/docs/analyzers/SyncBlockingAnalyzer.md index 241ebc2..b8f8fb7 100644 --- a/docs/analyzers/SyncBlockingAnalyzer.md +++ b/docs/analyzers/SyncBlockingAnalyzer.md @@ -9,12 +9,71 @@ index: ## Problem -```fsharp +Calls to blocking methods and properties like `Task.Wait` and `Task.Result`, or `Async.RunSynchronously`, consume a thread in the thread pool for as long as they are running. +The .NET runtime tries to cope with this by spinning up new operating system threads if it detects thread pool starvation, but [there is a maximum number of threads](https://learn.microsoft.com/en-us/dotnet/standard/threading/the-managed-thread-pool#maximum-number-of-thread-pool-threads), and attempting to exceed this number will cause the application to deadlock once all threads are blocked waiting for a synchronous call to complete. +```fsharp +let doTheThing (input : Task) : int = + // This line blocks + let input = input.Result + input + 1 ``` ## Fix +The correct fix depends on context. + +## In a library function + +Usually the correct answer is to propagate the asynchronous nature of the workflow up into the function signature: + +```fsharp +let doTheThing (input : Task) : Task = + task { + // Correct: await the asynchronous workflow + let! input = input + return input + 1 + } +``` + +## In the main method +F# [does not support async main methods](https://github.com/dotnet/fsharp/issues/11631#issuecomment-855052325), so a blocking call will always be necessary in the entry point. + +```fsharp +[] +let main argv = + doTheThing () + // ANALYZER: synchronous blocking call allowed in entry point + |> Async.RunSynchronously +``` + +## In tests + +Many standard test runners support asynchronous tests out of the box. + +Avoid synchronous blocking in tests. +Test runners may give you a much more restrictive thread pool than you are used to; this can result in heavy slowdowns or deadlocks in tests even when you wouldn't expect to see them in prod. + ```fsharp +open NUnit.Framework +// NUnit supports Task and Async out of the box, for example +[] +let myAsyncTest () = task { + let! result = computeMyResult () + result |> shouldEqual 8 +} +``` + +## If you already have a proof that the access is safe + +You might have some proof that a `Task` has already completed at the time you want to access it. +In that case, just suppress the analyzer at the point of access. + +```fsharp +if myTask.IsCompletedSuccessfully then + // SAFETY: synchronous blocking call allowed because task has completed + myTask.Result +else + ... ``` diff --git a/src/FSharp.Analyzers/SyncBlockingAnalyzer.fs b/src/FSharp.Analyzers/SyncBlockingAnalyzer.fs index 907fcde..2e02a90 100644 --- a/src/FSharp.Analyzers/SyncBlockingAnalyzer.fs +++ b/src/FSharp.Analyzers/SyncBlockingAnalyzer.fs @@ -19,8 +19,6 @@ let SwitchOffComment = "synchronous blocking call allowed" let problematicMethods = [ "Microsoft.FSharp.Control.RunSynchronously" // This is how it appears in the TAST - "Microsoft.FSharp.Control.Async.RunSynchronously" - "Microsoft.FSharp.Control.FSharpAsync.RunSynchronously" "System.Threading.Tasks.Task.Wait" "System.Threading.Tasks.Task.WaitAll" "System.Threading.Tasks.Task.WaitAny" @@ -56,10 +54,8 @@ let analyze (sourceText : ISourceText) (ast : ParsedInput) (checkFileResults : F let methodName = if mfv.DisplayName.Contains '.' then mfv.DisplayName - else if - // Special handling for Async.RunSynchronously - mfv.FullName = "Microsoft.FSharp.Control.RunSynchronously" - then + elif mfv.FullName = "Microsoft.FSharp.Control.RunSynchronously" then + // Special handling for Async.RunSynchronously, which has a different name in the TAST "Async.RunSynchronously" else // Get more context for better error messages