From 9cdb1bf20b5423c9c25a1518e3694d5c74a7340f Mon Sep 17 00:00:00 2001 From: Garrett Beatty Date: Thu, 25 Jun 2026 12:07:07 -0400 Subject: [PATCH] Add Roslyn analyzers (DE001-DE004) for Amazon.Lambda.DurableExecution Ports the JavaScript SDK's ESLint plugin to Roslyn and adds a .NET-specific rule, bundled into the Amazon.Lambda.DurableExecution package so they activate automatically for consumers (analyzers/dotnet/cs). - DE001 (Warning): non-deterministic API (DateTime.Now, Guid.NewGuid, Random, Stopwatch, Environment.TickCount, crypto RNG) used outside a step + code fix. - DE002 (Warning): durable operation invoked inside a step-wrapped delegate via the captured outer IDurableContext. - DE003 (Warning): mutation of a captured outer-scope variable inside a durable-operation delegate. - DE004 (Info): Task.WhenAll/WhenAny over durable tasks; prefer ParallelAsync/MapAsync + code fix. Detection is semantic (matches IDurableContext by symbol), not name-based like the JS plugin. A shared per-compilation symbol cache bails out when the SDK is not referenced, so the analyzers are zero-cost on non-durable projects. 34 unit tests via Microsoft.CodeAnalysis.*.Testing; verified end-to-end against a packed consumer. --- .autover/autover.json | 5 +- .../changes/durable-execution-analyzers.json | 11 + Libraries/Libraries.sln | 30 ++ ...n.Lambda.DurableExecution.Analyzers.csproj | 48 +++ .../AnalyzerReleases.Shipped.md | 2 + .../AnalyzerReleases.Unshipped.md | 11 + .../DurableDiagnostics.cs | 82 +++++ .../DurableKnownSymbols.cs | 344 ++++++++++++++++++ .../DurableScope.cs | 201 ++++++++++ .../DurableTaskCombinatorAnalyzer.cs | 200 ++++++++++ .../DurableTaskCombinatorCodeFixProvider.cs | 233 ++++++++++++ .../MutableCaptureAnalyzer.cs | 157 ++++++++ .../NestedDurableOperationAnalyzer.cs | 124 +++++++ .../NonDeterministicCallAnalyzer.cs | 77 ++++ .../NonDeterministicCallCodeFixProvider.cs | 231 ++++++++++++ .../Amazon.Lambda.DurableExecution.csproj | 20 + .../docs/analyzers.md | 130 +++++++ ...da.DurableExecution.Analyzers.Tests.csproj | 31 ++ .../DurableStubs.cs | 67 ++++ .../DurableTaskCombinatorAnalyzerTests.cs | 108 ++++++ .../DurableTaskCombinatorCodeFixTests.cs | 49 +++ .../MutableCaptureAnalyzerTests.cs | 184 ++++++++++ .../NestedDurableOperationAnalyzerTests.cs | 153 ++++++++ .../NonDeterministicCallAnalyzerTests.cs | 211 +++++++++++ .../NonDeterministicCallCodeFixTests.cs | 48 +++ .../Verifiers.cs | 74 ++++ 26 files changed, 2830 insertions(+), 1 deletion(-) create mode 100644 .autover/changes/durable-execution-analyzers.json create mode 100644 Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/Amazon.Lambda.DurableExecution.Analyzers.csproj create mode 100644 Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/AnalyzerReleases.Shipped.md create mode 100644 Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/AnalyzerReleases.Unshipped.md create mode 100644 Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableDiagnostics.cs create mode 100644 Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableKnownSymbols.cs create mode 100644 Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableScope.cs create mode 100644 Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableTaskCombinatorAnalyzer.cs create mode 100644 Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableTaskCombinatorCodeFixProvider.cs create mode 100644 Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/MutableCaptureAnalyzer.cs create mode 100644 Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/NestedDurableOperationAnalyzer.cs create mode 100644 Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/NonDeterministicCallAnalyzer.cs create mode 100644 Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/NonDeterministicCallCodeFixProvider.cs create mode 100644 Libraries/src/Amazon.Lambda.DurableExecution/docs/analyzers.md create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/Amazon.Lambda.DurableExecution.Analyzers.Tests.csproj create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/DurableStubs.cs create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/DurableTaskCombinatorAnalyzerTests.cs create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/DurableTaskCombinatorCodeFixTests.cs create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/MutableCaptureAnalyzerTests.cs create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/NestedDurableOperationAnalyzerTests.cs create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/NonDeterministicCallAnalyzerTests.cs create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/NonDeterministicCallCodeFixTests.cs create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/Verifiers.cs diff --git a/.autover/autover.json b/.autover/autover.json index 02f2ad0db..88c3c1795 100644 --- a/.autover/autover.json +++ b/.autover/autover.json @@ -49,7 +49,10 @@ }, { "Name": "Amazon.Lambda.DurableExecution", - "Path": "Libraries/src/Amazon.Lambda.DurableExecution/Amazon.Lambda.DurableExecution.csproj", + "Paths": [ + "Libraries/src/Amazon.Lambda.DurableExecution/Amazon.Lambda.DurableExecution.csproj", + "Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/Amazon.Lambda.DurableExecution.Analyzers.csproj" + ], "PrereleaseLabel": "preview" }, { diff --git a/.autover/changes/durable-execution-analyzers.json b/.autover/changes/durable-execution-analyzers.json new file mode 100644 index 000000000..4689a8839 --- /dev/null +++ b/.autover/changes/durable-execution-analyzers.json @@ -0,0 +1,11 @@ +{ + "Projects": [ + { + "Name": "Amazon.Lambda.DurableExecution", + "Type": "Minor", + "ChangelogMessages": [ + "Add Roslyn analyzers (DE001-DE004) that catch common durable-execution authoring mistakes at build time, bundled in the package so they activate automatically for consumers. DE001 (Warning) flags non-deterministic APIs (DateTime.Now, Guid.NewGuid(), Random, Stopwatch, Environment.TickCount, crypto RNG) used in workflow code outside a step. DE002 (Warning) flags a durable operation invoked inside a step body via the captured outer IDurableContext. DE003 (Warning) flags mutation of a captured outer-scope variable inside a durable-operation delegate. DE004 (Info) suggests ParallelAsync/MapAsync over Task.WhenAll/Task.WhenAny for durable tasks. DE001 and DE004 include code fixes. Preview." + ] + } + ] +} diff --git a/Libraries/Libraries.sln b/Libraries/Libraries.sln index 1b2bedcd5..9f66a0031 100644 --- a/Libraries/Libraries.sln +++ b/Libraries/Libraries.sln @@ -165,6 +165,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Amazon.Lambda.DurableExecut EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AnnotationsClassLibraryFunction", "test\Amazon.Lambda.DurableExecution.IntegrationTests\TestFunctions\AnnotationsClassLibraryFunction\AnnotationsClassLibraryFunction.csproj", "{D55E2D57-8374-4573-999B-6E64E109C25F}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Amazon.Lambda.DurableExecution.Analyzers", "src\Amazon.Lambda.DurableExecution.Analyzers\Amazon.Lambda.DurableExecution.Analyzers.csproj", "{6702F1BE-3A11-4DFB-93C3-50065789D814}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Amazon.Lambda.DurableExecution.Analyzers.Tests", "test\Amazon.Lambda.DurableExecution.Analyzers.Tests\Amazon.Lambda.DurableExecution.Analyzers.Tests.csproj", "{592F5A22-B03A-4E0B-9DCD-093FBF29ACFC}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -1039,6 +1043,30 @@ Global {D55E2D57-8374-4573-999B-6E64E109C25F}.Release|x64.Build.0 = Release|Any CPU {D55E2D57-8374-4573-999B-6E64E109C25F}.Release|x86.ActiveCfg = Release|Any CPU {D55E2D57-8374-4573-999B-6E64E109C25F}.Release|x86.Build.0 = Release|Any CPU + {6702F1BE-3A11-4DFB-93C3-50065789D814}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6702F1BE-3A11-4DFB-93C3-50065789D814}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6702F1BE-3A11-4DFB-93C3-50065789D814}.Debug|x64.ActiveCfg = Debug|Any CPU + {6702F1BE-3A11-4DFB-93C3-50065789D814}.Debug|x64.Build.0 = Debug|Any CPU + {6702F1BE-3A11-4DFB-93C3-50065789D814}.Debug|x86.ActiveCfg = Debug|Any CPU + {6702F1BE-3A11-4DFB-93C3-50065789D814}.Debug|x86.Build.0 = Debug|Any CPU + {6702F1BE-3A11-4DFB-93C3-50065789D814}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6702F1BE-3A11-4DFB-93C3-50065789D814}.Release|Any CPU.Build.0 = Release|Any CPU + {6702F1BE-3A11-4DFB-93C3-50065789D814}.Release|x64.ActiveCfg = Release|Any CPU + {6702F1BE-3A11-4DFB-93C3-50065789D814}.Release|x64.Build.0 = Release|Any CPU + {6702F1BE-3A11-4DFB-93C3-50065789D814}.Release|x86.ActiveCfg = Release|Any CPU + {6702F1BE-3A11-4DFB-93C3-50065789D814}.Release|x86.Build.0 = Release|Any CPU + {592F5A22-B03A-4E0B-9DCD-093FBF29ACFC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {592F5A22-B03A-4E0B-9DCD-093FBF29ACFC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {592F5A22-B03A-4E0B-9DCD-093FBF29ACFC}.Debug|x64.ActiveCfg = Debug|Any CPU + {592F5A22-B03A-4E0B-9DCD-093FBF29ACFC}.Debug|x64.Build.0 = Debug|Any CPU + {592F5A22-B03A-4E0B-9DCD-093FBF29ACFC}.Debug|x86.ActiveCfg = Debug|Any CPU + {592F5A22-B03A-4E0B-9DCD-093FBF29ACFC}.Debug|x86.Build.0 = Debug|Any CPU + {592F5A22-B03A-4E0B-9DCD-093FBF29ACFC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {592F5A22-B03A-4E0B-9DCD-093FBF29ACFC}.Release|Any CPU.Build.0 = Release|Any CPU + {592F5A22-B03A-4E0B-9DCD-093FBF29ACFC}.Release|x64.ActiveCfg = Release|Any CPU + {592F5A22-B03A-4E0B-9DCD-093FBF29ACFC}.Release|x64.Build.0 = Release|Any CPU + {592F5A22-B03A-4E0B-9DCD-093FBF29ACFC}.Release|x86.ActiveCfg = Release|Any CPU + {592F5A22-B03A-4E0B-9DCD-093FBF29ACFC}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1120,6 +1148,8 @@ Global {CA132CAB-FF4F-4312-B3A3-66DE9D360F27} = {1DE4EE60-45BA-4EF7-BE00-B9EB861E4C69} {16B1B1CC-3AFC-4DC7-8DB6-D14AE12924A2} = {1DE4EE60-45BA-4EF7-BE00-B9EB861E4C69} {D55E2D57-8374-4573-999B-6E64E109C25F} = {1DE4EE60-45BA-4EF7-BE00-B9EB861E4C69} + {6702F1BE-3A11-4DFB-93C3-50065789D814} = {AAB54E74-20B1-42ED-BC3D-CE9F7BC7FD12} + {592F5A22-B03A-4E0B-9DCD-093FBF29ACFC} = {1DE4EE60-45BA-4EF7-BE00-B9EB861E4C69} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {503678A4-B8D1-4486-8915-405A3E9CF0EB} diff --git a/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/Amazon.Lambda.DurableExecution.Analyzers.csproj b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/Amazon.Lambda.DurableExecution.Analyzers.csproj new file mode 100644 index 000000000..f64018a6e --- /dev/null +++ b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/Amazon.Lambda.DurableExecution.Analyzers.csproj @@ -0,0 +1,48 @@ + + + + + netstandard2.0 + latest + enable + disable + + Roslyn analyzers and code fixes for Amazon.Lambda.DurableExecution — catches durable-execution determinism and authoring mistakes (DE001-DE004) at build time. + Amazon.Lambda.DurableExecution.Analyzers + Amazon.Lambda.DurableExecution.Analyzers + AWS;Amazon;Lambda;Durable;Workflow;Analyzer;Roslyn + + + false + false + true + + + ..\..\..\buildtools\public.snk + true + + true + true + + + $(NoWarn);RS1032;RS1033 + + + + + + + + + + + + + + + diff --git a/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/AnalyzerReleases.Shipped.md b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/AnalyzerReleases.Shipped.md new file mode 100644 index 000000000..f50bb1fe2 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/AnalyzerReleases.Shipped.md @@ -0,0 +1,2 @@ +; Shipped analyzer releases +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md diff --git a/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/AnalyzerReleases.Unshipped.md b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/AnalyzerReleases.Unshipped.md new file mode 100644 index 000000000..ceae365d0 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/AnalyzerReleases.Unshipped.md @@ -0,0 +1,11 @@ +; Unshipped analyzer release +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|---------------------------------------------------------------------- +DE001 | AWSLambdaDurableExecution | Warning | Non-deterministic call outside a step. https://github.com/aws/aws-lambda-dotnet/blob/master/Libraries/src/Amazon.Lambda.DurableExecution/docs/analyzers.md#de001 +DE002 | AWSLambdaDurableExecution | Warning | Nested durable operation inside a step body. https://github.com/aws/aws-lambda-dotnet/blob/master/Libraries/src/Amazon.Lambda.DurableExecution/docs/analyzers.md#de002 +DE003 | AWSLambdaDurableExecution | Warning | Mutable variable captured and modified inside a durable operation. https://github.com/aws/aws-lambda-dotnet/blob/master/Libraries/src/Amazon.Lambda.DurableExecution/docs/analyzers.md#de003 +DE004 | AWSLambdaDurableExecution | Info | Task.WhenAll/WhenAny over durable tasks; prefer ParallelAsync/MapAsync. https://github.com/aws/aws-lambda-dotnet/blob/master/Libraries/src/Amazon.Lambda.DurableExecution/docs/analyzers.md#de004 diff --git a/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableDiagnostics.cs b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableDiagnostics.cs new file mode 100644 index 000000000..448fea326 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableDiagnostics.cs @@ -0,0 +1,82 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using Microsoft.CodeAnalysis; + +namespace Amazon.Lambda.DurableExecution.Analyzers +{ + /// + /// Diagnostic descriptors for the durable-execution analyzers. These mirror the + /// JavaScript SDK's ESLint plugin rules (no-non-deterministic-outside-step, + /// no-nested-durable-operations, no-closure-in-durable-operations) and add a + /// .NET-specific rule (DE004) for the Task.WhenAll/WhenAny pattern. + /// + public static class DurableDiagnostics + { + internal const string Category = "AWSLambdaDurableExecution"; + + private const string HelpRoot = + "https://github.com/aws/aws-lambda-dotnet/blob/master/Libraries/src/Amazon.Lambda.DurableExecution/docs/analyzers.md"; + + /// + /// DE001 — a non-deterministic API (DateTime.Now, Guid.NewGuid, Random, …) is used in + /// workflow code outside a step. On replay the workflow re-runs from the top, so the value + /// differs between the original run and replays, corrupting checkpoint-derived state. + /// + public static readonly DiagnosticDescriptor NonDeterministicCallOutsideStep = new DiagnosticDescriptor( + id: "DE001", + title: "Non-deterministic call outside a step", + messageFormat: "Non-deterministic operation '{0}' is used in workflow code outside a step. Move it inside a step (e.g. context.StepAsync(...)) so its result is checkpointed and replays consistently", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "Durable workflow code re-executes from the top on every invocation. Values from non-deterministic APIs differ between the original execution and replays unless they are captured inside a step.", + helpLinkUri: HelpRoot + "#de001"); + + /// + /// DE002 — a durable operation is invoked inside a step body by capturing the outer + /// IDurableContext. Step bodies must contain only plain, deterministic code; + /// nesting durable operations requires RunInChildContextAsync. + /// + public static readonly DiagnosticDescriptor NestedDurableOperationInsideStep = new DiagnosticDescriptor( + id: "DE002", + title: "Nested durable operation inside a step body", + messageFormat: "Durable operation '{0}' is called on the outer durable context '{1}' inside a {2} body. Step bodies must contain only plain, deterministic code; nest durable operations with context.RunInChildContextAsync(...) instead", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "A step body is replayed verbatim on every retry. Calling another durable operation from inside it produces unpredictable behavior; use RunInChildContextAsync to group durable operations.", + helpLinkUri: HelpRoot + "#de002"); + + /// + /// DE003 — a variable captured from an outer scope is mutated inside a durable-operation + /// delegate. On replay the operation returns its cached result without re-executing the body, + /// so the write never happens and the captured variable holds stale state. + /// + public static readonly DiagnosticDescriptor MutableCaptureInDurableOperation = new DiagnosticDescriptor( + id: "DE003", + title: "Mutable variable captured and modified inside a durable operation", + messageFormat: "Variable '{0}' is captured from an outer scope and modified inside a durable operation. On replay the operation returns its cached result without re-executing the body, so this write is lost and '{0}' becomes stale. Return the value from the operation and assign it (e.g. {0} = await context.StepAsync(...)) instead", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "Reading a captured variable inside a durable operation is safe; mutating it is not, because the body is skipped on replay.", + helpLinkUri: HelpRoot + "#de003"); + + /// + /// DE004 — Task.WhenAll/Task.WhenAny is called with tasks produced by durable + /// operations. This is not incorrect (operation IDs are allocated deterministically), but it + /// bypasses completion policies, concurrency limits, branch naming, and IBatchResult output. + /// Advisory (Info) only. + /// + public static readonly DiagnosticDescriptor DurableTaskInTaskCombinator = new DiagnosticDescriptor( + id: "DE004", + title: "Prefer ParallelAsync/MapAsync over Task.WhenAll/WhenAny for durable tasks", + messageFormat: "'{0}' over durable tasks bypasses completion policies, concurrency limits, branch naming, and IBatchResult output. Use context.ParallelAsync (or MapAsync) so concurrent durable operations get framework coordination and complete execution traces", + category: Category, + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: true, + description: "Task.WhenAll/WhenAny work correctly with durable tasks, but ParallelAsync/MapAsync are preferred for completion policies, concurrency control, and observability.", + helpLinkUri: HelpRoot + "#de004"); + } +} diff --git a/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableKnownSymbols.cs b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableKnownSymbols.cs new file mode 100644 index 000000000..40b05ec51 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableKnownSymbols.cs @@ -0,0 +1,344 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Collections.Generic; +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Operations; + +namespace Amazon.Lambda.DurableExecution.Analyzers +{ + /// + /// How a delegate passed to a durable operation executes, which determines what the + /// analyzers allow inside its body. + /// + internal enum DurableDelegateRole + { + /// Not a delegate accepted by a durable operation. + None, + + /// + /// The delegate body runs inside a checkpointed step (StepAsync func, + /// WaitForCallbackAsync submitter, WaitForConditionAsync check). Non-deterministic code is + /// allowed here because the result is checkpointed; nested durable operations are not. + /// + StepWrapped, + + /// + /// The delegate body runs as a child sub-workflow (RunInChildContextAsync func, ParallelAsync + /// branch, MapAsync func). It is still workflow code (must be deterministic), but nested + /// durable operations are allowed. + /// + ChildContext, + } + + /// + /// Per-compilation cache of the durable-execution and BCL symbols the analyzers match against. + /// Resolved once in a compilation-start action; if IDurableContext is not present + /// the compilation does not use durable execution and the analyzers register nothing. + /// + internal sealed class DurableKnownSymbols + { + internal const string IDurableContextMetadataName = "Amazon.Lambda.DurableExecution.IDurableContext"; + + /// The names of the durable operations declared on IDurableContext. + private static readonly ImmutableHashSet DurableOperationNames = ImmutableHashSet.Create( + "StepAsync", + "WaitAsync", + "RunInChildContextAsync", + "CreateCallbackAsync", + "WaitForCallbackAsync", + "InvokeAsync", + "WaitForConditionAsync", + "ParallelAsync", + "MapAsync"); + + internal INamedTypeSymbol DurableContext { get; } + + // BCL types backing the DE001 non-determinism catalog. Any may be null on a given target framework. + private readonly INamedTypeSymbol? _dateTime; + private readonly INamedTypeSymbol? _dateTimeOffset; + private readonly INamedTypeSymbol? _guid; + private readonly INamedTypeSymbol? _random; + private readonly INamedTypeSymbol? _stopwatch; + private readonly INamedTypeSymbol? _environment; + private readonly INamedTypeSymbol? _path; + private readonly INamedTypeSymbol? _randomNumberGenerator; + private readonly INamedTypeSymbol? _rngCryptoServiceProvider; + private readonly INamedTypeSymbol? _task; + private readonly INamedTypeSymbol? _taskOfT; + + private DurableKnownSymbols(Compilation compilation, INamedTypeSymbol durableContext) + { + DurableContext = durableContext; + _dateTime = compilation.GetTypeByMetadataName("System.DateTime"); + _dateTimeOffset = compilation.GetTypeByMetadataName("System.DateTimeOffset"); + _guid = compilation.GetTypeByMetadataName("System.Guid"); + _random = compilation.GetTypeByMetadataName("System.Random"); + _stopwatch = compilation.GetTypeByMetadataName("System.Diagnostics.Stopwatch"); + _environment = compilation.GetTypeByMetadataName("System.Environment"); + _path = compilation.GetTypeByMetadataName("System.IO.Path"); + _randomNumberGenerator = compilation.GetTypeByMetadataName("System.Security.Cryptography.RandomNumberGenerator"); + _rngCryptoServiceProvider = compilation.GetTypeByMetadataName("System.Security.Cryptography.RNGCryptoServiceProvider"); + _task = compilation.GetTypeByMetadataName("System.Threading.Tasks.Task"); + _taskOfT = compilation.GetTypeByMetadataName("System.Threading.Tasks.Task`1"); + } + + /// + /// Resolves the durable symbols. Returns null when the compilation does not reference + /// Amazon.Lambda.DurableExecution, so callers register no per-node work. + /// + internal static DurableKnownSymbols? TryCreate(Compilation compilation) + { + var durableContext = compilation.GetTypeByMetadataName(IDurableContextMetadataName); + return durableContext is null ? null : new DurableKnownSymbols(compilation, durableContext); + } + + /// + /// True if is IDurableContext or implements it (covers the + /// concrete DurableContext and any user implementation). + /// + internal bool IsDurableContextType(ITypeSymbol? type) + { + if (type is null) + { + return false; + } + + if (SymbolEqualityComparer.Default.Equals(type.OriginalDefinition, DurableContext)) + { + return true; + } + + foreach (var iface in type.AllInterfaces) + { + if (SymbolEqualityComparer.Default.Equals(iface.OriginalDefinition, DurableContext)) + { + return true; + } + } + + return false; + } + + /// + /// True if is one of the durable operations on a durable context. + /// Matched by symbol (containing type is/implements IDurableContext) plus an explicit + /// name allowlist, so property getters and ConfigureLogger are excluded, and an + /// unrelated obj.StepAsync() on some other type is never matched. + /// + internal bool IsDurableOperation(IMethodSymbol? method, out string operationName, out DurableDelegateRole role) + { + operationName = string.Empty; + role = DurableDelegateRole.None; + + if (method is null) + { + return false; + } + + var name = method.OriginalDefinition.Name; + if (!DurableOperationNames.Contains(name)) + { + return false; + } + + if (!IsDurableContextType(method.OriginalDefinition.ContainingType)) + { + return false; + } + + operationName = name; + role = RoleFor(name); + return true; + } + + private static DurableDelegateRole RoleFor(string operationName) + { + switch (operationName) + { + case "StepAsync": + case "WaitForCallbackAsync": + case "WaitForConditionAsync": + return DurableDelegateRole.StepWrapped; + case "RunInChildContextAsync": + case "ParallelAsync": + case "MapAsync": + return DurableDelegateRole.ChildContext; + default: + return DurableDelegateRole.None; + } + } + + /// True if is Task.WhenAll or Task.WhenAny. + internal bool IsTaskCombinator(IMethodSymbol? method, out string friendlyName) + { + friendlyName = string.Empty; + if (method is null || _task is null) + { + return false; + } + + var def = method.OriginalDefinition; + if (!SymbolEqualityComparer.Default.Equals(def.ContainingType, _task)) + { + return false; + } + + if (def.Name == "WhenAll") + { + friendlyName = "Task.WhenAll"; + return true; + } + + if (def.Name == "WhenAny") + { + friendlyName = "Task.WhenAny"; + return true; + } + + return false; + } + + /// True if the type is Task or Task<T> (a candidate durable task). + internal bool IsTaskType(ITypeSymbol? type) + { + if (type is null) + { + return false; + } + + var def = type.OriginalDefinition; + return SymbolEqualityComparer.Default.Equals(def, _task) + || SymbolEqualityComparer.Default.Equals(def, _taskOfT); + } + + /// + /// Returns the friendly name (e.g. "DateTime.Now") if the operation reads/calls/creates + /// a non-deterministic API from the DE001 catalog; otherwise null. + /// + internal string? TryGetNonDeterministicApi(IOperation operation) + { + switch (operation) + { + case IPropertyReferenceOperation pr: + return MatchProperty(pr.Property); + case IInvocationOperation inv: + return MatchMethod(inv.TargetMethod); + case IObjectCreationOperation oc: + return MatchObjectCreation(oc); + default: + return null; + } + } + + private string? MatchProperty(IPropertySymbol property) + { + var owner = property.ContainingType?.OriginalDefinition; + var name = property.Name; + + if (SymbolEqualityComparer.Default.Equals(owner, _dateTime) + && (name == "Now" || name == "UtcNow" || name == "Today")) + { + return "DateTime." + name; + } + + if (SymbolEqualityComparer.Default.Equals(owner, _dateTimeOffset) + && (name == "Now" || name == "UtcNow")) + { + return "DateTimeOffset." + name; + } + + if (SymbolEqualityComparer.Default.Equals(owner, _environment) + && (name == "TickCount" || name == "TickCount64")) + { + return "Environment." + name; + } + + if (SymbolEqualityComparer.Default.Equals(owner, _stopwatch) + && (name == "Elapsed" || name == "ElapsedMilliseconds" || name == "ElapsedTicks")) + { + return "Stopwatch." + name; + } + + // Random.Shared is seeded non-deterministically. (A user-seeded `new Random(42)` is + // deterministic, so we flag the seedless ctor / Random.Shared, not the .Next() call.) + if (SymbolEqualityComparer.Default.Equals(owner, _random) && name == "Shared") + { + return "Random.Shared"; + } + + return null; + } + + private string? MatchMethod(IMethodSymbol method) + { + var owner = method.ContainingType?.OriginalDefinition; + var name = method.Name; + + if (SymbolEqualityComparer.Default.Equals(owner, _guid) && name == "NewGuid") + { + return "Guid.NewGuid()"; + } + + // Note: Random instance methods (.Next() etc.) are intentionally NOT flagged here — a + // user-seeded `new Random(42)` is deterministic. Non-determinism is introduced by the + // seedless `new Random()` ctor (MatchObjectCreation) and `Random.Shared` (MatchProperty). + if (SymbolEqualityComparer.Default.Equals(owner, _stopwatch) + && (name == "GetTimestamp" || name == "StartNew")) + { + return "Stopwatch." + name + "()"; + } + + if (SymbolEqualityComparer.Default.Equals(owner, _randomNumberGenerator) + && (name == "GetBytes" || name == "GetInt32" || name == "Fill" + || name == "GetString" || name == "GetHexString")) + { + return "RandomNumberGenerator." + name + "()"; + } + + if (SymbolEqualityComparer.Default.Equals(owner, _path) + && (name == "GetTempFileName" || name == "GetRandomFileName")) + { + return "Path." + name + "()"; + } + + return null; + } + + private string? MatchObjectCreation(IObjectCreationOperation oc) + { + var type = oc.Constructor?.ContainingType?.OriginalDefinition; + + // Only the seedless Random ctor is non-deterministic; new Random(seed) is fine. + if (SymbolEqualityComparer.Default.Equals(type, _random) && oc.Arguments.Length == 0) + { + return "new Random()"; + } + + if (SymbolEqualityComparer.Default.Equals(type, _rngCryptoServiceProvider)) + { + return "new RNGCryptoServiceProvider()"; + } + + return null; + } + + /// + /// True if contains an IDurableContext-typed parameter, + /// which marks a method/local-function/lambda as durable workflow code. + /// + internal bool HasDurableContextParameter(IEnumerable parameters) + { + foreach (var p in parameters) + { + if (IsDurableContextType(p.Type)) + { + return true; + } + } + + return false; + } + } +} diff --git a/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableScope.cs b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableScope.cs new file mode 100644 index 000000000..5ec1846df --- /dev/null +++ b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableScope.cs @@ -0,0 +1,201 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Collections.Generic; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Operations; + +namespace Amazon.Lambda.DurableExecution.Analyzers +{ + /// + /// Shared helpers for reasoning about where an operation sits relative to durable-operation + /// delegates. Used by DE001 (is this non-deterministic call inside a step?) and DE002 (is this + /// durable call inside a step body?). + /// + internal static class DurableScope + { + /// + /// Describes the nearest enclosing durable-operation delegate of an operation, if any. + /// + internal readonly struct EnclosingDelegate + { + internal EnclosingDelegate(DurableDelegateRole role, string operationName, IAnonymousFunctionOperation function) + { + Role = role; + OperationName = operationName; + Function = function; + } + + internal DurableDelegateRole Role { get; } + + internal string OperationName { get; } + + internal IAnonymousFunctionOperation Function { get; } + + internal bool Found => Role != DurableDelegateRole.None; + } + + /// + /// Walks up the operation tree from and returns the nearest + /// enclosing lambda that is an argument to a durable operation, classified by its role. + /// Non-durable lambdas (e.g. a Select projection or Task.Run) are skipped so a + /// non-deterministic call nested in such a lambda is still attributed to the durable step that + /// contains it. + /// + internal static EnclosingDelegate FindNearestDurableDelegate(IOperation operation, DurableKnownSymbols symbols) + { + for (var current = operation.Parent; current is not null; current = current.Parent) + { + if (current is IAnonymousFunctionOperation lambda + && TryClassifyDelegate(lambda, symbols, out var role, out var opName)) + { + return new EnclosingDelegate(role, opName, lambda); + } + } + + return default; + } + + /// + /// True if any enclosing scope (method, local function, or lambda) of + /// is durable workflow code — i.e. declares an + /// IDurableContext parameter. This is the single shared scoping primitive: it covers + /// the [DurableExecution] annotation path, the hand-wired + /// DurableFunction.WrapAsync(async (input, ctx) => …) lambda, and child/parallel/map + /// branch delegates. + /// + internal static bool IsInWorkflowCode(IOperation operation, DurableKnownSymbols symbols) + { + for (var current = operation.Parent; current is not null; current = current.Parent) + { + switch (current) + { + case IAnonymousFunctionOperation lambda + when symbols.HasDurableContextParameter(lambda.Symbol.Parameters): + return true; + case ILocalFunctionOperation local + when symbols.HasDurableContextParameter(local.Symbol.Parameters): + return true; + case IMethodBodyOperation: + case IBlockOperation { Parent: null }: + break; + } + } + + // The enclosing method symbol (top-level body, not reachable as an IOperation parent). + var enclosing = operation.SemanticModel?.GetEnclosingSymbol(operation.Syntax.SpanStart) as IMethodSymbol; + while (enclosing is not null) + { + if (symbols.HasDurableContextParameter(enclosing.Parameters)) + { + return true; + } + + enclosing = enclosing.ContainingSymbol as IMethodSymbol; + } + + return false; + } + + /// + /// If is passed as an argument to a durable operation, reports which + /// operation and the delegate's role. Maps the lambda's argument position to the corresponding + /// parameter so the WaitForCallbackAsync submitter / WaitForConditionAsync check (which are + /// step-wrapped) and the ParallelAsync/MapAsync branches (child context) are classified + /// correctly — even though they are not the operation's "first" delegate. + /// + internal static bool TryClassifyDelegate( + IAnonymousFunctionOperation lambda, + DurableKnownSymbols symbols, + out DurableDelegateRole role, + out string operationName) + { + role = DurableDelegateRole.None; + operationName = string.Empty; + + // The lambda may be a direct argument (StepAsync(lambda)), wrapped in a delegate + // conversion, or nested inside the array/collection that builds the branches list of + // ParallelAsync ([ (c, ct) => …, (c, ct) => … ]). Walk up through those expression + // wrappers to the enclosing argument, but stop at any statement, block, or another + // anonymous function so we never attribute the lambda to a non-enclosing invocation. + // This avoids naming ICollectionExpressionOperation, which is not in all Roslyn versions. + IOperation? argument = lambda.Parent; + while (argument is not null + && argument is not IArgumentOperation + && argument is not IAnonymousFunctionOperation + && argument is not IBlockOperation + && argument is not IExpressionStatementOperation) + { + argument = argument.Parent; + } + + if (argument is not IArgumentOperation arg || arg.Parent is not IInvocationOperation invocation) + { + return false; + } + + if (!symbols.IsDurableOperation(invocation.TargetMethod, out operationName, out role)) + { + role = DurableDelegateRole.None; + operationName = string.Empty; + return false; + } + + return role != DurableDelegateRole.None; + } + + /// + /// Collects the symbols (parameters and locals) declared within , + /// including nested lambdas/local functions, so DE003 can tell a captured outer variable from a + /// delegate-local one. + /// + internal static HashSet CollectDeclaredSymbols(IAnonymousFunctionOperation function) + { + // The comparer IS supplied; this suppresses a known RS1024 false positive in the 4.0.1 + // analyzer pack that flags `new HashSet(SymbolEqualityComparer.Default)`. +#pragma warning disable RS1024 // Compare symbols correctly + var declared = new HashSet(SymbolEqualityComparer.Default); +#pragma warning restore RS1024 + + foreach (var p in function.Symbol.Parameters) + { + declared.Add(p); + } + + CollectDescendantDeclarations(function.Body, declared); + return declared; + } + + private static void CollectDescendantDeclarations(IOperation? operation, HashSet declared) + { + if (operation is null) + { + return; + } + + foreach (var child in operation.Descendants()) + { + switch (child) + { + case IVariableDeclaratorOperation decl: + declared.Add(decl.Symbol); + break; + case IAnonymousFunctionOperation nested: + foreach (var p in nested.Symbol.Parameters) + { + declared.Add(p); + } + + break; + case ILocalFunctionOperation localFn: + foreach (var p in localFn.Symbol.Parameters) + { + declared.Add(p); + } + + break; + } + } + } + } +} diff --git a/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableTaskCombinatorAnalyzer.cs b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableTaskCombinatorAnalyzer.cs new file mode 100644 index 000000000..1b232c4b9 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableTaskCombinatorAnalyzer.cs @@ -0,0 +1,200 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace Amazon.Lambda.DurableExecution.Analyzers +{ + /// + /// DE004 — flags Task.WhenAll/Task.WhenAny called with tasks produced by durable + /// operations. This is advisory (Info): the combinators work correctly with durable tasks because + /// operation IDs are allocated deterministically, but they bypass completion policies, concurrency + /// limits, branch naming, and structured IBatchResult output, so ParallelAsync / + /// MapAsync are preferred. + /// + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public sealed class DurableTaskCombinatorAnalyzer : DiagnosticAnalyzer + { + /// + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create(DurableDiagnostics.DurableTaskInTaskCombinator); + + /// + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + + context.RegisterCompilationStartAction(compilationStart => + { + var symbols = DurableKnownSymbols.TryCreate(compilationStart.Compilation); + if (symbols is null) + { + return; + } + + compilationStart.RegisterOperationAction( + ctx => AnalyzeInvocation(ctx, symbols), + OperationKind.Invocation); + }); + } + + private static void AnalyzeInvocation(OperationAnalysisContext context, DurableKnownSymbols symbols) + { + var invocation = (IInvocationOperation)context.Operation; + + if (!symbols.IsTaskCombinator(invocation.TargetMethod, out var friendlyName)) + { + return; + } + + // Gather the task expressions passed in and report if any is produced by a durable op. + foreach (var argument in invocation.Arguments) + { + if (ArgumentContainsDurableTask(argument.Value, symbols)) + { + context.ReportDiagnostic(Diagnostic.Create( + DurableDiagnostics.DurableTaskInTaskCombinator, + invocation.Syntax.GetLocation(), + friendlyName)); + return; + } + } + } + + /// + /// True if the argument value contains (directly, or via the local it references) at least one + /// task produced by a durable-context operation. + /// + private static bool ArgumentContainsDurableTask(IOperation value, DurableKnownSymbols symbols) + { + // Strip params-array / collection conversions. + value = Unwrap(value); + + // Inline: Task.WhenAll(ctx.StepAsync(a), ctx.StepAsync(b)) or new[] { ... } or [ ... ]. + foreach (var op in value.DescendantsAndSelf()) + { + if (op is IInvocationOperation inv && IsDurableTaskProducer(inv, symbols)) + { + return true; + } + } + + // Indirect: Task[] tasks = ...; await Task.WhenAll(tasks). Follow a local to its + // initializer and to assignments/Add calls in the enclosing method body. + if (value is ILocalReferenceOperation localRef) + { + return LocalIsPopulatedWithDurableTasks(localRef, symbols); + } + + return false; + } + + private static IOperation Unwrap(IOperation op) + { + while (op is IConversionOperation conv) + { + op = conv.Operand; + } + + return op; + } + + private static bool IsDurableTaskProducer(IInvocationOperation invocation, DurableKnownSymbols symbols) + { + if (!symbols.IsDurableOperation(invocation.TargetMethod, out _, out _)) + { + return false; + } + + return invocation.Instance is not null && symbols.IsDurableContextType(invocation.Instance.Type); + } + + /// + /// Bounded scan: walks the enclosing method/lambda body for the declaration of the referenced + /// local and any assignments or List.Add calls that store a durable task into it. + /// + private static bool LocalIsPopulatedWithDurableTasks(ILocalReferenceOperation localRef, DurableKnownSymbols symbols) + { + var local = localRef.Local; + + // Find the enclosing body operation that contains the WhenAll call. + IOperation root = localRef; + while (root.Parent is not null) + { + root = root.Parent; + } + + foreach (var op in root.DescendantsAndSelf()) + { + switch (op) + { + case IVariableDeclaratorOperation decl + when SymbolEqualityComparer.Default.Equals(decl.Symbol, local) + && decl.Initializer is not null: + if (ContainsDurableTaskProducer(decl.Initializer.Value, symbols)) + { + return true; + } + + break; + + case ISimpleAssignmentOperation assign + when TargetsLocal(assign.Target, local): + if (ContainsDurableTaskProducer(assign.Value, symbols)) + { + return true; + } + + break; + + case IInvocationOperation addCall + when addCall.TargetMethod.Name == "Add" + && addCall.Instance is ILocalReferenceOperation listRef + && SymbolEqualityComparer.Default.Equals(listRef.Local, local): + foreach (var addArg in addCall.Arguments) + { + if (ContainsDurableTaskProducer(addArg.Value, symbols)) + { + return true; + } + } + + break; + } + } + + return false; + } + + private static bool TargetsLocal(IOperation target, ILocalSymbol local) + { + // Direct local assignment, or element assignment tasks[i] = ... + switch (Unwrap(target)) + { + case ILocalReferenceOperation l: + return SymbolEqualityComparer.Default.Equals(l.Local, local); + case IArrayElementReferenceOperation arr when arr.ArrayReference is ILocalReferenceOperation arrLocal: + return SymbolEqualityComparer.Default.Equals(arrLocal.Local, local); + default: + return false; + } + } + + private static bool ContainsDurableTaskProducer(IOperation value, DurableKnownSymbols symbols) + { + foreach (var op in Unwrap(value).DescendantsAndSelf()) + { + if (op is IInvocationOperation inv && IsDurableTaskProducer(inv, symbols)) + { + return true; + } + } + + return false; + } + } +} diff --git a/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableTaskCombinatorCodeFixProvider.cs b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableTaskCombinatorCodeFixProvider.cs new file mode 100644 index 000000000..6bd52acce --- /dev/null +++ b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableTaskCombinatorCodeFixProvider.cs @@ -0,0 +1,233 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Composition; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Formatting; + +namespace Amazon.Lambda.DurableExecution.Analyzers +{ + /// + /// Code fix for DE004: rewrites await Task.WhenAll(ctx.StepAsync(a), ctx.StepAsync(b)) into + /// await ctx.ParallelAsync(new[] { (c, ct) => c.StepAsync(a), (c, ct) => c.StepAsync(b) }) + /// for the one provably-safe shape: an inline list of direct durable calls on a single shared + /// context whose aggregate result is discarded. Every other shape (result consumed, mixed task + /// types, a variable instead of an inline list, WhenAny) is left diagnostic-only. + /// + [ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(DurableTaskCombinatorCodeFixProvider))] + [Shared] + public sealed class DurableTaskCombinatorCodeFixProvider : CodeFixProvider + { + /// + public override ImmutableArray FixableDiagnosticIds { get; } = + ImmutableArray.Create(DurableDiagnostics.DurableTaskInTaskCombinator.Id); + + /// Fix-all is disabled — each conversion changes durable-operation structure and is reviewed individually. + public override FixAllProvider? GetFixAllProvider() => null; + + /// + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root is null) + { + return; + } + + var diagnostic = context.Diagnostics[0]; + var node = root.FindNode(diagnostic.Location.SourceSpan, getInnermostNodeForTie: true); + var invocation = node.FirstAncestorOrSelf(); + if (invocation is null) + { + return; + } + + // Only WhenAll (not WhenAny) and only when its result is discarded (the await is an + // expression statement, not consumed by an assignment / argument / return). + if (GetCombinatorName(invocation) != "WhenAll" || !ResultIsDiscarded(invocation)) + { + return; + } + + // All arguments must be inline durable calls of the SAME receiver identifier. + if (!TryGetHomogeneousDurableCalls(invocation, out var receiver, out var calls)) + { + return; + } + + var semanticModel = await context.Document.GetSemanticModelAsync(context.CancellationToken).ConfigureAwait(false); + if (semanticModel is null) + { + return; + } + + // Determine the shared result type T (each durable call returns Task). Bail if the calls + // are not all the same T — that is not the provably-safe homogeneous shape. + var elementType = TryGetSharedResultType(calls, semanticModel, context.CancellationToken); + if (elementType is null) + { + return; + } + + context.RegisterCodeFix( + CodeAction.Create( + title: $"Convert to {receiver}.ParallelAsync(...)", + createChangedDocument: ct => ConvertAsync(context.Document, root, invocation, receiver, calls, elementType, ct), + equivalenceKey: "ConvertToParallelAsync"), + diagnostic); + } + + private static string? TryGetSharedResultType( + List calls, + SemanticModel semanticModel, + CancellationToken cancellationToken) + { + string? shared = null; + foreach (var call in calls) + { + if (semanticModel.GetTypeInfo(call, cancellationToken).Type is not INamedTypeSymbol taskType + || taskType.TypeArguments.Length != 1) + { + return null; // Non-generic Task (void step) — not handled by this fix. + } + + var display = taskType.TypeArguments[0].ToDisplayString(); + if (shared is null) + { + shared = display; + } + else if (shared != display) + { + return null; // Heterogeneous result types — bail. + } + } + + return shared; + } + + private static string? GetCombinatorName(InvocationExpressionSyntax invocation) + { + return invocation.Expression switch + { + MemberAccessExpressionSyntax ma => ma.Name.Identifier.ValueText, + _ => null, + }; + } + + private static bool ResultIsDiscarded(InvocationExpressionSyntax invocation) + { + // The WhenAll invocation is typically wrapped in an await; the await must stand alone. + SyntaxNode current = invocation; + if (current.Parent is AwaitExpressionSyntax awaitExpr) + { + current = awaitExpr; + } + + return current.Parent is ExpressionStatementSyntax; + } + + private static bool TryGetHomogeneousDurableCalls( + InvocationExpressionSyntax invocation, + out string receiver, + out List calls) + { + receiver = string.Empty; + calls = new List(); + + var arguments = invocation.ArgumentList.Arguments; + if (arguments.Count < 2) + { + return false; // Nothing to parallelize. + } + + string? sharedReceiver = null; + foreach (var argument in arguments) + { + if (argument.Expression is not InvocationExpressionSyntax call + || call.Expression is not MemberAccessExpressionSyntax memberAccess + || memberAccess.Expression is not IdentifierNameSyntax receiverId) + { + return false; // Not a direct ctx.Method(...) call. + } + + if (sharedReceiver is null) + { + sharedReceiver = receiverId.Identifier.ValueText; + } + else if (sharedReceiver != receiverId.Identifier.ValueText) + { + return false; // Mixed receivers — bail. + } + + calls.Add(call); + } + + receiver = sharedReceiver!; + return true; + } + + private static Task ConvertAsync( + Document document, + SyntaxNode root, + InvocationExpressionSyntax whenAll, + string receiver, + List calls, + string elementType, + CancellationToken cancellationToken) + { + // Build one branch lambda per call: (c, ct) => c.StepAsync(...originalArgs...) + var branchLambdas = calls.Select(call => + { + var memberAccess = (MemberAccessExpressionSyntax)call.Expression; + var rebound = call.WithExpression( + memberAccess.WithExpression(SyntaxFactory.IdentifierName("c"))); + + return (ExpressionSyntax)SyntaxFactory.ParenthesizedLambdaExpression( + SyntaxFactory.ParameterList(SyntaxFactory.SeparatedList(new[] + { + SyntaxFactory.Parameter(SyntaxFactory.Identifier("c")), + SyntaxFactory.Parameter(SyntaxFactory.Identifier("ct")), + })), + rebound); + }).ToArray(); + + // Explicitly-typed array — a lambda has no inferable type, so `new[] { … }` would not + // compile; emit `new Func>[] { … }`. + var arrayType = SyntaxFactory.ArrayType( + SyntaxFactory.ParseTypeName( + $"System.Func>"), + SyntaxFactory.SingletonList( + SyntaxFactory.ArrayRankSpecifier( + SyntaxFactory.SingletonSeparatedList( + SyntaxFactory.OmittedArraySizeExpression())))); + + var arrayLiteral = SyntaxFactory.ArrayCreationExpression( + arrayType, + SyntaxFactory.InitializerExpression( + SyntaxKind.ArrayInitializerExpression, + SyntaxFactory.SeparatedList(branchLambdas))); + + var parallelCall = SyntaxFactory.InvocationExpression( + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + SyntaxFactory.IdentifierName(receiver), + SyntaxFactory.IdentifierName("ParallelAsync")), + SyntaxFactory.ArgumentList(SyntaxFactory.SingletonSeparatedList( + SyntaxFactory.Argument(arrayLiteral)))) + .WithTriviaFrom(whenAll) + .WithAdditionalAnnotations(Formatter.Annotation); + + var newRoot = root.ReplaceNode(whenAll, parallelCall); + return Task.FromResult(document.WithSyntaxRoot(newRoot)); + } + } +} diff --git a/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/MutableCaptureAnalyzer.cs b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/MutableCaptureAnalyzer.cs new file mode 100644 index 000000000..b2ba45da0 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/MutableCaptureAnalyzer.cs @@ -0,0 +1,157 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Collections.Generic; +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace Amazon.Lambda.DurableExecution.Analyzers +{ + /// + /// DE003 — flags mutation of a variable captured from an outer scope inside a durable-operation + /// delegate (StepAsync, RunInChildContextAsync, WaitForConditionAsync, WaitForCallbackAsync, and + /// the ParallelAsync / MapAsync branches). On replay the delegate body is skipped and the cached + /// result returned, so the write never happens and the captured variable holds stale state. + /// Reading a captured variable is safe and not flagged. + /// + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public sealed class MutableCaptureAnalyzer : DiagnosticAnalyzer + { + /// + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create(DurableDiagnostics.MutableCaptureInDurableOperation); + + /// + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + + context.RegisterCompilationStartAction(compilationStart => + { + var symbols = DurableKnownSymbols.TryCreate(compilationStart.Compilation); + if (symbols is null) + { + return; + } + + compilationStart.RegisterOperationAction( + ctx => AnalyzeInvocation(ctx, symbols), + OperationKind.Invocation); + }); + } + + private static void AnalyzeInvocation(OperationAnalysisContext context, DurableKnownSymbols symbols) + { + var invocation = (IInvocationOperation)context.Operation; + + if (!symbols.IsDurableOperation(invocation.TargetMethod, out _, out _)) + { + return; + } + + if (invocation.Instance is null || !symbols.IsDurableContextType(invocation.Instance.Type)) + { + return; + } + + // Each delegate argument is analyzed independently. ParallelAsync takes a list of branch + // delegates, so a single invocation can carry several lambdas (nested in array/collection + // wrappers). We collect only the OUTERMOST lambdas (the actual step/branch delegates); + // a lambda nested inside a delegate body is covered by that delegate's recursive analysis, + // so collecting it again would double-report the same captured write. + foreach (var argument in invocation.Arguments) + { + foreach (var lambda in OutermostLambdas(argument)) + { + AnalyzeDelegate(context, lambda); + } + } + } + + private static IEnumerable OutermostLambdas(IOperation root) + { + foreach (var op in root.Descendants()) + { + // Keep a lambda only if no other lambda sits between it and the argument root, so we + // return the branch/step delegates themselves, not lambdas nested inside their bodies. + if (op is IAnonymousFunctionOperation lambda && !HasEnclosingLambdaBelow(lambda, root)) + { + yield return lambda; + } + } + } + + private static bool HasEnclosingLambdaBelow(IOperation operation, IOperation root) + { + for (var parent = operation.Parent; parent is not null && !ReferenceEquals(parent, root); parent = parent.Parent) + { + if (parent is IAnonymousFunctionOperation) + { + return true; + } + } + + return false; + } + + private static void AnalyzeDelegate(OperationAnalysisContext context, IAnonymousFunctionOperation lambda) + { + var declaredInDelegate = DurableScope.CollectDeclaredSymbols(lambda); + + foreach (var descendant in lambda.Body.Descendants()) + { + var targetOp = GetAssignmentTargetOperation(descendant); + if (targetOp is null) + { + continue; + } + + var target = GetReferencedSymbol(targetOp); + if (target is null) + { + continue; + } + + // Captured iff the mutated symbol is not declared within this delegate (or a lambda + // nested inside it). Reads never reach here because only assignment targets are + // collected, so reading a captured variable is inherently allowed. + if (!declaredInDelegate.Contains(target)) + { + context.ReportDiagnostic(Diagnostic.Create( + DurableDiagnostics.MutableCaptureInDurableOperation, + targetOp.Syntax.GetLocation(), + target.Name)); + } + } + } + + /// + /// Returns the target operation being mutated by an assignment, compound assignment, coalesce + /// assignment, or increment/decrement; otherwise null. + /// + private static IOperation? GetAssignmentTargetOperation(IOperation operation) + { + return operation switch + { + ISimpleAssignmentOperation simple => simple.Target, + ICompoundAssignmentOperation compound => compound.Target, + ICoalesceAssignmentOperation coalesce => coalesce.Target, + IIncrementOrDecrementOperation incDec => incDec.Target, + _ => null, + }; + } + + private static ISymbol? GetReferencedSymbol(IOperation targetOp) + { + return targetOp switch + { + ILocalReferenceOperation local => local.Local, + IParameterReferenceOperation parameter => parameter.Parameter, + _ => null, + }; + } + } +} diff --git a/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/NestedDurableOperationAnalyzer.cs b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/NestedDurableOperationAnalyzer.cs new file mode 100644 index 000000000..f32184181 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/NestedDurableOperationAnalyzer.cs @@ -0,0 +1,124 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace Amazon.Lambda.DurableExecution.Analyzers +{ + /// + /// DE002 — flags a durable operation invoked inside a step-wrapped delegate (a StepAsync body, a + /// WaitForCallback submitter, or a WaitForCondition check) by capturing the outer durable context. + /// In .NET a step delegate receives IStepContext, which exposes no durable operations, so + /// the only way to compile a nested durable call is to capture the outer IDurableContext — + /// which is exactly what this rule detects. Nesting durable operations requires + /// RunInChildContextAsync. + /// + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public sealed class NestedDurableOperationAnalyzer : DiagnosticAnalyzer + { + /// + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create(DurableDiagnostics.NestedDurableOperationInsideStep); + + /// + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + + context.RegisterCompilationStartAction(compilationStart => + { + var symbols = DurableKnownSymbols.TryCreate(compilationStart.Compilation); + if (symbols is null) + { + return; + } + + compilationStart.RegisterOperationAction( + ctx => AnalyzeInvocation(ctx, symbols), + OperationKind.Invocation); + }); + } + + private static void AnalyzeInvocation(OperationAnalysisContext context, DurableKnownSymbols symbols) + { + var invocation = (IInvocationOperation)context.Operation; + + // Is this call itself a durable operation? + if (!symbols.IsDurableOperation(invocation.TargetMethod, out var nestedOpName, out _)) + { + return; + } + + // The receiver must be a durable context. Static/extension calls (Instance == null) and + // non-durable receivers are ignored. + if (invocation.Instance is null || !symbols.IsDurableContextType(invocation.Instance.Type)) + { + return; + } + + // Find the nearest enclosing durable delegate. We only care about step-wrapped bodies; + // nesting inside a child-context / parallel / map branch is legitimate. + var enclosing = DurableScope.FindNearestDurableDelegate(invocation, symbols); + if (!enclosing.Found || enclosing.Role != DurableDelegateRole.StepWrapped) + { + return; + } + + // The receiver must be captured from OUTSIDE this step delegate. A step delegate's own + // parameter is IStepContext (not a durable context), so any durable-context receiver here + // is necessarily captured — but we verify the symbol is not declared inside the delegate + // to stay correct for child/parallel branches that legitimately receive their own context. + var receiverSymbol = GetReferencedSymbol(invocation.Instance); + if (receiverSymbol is not null) + { + var declaredInDelegate = DurableScope.CollectDeclaredSymbols(enclosing.Function); + if (declaredInDelegate.Contains(receiverSymbol)) + { + return; // Receiver is local to the step delegate — not a captured outer context. + } + } + + var contextName = receiverSymbol?.Name ?? "context"; + var bodyKind = DescribeBody(enclosing.OperationName); + + context.ReportDiagnostic(Diagnostic.Create( + DurableDiagnostics.NestedDurableOperationInsideStep, + invocation.Syntax.GetLocation(), + nestedOpName, + contextName, + bodyKind)); + } + + private static ISymbol? GetReferencedSymbol(IOperation receiver) + { + switch (receiver) + { + case ILocalReferenceOperation local: + return local.Local; + case IParameterReferenceOperation parameter: + return parameter.Parameter; + case IFieldReferenceOperation field: + return field.Field; + default: + return null; + } + } + + private static string DescribeBody(string enclosingOperationName) + { + switch (enclosingOperationName) + { + case "WaitForConditionAsync": + return "condition-check"; + case "WaitForCallbackAsync": + return "callback-submitter"; + default: + return "step"; + } + } + } +} diff --git a/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/NonDeterministicCallAnalyzer.cs b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/NonDeterministicCallAnalyzer.cs new file mode 100644 index 000000000..ea3c00b8d --- /dev/null +++ b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/NonDeterministicCallAnalyzer.cs @@ -0,0 +1,77 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace Amazon.Lambda.DurableExecution.Analyzers +{ + /// + /// DE001 — flags non-deterministic API usage (DateTime.Now, Guid.NewGuid(), Random, …) in + /// durable workflow code that is not inside a step. On replay the workflow re-runs from the top, + /// so such values differ between the original execution and replays. The sanctioned place for + /// non-determinism is inside a step (or a WaitForCallback submitter / WaitForCondition check, + /// which the SDK also runs inside a step), where the result is checkpointed. + /// + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public sealed class NonDeterministicCallAnalyzer : DiagnosticAnalyzer + { + /// + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create(DurableDiagnostics.NonDeterministicCallOutsideStep); + + /// + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + + context.RegisterCompilationStartAction(compilationStart => + { + var symbols = DurableKnownSymbols.TryCreate(compilationStart.Compilation); + if (symbols is null) + { + return; // Project does not reference the durable SDK; nothing to analyze. + } + + compilationStart.RegisterOperationAction( + ctx => AnalyzeOperation(ctx, symbols), + OperationKind.PropertyReference, + OperationKind.Invocation, + OperationKind.ObjectCreation); + }); + } + + private static void AnalyzeOperation(OperationAnalysisContext context, DurableKnownSymbols symbols) + { + var operation = context.Operation; + + var api = symbols.TryGetNonDeterministicApi(operation); + if (api is null) + { + return; + } + + // Only flag inside durable workflow code (a method/lambda taking an IDurableContext). + if (!DurableScope.IsInWorkflowCode(operation, symbols)) + { + return; + } + + // Suppress when the nearest enclosing durable delegate is step-wrapped — non-determinism + // is allowed (and expected) there because its result is checkpointed. + var enclosing = DurableScope.FindNearestDurableDelegate(operation, symbols); + if (enclosing.Found && enclosing.Role == DurableDelegateRole.StepWrapped) + { + return; + } + + context.ReportDiagnostic(Diagnostic.Create( + DurableDiagnostics.NonDeterministicCallOutsideStep, + operation.Syntax.GetLocation(), + api)); + } + } +} diff --git a/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/NonDeterministicCallCodeFixProvider.cs b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/NonDeterministicCallCodeFixProvider.cs new file mode 100644 index 000000000..8d839635c --- /dev/null +++ b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/NonDeterministicCallCodeFixProvider.cs @@ -0,0 +1,231 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Collections.Immutable; +using System.Composition; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Formatting; + +namespace Amazon.Lambda.DurableExecution.Analyzers +{ + /// + /// Code fix for DE001: wraps a non-deterministic expression in a step so its value is checkpointed + /// (await context.StepAsync((_, _) => Task.FromResult(E))). Offered as a single-occurrence + /// quick fix only — never a fix-all — because inserting a step shifts the position-derived + /// operation IDs of subsequent durable calls, which would break replay for already-running + /// executions. The fix is withheld for shapes it cannot safely rewrite. + /// + [ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(NonDeterministicCallCodeFixProvider))] + [Shared] + public sealed class NonDeterministicCallCodeFixProvider : CodeFixProvider + { + private const string IDurableContextMetadataName = DurableKnownSymbols.IDurableContextMetadataName; + + /// + public override ImmutableArray FixableDiagnosticIds { get; } = + ImmutableArray.Create(DurableDiagnostics.NonDeterministicCallOutsideStep.Id); + + /// + /// Fix-all is intentionally disabled: inserting a step shifts the position-derived operation + /// IDs of every subsequent durable call, which would break replay for in-flight executions. + /// + public override FixAllProvider? GetFixAllProvider() => null; + + /// + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root is null) + { + return; + } + + var diagnostic = context.Diagnostics[0]; + var node = root.FindNode(diagnostic.Location.SourceSpan, getInnermostNodeForTie: true); + var expression = node as ExpressionSyntax ?? node.FirstAncestorOrSelf(); + if (expression is null) + { + return; + } + + var semanticModel = await context.Document.GetSemanticModelAsync(context.CancellationToken).ConfigureAwait(false); + if (semanticModel is null) + { + return; + } + + if (!IsSafelyWrappable(expression, semanticModel, context.CancellationToken, out var contextName)) + { + return; + } + + context.RegisterCodeFix( + CodeAction.Create( + title: $"Wrap in {contextName}.StepAsync(...)", + createChangedDocument: ct => WrapInStepAsync(context.Document, root, expression, contextName, ct), + equivalenceKey: "WrapInStepAsync"), + diagnostic); + } + + private static bool IsSafelyWrappable( + ExpressionSyntax expression, + SemanticModel semanticModel, + CancellationToken cancellationToken, + out string contextName) + { + contextName = "context"; + + // Guard: the expression must not itself contain an await, out/ref/in argument, or be a + // method group / lambda — wrapping any of those produces non-compiling or wrong code. + if (expression.DescendantNodesAndSelf().Any(n => n is AwaitExpressionSyntax)) + { + return false; + } + + foreach (var argument in expression.DescendantNodes().OfType()) + { + if (!argument.RefKindKeyword.IsKind(SyntaxKind.None)) + { + return false; + } + } + + // The expression must produce a usable value (object creation of e.g. Random is flagged by + // DE001 but is not meaningfully wrappable into a checkpointed value — skip it). + if (expression is ObjectCreationExpressionSyntax) + { + return false; + } + + var typeInfo = semanticModel.GetTypeInfo(expression, cancellationToken); + if (typeInfo.Type is null || typeInfo.Type.TypeKind == TypeKind.Error || typeInfo.Type.SpecialType == SpecialType.System_Void) + { + return false; + } + + // await is only legal inside an async context; require one. + if (!IsInAsyncContext(expression)) + { + return false; + } + + // Find an in-scope IDurableContext to call StepAsync on. + var ctxName = FindDurableContextName(expression, semanticModel, cancellationToken); + if (ctxName is null) + { + return false; + } + + contextName = ctxName; + return true; + } + + private static bool IsInAsyncContext(SyntaxNode node) + { + for (var current = node.Parent; current is not null; current = current.Parent) + { + switch (current) + { + case MethodDeclarationSyntax m: + return m.Modifiers.Any(SyntaxKind.AsyncKeyword); + case LocalFunctionStatementSyntax lf: + return lf.Modifiers.Any(SyntaxKind.AsyncKeyword); + case AnonymousFunctionExpressionSyntax af: + return af.AsyncKeyword.IsKind(SyntaxKind.AsyncKeyword); + } + } + + return false; + } + + private static string? FindDurableContextName( + ExpressionSyntax expression, + SemanticModel semanticModel, + CancellationToken cancellationToken) + { + var durableContextType = semanticModel.Compilation.GetTypeByMetadataName(IDurableContextMetadataName); + if (durableContextType is null) + { + return null; + } + + // Walk enclosing lambdas/methods looking for an IDurableContext-typed parameter. + for (var current = expression.Parent; current is not null; current = current.Parent) + { + var parameters = current switch + { + ParenthesizedLambdaExpressionSyntax pl => pl.ParameterList.Parameters, + SimpleLambdaExpressionSyntax sl => SyntaxFactory.SeparatedList(new[] { sl.Parameter }), + MethodDeclarationSyntax m => m.ParameterList.Parameters, + LocalFunctionStatementSyntax lf => lf.ParameterList.Parameters, + _ => default, + }; + + foreach (var parameter in parameters) + { + var symbol = semanticModel.GetDeclaredSymbol(parameter, cancellationToken) as IParameterSymbol; + if (symbol is not null && Implements(symbol.Type, durableContextType)) + { + return symbol.Name; + } + } + } + + return null; + } + + private static bool Implements(ITypeSymbol type, INamedTypeSymbol durableContextType) + { + if (SymbolEqualityComparer.Default.Equals(type.OriginalDefinition, durableContextType)) + { + return true; + } + + return type.AllInterfaces.Any(i => SymbolEqualityComparer.Default.Equals(i.OriginalDefinition, durableContextType)); + } + + private static Task WrapInStepAsync( + Document document, + SyntaxNode root, + ExpressionSyntax expression, + string contextName, + CancellationToken cancellationToken) + { + // await {ctx}.StepAsync((_, _) => System.Threading.Tasks.Task.FromResult(E)) + var lambda = SyntaxFactory.ParenthesizedLambdaExpression( + SyntaxFactory.ParameterList(SyntaxFactory.SeparatedList(new[] + { + SyntaxFactory.Parameter(SyntaxFactory.Identifier("_")), + SyntaxFactory.Parameter(SyntaxFactory.Identifier("__")), + })), + SyntaxFactory.InvocationExpression( + SyntaxFactory.ParseExpression("System.Threading.Tasks.Task.FromResult"), + SyntaxFactory.ArgumentList(SyntaxFactory.SingletonSeparatedList( + SyntaxFactory.Argument(expression.WithoutTrivia()))))); + + var stepCall = SyntaxFactory.AwaitExpression( + SyntaxFactory.InvocationExpression( + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + SyntaxFactory.IdentifierName(contextName), + SyntaxFactory.IdentifierName("StepAsync")), + SyntaxFactory.ArgumentList(SyntaxFactory.SingletonSeparatedList( + SyntaxFactory.Argument(lambda))))); + + var replacement = stepCall + .WithLeadingTrivia(expression.GetLeadingTrivia()) + .WithTrailingTrivia(expression.GetTrailingTrivia()) + .WithAdditionalAnnotations(Formatter.Annotation); + + var newRoot = root.ReplaceNode(expression, replacement); + return Task.FromResult(document.WithSyntaxRoot(newRoot)); + } + } +} diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Amazon.Lambda.DurableExecution.csproj b/Libraries/src/Amazon.Lambda.DurableExecution/Amazon.Lambda.DurableExecution.csproj index cb03b2715..632911276 100644 --- a/Libraries/src/Amazon.Lambda.DurableExecution/Amazon.Lambda.DurableExecution.csproj +++ b/Libraries/src/Amazon.Lambda.DurableExecution/Amazon.Lambda.DurableExecution.csproj @@ -32,6 +32,26 @@ + + + ..\Amazon.Lambda.DurableExecution.Analyzers\ + + + + + + + diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/docs/analyzers.md b/Libraries/src/Amazon.Lambda.DurableExecution/docs/analyzers.md new file mode 100644 index 000000000..bc498c10b --- /dev/null +++ b/Libraries/src/Amazon.Lambda.DurableExecution/docs/analyzers.md @@ -0,0 +1,130 @@ +# Durable Execution Analyzers (DE001–DE004) + +`Amazon.Lambda.DurableExecution` ships a set of Roslyn analyzers that run in the IDE and during +`dotnet build` to catch the most common durable-execution authoring mistakes before they become +confusing runtime failures. The analyzers are bundled inside the `Amazon.Lambda.DurableExecution` +NuGet package (`analyzers/dotnet/cs`), so they activate automatically for any project that references +the package — no extra reference is required. They only run on projects that reference the durable SDK +and only inside durable *workflow code* (a method, local function, or lambda that takes an +`IDurableContext` parameter), so unrelated code in the same project is unaffected. + +These analyzers are the .NET counterpart of the JavaScript SDK's +[`@aws/durable-execution-sdk-js-eslint-plugin`](https://github.com/aws/aws-durable-execution-sdk-js/tree/main/packages/aws-durable-execution-sdk-js-eslint-plugin), +re-implemented with Roslyn's semantic model so durable operations are matched by symbol +(`Amazon.Lambda.DurableExecution.IDurableContext`) rather than by method name. + +## Why determinism matters + +A durable workflow has no persisted program counter. On every invocation — including every replay +after a wait, retry, or failure — your handler runs again from the top. Each durable call +(`StepAsync`, `WaitAsync`, …) looks up its checkpoint and either replays the cached result or runs +fresh. For the cached results to line up with the code, the workflow must execute the **same +operations, in the same order, every time**. Code *outside* a step must therefore be deterministic; +code *inside* a step may be non-deterministic because the step's result is checkpointed once. + +## Rules + +| ID | Severity | Rule | +|-------|----------|------| +| DE001 | Warning | Non-deterministic call outside a step | +| DE002 | Warning | Nested durable operation inside a step body | +| DE003 | Warning | Mutable variable captured and modified inside a durable operation | +| DE004 | Info | `Task.WhenAll`/`Task.WhenAny` over durable tasks | + +### DE001 — Non-deterministic call outside a step + +Flags `DateTime.Now`/`UtcNow`/`Today`, `DateTimeOffset.Now`/`UtcNow`, `Guid.NewGuid()`, +`new Random()` / `Random.Shared`, `Stopwatch.GetTimestamp()`/`StartNew()`/`Elapsed*`, +`Environment.TickCount`/`TickCount64`, `RandomNumberGenerator`/`RNGCryptoServiceProvider`, and +`Path.GetTempFileName()`/`GetRandomFileName()` when used in workflow code outside a step. The value +would differ between the original execution and replays, corrupting checkpoint-derived state. A +seeded `new Random(42)` is deterministic and is **not** flagged. + +```csharp +// ❌ Flagged — different value on replay +var now = DateTime.UtcNow; +await context.StepAsync((s, ct) => DoWork(now)); + +// ✅ Captured inside a step +var now = await context.StepAsync((s, ct) => Task.FromResult(DateTime.UtcNow)); +await context.StepAsync((s, ct) => DoWork(now)); +``` + +A code fix is offered that wraps the expression in `context.StepAsync(...)`. It is a single-occurrence +quick fix only (no Fix All), because inserting a step shifts the position-derived operation IDs of +subsequent durable calls and would break replay for already-running executions. + +### DE002 — Nested durable operation inside a step body + +Flags any durable operation (`StepAsync`, `WaitAsync`, `ParallelAsync`, `MapAsync`, `InvokeAsync`, +`RunInChildContextAsync`, `CreateCallbackAsync`, `WaitForCallbackAsync`) invoked inside a *step-wrapped* +delegate — a `StepAsync` body, a `WaitForCallbackAsync` submitter, or a `WaitForConditionAsync` check — +by capturing the outer `IDurableContext`. Step bodies are leaf operations; group durable operations +with `RunInChildContextAsync` instead. + +```csharp +// ❌ Flagged — durable op nested in a step body +await context.StepAsync(async (s, ct) => +{ + await context.WaitAsync(TimeSpan.FromSeconds(1)); // uses the captured outer context +}); + +// ✅ Group with a child context +await context.RunInChildContextAsync(async (child, ct) => +{ + await child.StepAsync((s, c) => DoWork()); + await child.WaitAsync(TimeSpan.FromSeconds(1)); +}); +``` + +### DE003 — Mutable variable captured and modified inside a durable operation + +Flags assignment, compound assignment, or increment/decrement of a variable captured from an outer +scope inside a durable-operation delegate (`StepAsync`, `RunInChildContextAsync`, +`WaitForConditionAsync`, `WaitForCallbackAsync`, and `ParallelAsync`/`MapAsync` branches). On replay the +body is skipped and the cached result returned, so the write never happens. Reading a captured variable +is safe and is not flagged. + +```csharp +// ❌ Flagged — write is lost on replay +int total = 0; +await context.StepAsync((s, ct) => { total += 1; return Task.CompletedTask; }); + +// ✅ Return the value and assign it +total = await context.StepAsync((s, ct) => Task.FromResult(total + 1)); +``` + +### DE004 — `Task.WhenAll`/`Task.WhenAny` over durable tasks + +Advisory (Info). `Task.WhenAll`/`Task.WhenAny` work correctly with durable tasks (operation IDs are +allocated deterministically), but they bypass completion policies, concurrency limits, branch naming, +and structured `IBatchResult` output. Prefer `ParallelAsync`/`MapAsync`. + +```csharp +// ℹ️ Suggested — works, but prefer ParallelAsync +await Task.WhenAll(context.StepAsync(a), context.StepAsync(b)); + +// ✅ Preferred +await context.ParallelAsync(new Func>[] +{ + (c, ct) => c.StepAsync((s, t) => A()), + (c, ct) => c.StepAsync((s, t) => B()), +}); +``` + +A code fix converts the simplest safe shape (an inline list of same-typed durable calls on one context +whose aggregate result is discarded) into `ParallelAsync`. + +## Configuring severity + +Each rule's severity can be overridden per project via `.editorconfig`: + +```ini +# Treat the non-determinism rule as an error, silence the WhenAll suggestion. +dotnet_diagnostic.DE001.severity = error +dotnet_diagnostic.DE004.severity = none +``` + +Note: if your project sets `true`, the Warning-level +rules (DE001–DE003) will fail the build. Lower their severity in `.editorconfig` if you prefer them as +warnings during the preview. diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/Amazon.Lambda.DurableExecution.Analyzers.Tests.csproj b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/Amazon.Lambda.DurableExecution.Analyzers.Tests.csproj new file mode 100644 index 000000000..a9f3df12c --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/Amazon.Lambda.DurableExecution.Analyzers.Tests.csproj @@ -0,0 +1,31 @@ + + + + net8.0 + latest + enable + false + false + + $(NoWarn);CS0618 + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/DurableStubs.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/DurableStubs.cs new file mode 100644 index 000000000..2e7c36ebd --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/DurableStubs.cs @@ -0,0 +1,67 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +namespace Amazon.Lambda.DurableExecution.Analyzers.Tests +{ + /// + /// A faithful (signature-only) source copy of the durable-execution surface the analyzers match + /// against. Injected into every test compilation as source so tests do not need to reference the + /// real Amazon.Lambda.DurableExecution package (which pulls AWSSDK and is awkward in the analyzer + /// test harness). The analyzers resolve these by metadata name, so the stub namespace and member + /// shapes must match the real SDK exactly. + /// + internal static class DurableStubs + { + internal const string Source = @" +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Amazon.Lambda.DurableExecution +{ + public interface IStepContext { int AttemptNumber { get; } string OperationId { get; } } + public interface IConditionCheckContext { } + public interface IWaitForCallbackContext { } + public interface IExecutionContext { string DurableExecutionArn { get; } } + public interface ICallback { string CallbackId { get; } Task GetResultAsync(CancellationToken ct = default); } + + public sealed class StepConfig { } + public sealed class ChildContextConfig { } + public sealed class CallbackConfig { } + public sealed class WaitForCallbackConfig { } + public sealed class InvokeConfig { } + public sealed class ParallelConfig { } + public sealed class MapConfig { } + public sealed class WaitForConditionConfig { } + public readonly struct DurableBranch { public DurableBranch(string name, Func> func) { } } + public interface IBatchResult { } + + public interface IDurableContext + { + IExecutionContext ExecutionContext { get; } + + Task StepAsync(Func> func, string name = null, StepConfig config = null, CancellationToken cancellationToken = default); + Task StepAsync(Func func, string name = null, StepConfig config = null, CancellationToken cancellationToken = default); + + Task WaitAsync(TimeSpan duration, string name = null, CancellationToken cancellationToken = default); + + Task RunInChildContextAsync(Func> func, string name = null, ChildContextConfig config = null, CancellationToken cancellationToken = default); + Task RunInChildContextAsync(Func func, string name = null, ChildContextConfig config = null, CancellationToken cancellationToken = default); + + Task> CreateCallbackAsync(string name = null, CallbackConfig config = null, CancellationToken cancellationToken = default); + Task WaitForCallbackAsync(Func submitter, string name = null, WaitForCallbackConfig config = null, CancellationToken cancellationToken = default); + + Task InvokeAsync(string functionName, TPayload payload, string name = null, InvokeConfig config = null, CancellationToken cancellationToken = default); + + Task WaitForConditionAsync(Func> check, WaitForConditionConfig config, string name = null, CancellationToken cancellationToken = default); + + Task> ParallelAsync(IReadOnlyList>> branches, string name = null, ParallelConfig config = null, CancellationToken cancellationToken = default); + Task> ParallelAsync(IReadOnlyList> branches, string name = null, ParallelConfig config = null, CancellationToken cancellationToken = default); + + Task> MapAsync(IReadOnlyList items, Func, CancellationToken, Task> func, string name = null, MapConfig config = null, CancellationToken cancellationToken = default); + } +} +"; + } +} diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/DurableTaskCombinatorAnalyzerTests.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/DurableTaskCombinatorAnalyzerTests.cs new file mode 100644 index 000000000..d79e02e31 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/DurableTaskCombinatorAnalyzerTests.cs @@ -0,0 +1,108 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.Testing; +using Xunit; +using Verify = Amazon.Lambda.DurableExecution.Analyzers.Tests.AnalyzerVerifier< + Amazon.Lambda.DurableExecution.Analyzers.DurableTaskCombinatorAnalyzer>; + +namespace Amazon.Lambda.DurableExecution.Analyzers.Tests +{ + public class DurableTaskCombinatorAnalyzerTests + { + private const string Usings = @" +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Amazon.Lambda.DurableExecution; +"; + + [Fact] + public async Task WhenAll_OverInlineDurableTasks_IsFlagged() + { + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + await {|#0:Task.WhenAll( + context.StepAsync((s, ct) => Task.FromResult(1)), + context.StepAsync((s, ct) => Task.FromResult(2)))|}; + } +}"; + var expected = Verify.Diagnostic(DurableDiagnostics.DurableTaskInTaskCombinator) + .WithLocation(0).WithArguments("Task.WhenAll"); + await Verify.VerifyAsync(source, expected); + } + + [Fact] + public async Task WhenAny_OverInlineDurableTasks_IsFlagged() + { + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + await {|#0:Task.WhenAny( + context.StepAsync((s, ct) => Task.FromResult(1)), + context.StepAsync((s, ct) => Task.FromResult(2)))|}; + } +}"; + var expected = Verify.Diagnostic(DurableDiagnostics.DurableTaskInTaskCombinator) + .WithLocation(0).WithArguments("Task.WhenAny"); + await Verify.VerifyAsync(source, expected); + } + + [Fact] + public async Task WhenAll_OverTaskArrayLocal_IsFlagged() + { + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + var tasks = new List>(); + tasks.Add(context.StepAsync((s, ct) => Task.FromResult(1))); + await {|#0:Task.WhenAll(tasks)|}; + } +}"; + var expected = Verify.Diagnostic(DurableDiagnostics.DurableTaskInTaskCombinator) + .WithLocation(0).WithArguments("Task.WhenAll"); + await Verify.VerifyAsync(source, expected); + } + + [Fact] + public async Task WhenAll_OverNonDurableTasks_IsNotFlagged() + { + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + await Task.WhenAll(Task.Delay(1), Task.Delay(2)); + } +}"; + await Verify.VerifyAsync(source); + } + + [Fact] + public async Task ParallelAsync_IsNotFlagged() + { + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + await context.ParallelAsync(new Func>[] + { + (c, ct) => c.StepAsync((s, t) => Task.FromResult(1)), + (c, ct) => c.StepAsync((s, t) => Task.FromResult(2)), + }); + } +}"; + await Verify.VerifyAsync(source); + } + } +} diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/DurableTaskCombinatorCodeFixTests.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/DurableTaskCombinatorCodeFixTests.cs new file mode 100644 index 000000000..12e7a4d28 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/DurableTaskCombinatorCodeFixTests.cs @@ -0,0 +1,49 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.Testing; +using Xunit; +using Verify = Amazon.Lambda.DurableExecution.Analyzers.Tests.CodeFixVerifier< + Amazon.Lambda.DurableExecution.Analyzers.DurableTaskCombinatorAnalyzer, + Amazon.Lambda.DurableExecution.Analyzers.DurableTaskCombinatorCodeFixProvider>; + +namespace Amazon.Lambda.DurableExecution.Analyzers.Tests +{ + public class DurableTaskCombinatorCodeFixTests + { + private const string Usings = @" +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Amazon.Lambda.DurableExecution; +"; + + [Fact] + public async Task ConvertsWhenAll_ToParallelAsync_WhenResultDiscarded() + { + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + await {|#0:Task.WhenAll( + context.StepAsync((s, ct) => Task.FromResult(1)), + context.StepAsync((s, ct) => Task.FromResult(2)))|}; + } +}"; + var fixedSource = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + await context.ParallelAsync(new System.Func>[] { (c, ct) => c.StepAsync((s, ct) => Task.FromResult(1)), (c, ct) => c.StepAsync((s, ct) => Task.FromResult(2)) }); + } +}"; + var expected = Verify.Diagnostic(DurableDiagnostics.DurableTaskInTaskCombinator) + .WithLocation(0).WithArguments("Task.WhenAll"); + await Verify.VerifyAsync(source, fixedSource, expected); + } + } +} diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/MutableCaptureAnalyzerTests.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/MutableCaptureAnalyzerTests.cs new file mode 100644 index 000000000..52c487f78 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/MutableCaptureAnalyzerTests.cs @@ -0,0 +1,184 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.Testing; +using Xunit; +using Verify = Amazon.Lambda.DurableExecution.Analyzers.Tests.AnalyzerVerifier< + Amazon.Lambda.DurableExecution.Analyzers.MutableCaptureAnalyzer>; + +namespace Amazon.Lambda.DurableExecution.Analyzers.Tests +{ + public class MutableCaptureAnalyzerTests + { + private const string Usings = @" +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Amazon.Lambda.DurableExecution; +"; + + [Fact] + public async Task SimpleAssignment_OfCapturedVariable_IsFlagged() + { + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + int counter = 0; + await context.StepAsync((s, ct) => { {|#0:counter|} = 5; return Task.CompletedTask; }); + } +}"; + var expected = Verify.Diagnostic(DurableDiagnostics.MutableCaptureInDurableOperation) + .WithLocation(0).WithArguments("counter"); + await Verify.VerifyAsync(source, expected); + } + + [Fact] + public async Task CompoundAssignment_OfCapturedVariable_IsFlagged() + { + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + int total = 0; + await context.StepAsync((s, ct) => { {|#0:total|} += 1; return Task.CompletedTask; }); + } +}"; + var expected = Verify.Diagnostic(DurableDiagnostics.MutableCaptureInDurableOperation) + .WithLocation(0).WithArguments("total"); + await Verify.VerifyAsync(source, expected); + } + + [Fact] + public async Task Increment_OfCapturedVariable_IsFlagged() + { + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + int n = 0; + await context.StepAsync((s, ct) => { {|#0:n|}++; return Task.CompletedTask; }); + } +}"; + var expected = Verify.Diagnostic(DurableDiagnostics.MutableCaptureInDurableOperation) + .WithLocation(0).WithArguments("n"); + await Verify.VerifyAsync(source, expected); + } + + [Fact] + public async Task ReadingCapturedVariable_IsNotFlagged() + { + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + int seed = 41; + await context.StepAsync((s, ct) => Task.FromResult(seed + 1)); + } +}"; + await Verify.VerifyAsync(source); + } + + [Fact] + public async Task MutatingDelegateLocal_IsNotFlagged() + { + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + await context.StepAsync((s, ct) => + { + int local = 0; + local += 1; + return Task.FromResult(local); + }); + } +}"; + await Verify.VerifyAsync(source); + } + + [Fact] + public async Task ShadowingLocal_IsNotFlagged() + { + // An inner local that shadows an outer name is delegate-local by symbol identity. + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + int total = 100; + await context.StepAsync((s, ct) => + { + int total2 = 0; + total2 += 1; + return Task.FromResult(total2); + }); + } +}"; + await Verify.VerifyAsync(source); + } + + [Fact] + public async Task MutatingCapturedVariable_InChildContext_IsFlagged() + { + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + int flag = 0; + await context.RunInChildContextAsync((child, ct) => { {|#0:flag|} = 1; return Task.CompletedTask; }); + } +}"; + var expected = Verify.Diagnostic(DurableDiagnostics.MutableCaptureInDurableOperation) + .WithLocation(0).WithArguments("flag"); + await Verify.VerifyAsync(source, expected); + } + + [Fact] + public async Task MutatingPerItemAndBranchLocals_InParallel_IsNotFlagged() + { + // The branch delegate's own parameters and locals are not captures. + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + await context.ParallelAsync(new Func>[] + { + (branch, ct) => { int x = 0; x += 1; return Task.FromResult(x); }, + (branch, ct) => Task.FromResult(2), + }); + } +}"; + await Verify.VerifyAsync(source); + } + + [Fact] + public async Task MutatingCapturedVariable_FromParallelBranch_IsFlagged() + { + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + int shared = 0; + await context.ParallelAsync(new Func>[] + { + (branch, ct) => { {|#0:shared|} += 1; return Task.FromResult(shared); }, + }); + } +}"; + var expected = Verify.Diagnostic(DurableDiagnostics.MutableCaptureInDurableOperation) + .WithLocation(0).WithArguments("shared"); + await Verify.VerifyAsync(source, expected); + } + } +} diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/NestedDurableOperationAnalyzerTests.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/NestedDurableOperationAnalyzerTests.cs new file mode 100644 index 000000000..6493dba53 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/NestedDurableOperationAnalyzerTests.cs @@ -0,0 +1,153 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.Testing; +using Xunit; +using Verify = Amazon.Lambda.DurableExecution.Analyzers.Tests.AnalyzerVerifier< + Amazon.Lambda.DurableExecution.Analyzers.NestedDurableOperationAnalyzer>; + +namespace Amazon.Lambda.DurableExecution.Analyzers.Tests +{ + public class NestedDurableOperationAnalyzerTests + { + private const string Usings = @" +using System; +using System.Threading; +using System.Threading.Tasks; +using Amazon.Lambda.DurableExecution; +"; + + [Fact] + public async Task DurableOp_InsideStepBody_ViaCapturedContext_IsFlagged() + { + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + await context.StepAsync(async (s, ct) => + { + await {|#0:context.WaitAsync(TimeSpan.FromSeconds(1))|}; + }); + } +}"; + var expected = Verify.Diagnostic(DurableDiagnostics.NestedDurableOperationInsideStep) + .WithLocation(0).WithArguments("WaitAsync", "context", "step"); + await Verify.VerifyAsync(source, expected); + } + + [Fact] + public async Task NestedStepAsync_InsideStepBody_IsFlagged() + { + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + await context.StepAsync(async (s, ct) => + { + await {|#0:context.StepAsync((s2, ct2) => Task.CompletedTask)|}; + }); + } +}"; + var expected = Verify.Diagnostic(DurableDiagnostics.NestedDurableOperationInsideStep) + .WithLocation(0).WithArguments("StepAsync", "context", "step"); + await Verify.VerifyAsync(source, expected); + } + + [Fact] + public async Task DurableOp_InsideChildContext_OnChildContext_IsNotFlagged() + { + // Using the child's own context inside a child context is the SANCTIONED nesting pattern. + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + await context.RunInChildContextAsync(async (child, ct) => + { + await child.StepAsync((s, c) => Task.CompletedTask); + await child.WaitAsync(TimeSpan.FromSeconds(1)); + }); + } +}"; + await Verify.VerifyAsync(source); + } + + [Fact] + public async Task DurableOp_DirectlyInWorkflowBody_IsNotFlagged() + { + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + await context.StepAsync((s, ct) => Task.CompletedTask); + await context.WaitAsync(TimeSpan.FromSeconds(1)); + } +}"; + await Verify.VerifyAsync(source); + } + + [Fact] + public async Task DurableOp_InsideConditionCheck_ViaCapturedContext_IsFlagged() + { + // WaitForConditionAsync's check delegate is step-wrapped at runtime. + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + await context.WaitForConditionAsync(async (state, cc, ct) => + { + await {|#0:context.WaitAsync(TimeSpan.FromSeconds(1))|}; + return state + 1; + }, new WaitForConditionConfig()); + } +}"; + var expected = Verify.Diagnostic(DurableDiagnostics.NestedDurableOperationInsideStep) + .WithLocation(0).WithArguments("WaitAsync", "context", "condition-check"); + await Verify.VerifyAsync(source, expected); + } + + [Fact] + public async Task DurableOp_InsideCallbackSubmitter_ViaCapturedContext_IsFlagged() + { + // WaitForCallbackAsync's submitter delegate is step-wrapped at runtime. + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + await context.WaitForCallbackAsync(async (callbackId, cc, ct) => + { + await {|#0:context.StepAsync((s, c) => Task.CompletedTask)|}; + }); + } +}"; + var expected = Verify.Diagnostic(DurableDiagnostics.NestedDurableOperationInsideStep) + .WithLocation(0).WithArguments("StepAsync", "context", "callback-submitter"); + await Verify.VerifyAsync(source, expected); + } + + [Fact] + public async Task ConfigureLogger_LikeMembers_NotTreatedAsDurableOps() + { + // Reading ExecutionContext (a property, not a durable op) inside a step is fine. + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + await context.StepAsync((s, ct) => + { + var arn = context.ExecutionContext.DurableExecutionArn; + return Task.FromResult(arn); + }); + } +}"; + await Verify.VerifyAsync(source); + } + } +} diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/NonDeterministicCallAnalyzerTests.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/NonDeterministicCallAnalyzerTests.cs new file mode 100644 index 000000000..144344689 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/NonDeterministicCallAnalyzerTests.cs @@ -0,0 +1,211 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.Testing; +using Xunit; +using Verify = Amazon.Lambda.DurableExecution.Analyzers.Tests.AnalyzerVerifier< + Amazon.Lambda.DurableExecution.Analyzers.NonDeterministicCallAnalyzer>; + +namespace Amazon.Lambda.DurableExecution.Analyzers.Tests +{ + public class NonDeterministicCallAnalyzerTests + { + private const string Usings = @" +using System; +using System.Threading; +using System.Threading.Tasks; +using Amazon.Lambda.DurableExecution; +"; + + [Fact] + public async Task DateTimeNow_InWorkflowBody_OutsideStep_IsFlagged() + { + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + var now = {|#0:DateTime.Now|}; + await context.StepAsync((s, ct) => Task.FromResult(now)); + } +}"; + var expected = Verify.Diagnostic(DurableDiagnostics.NonDeterministicCallOutsideStep) + .WithLocation(0).WithArguments("DateTime.Now"); + await Verify.VerifyAsync(source, expected); + } + + [Fact] + public async Task DateTimeUtcNow_InsideStep_IsNotFlagged() + { + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + await context.StepAsync((s, ct) => Task.FromResult(DateTime.UtcNow)); + } +}"; + await Verify.VerifyAsync(source); + } + + [Fact] + public async Task GuidNewGuid_OutsideStep_IsFlagged() + { + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + var id = {|#0:Guid.NewGuid()|}; + await context.StepAsync((s, ct) => Task.FromResult(id.ToString())); + } +}"; + var expected = Verify.Diagnostic(DurableDiagnostics.NonDeterministicCallOutsideStep) + .WithLocation(0).WithArguments("Guid.NewGuid()"); + await Verify.VerifyAsync(source, expected); + } + + [Fact] + public async Task NewRandom_OutsideStep_IsFlagged() + { + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + var r = {|#0:new Random()|}; + await context.StepAsync((s, ct) => Task.FromResult(r.Next())); + } +}"; + var expected = Verify.Diagnostic(DurableDiagnostics.NonDeterministicCallOutsideStep) + .WithLocation(0).WithArguments("new Random()"); + await Verify.VerifyAsync(source, expected); + } + + [Fact] + public async Task SeededRandom_IsNotFlagged() + { + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + var r = new Random(42); + await context.StepAsync((s, ct) => Task.FromResult(r.Next())); + } +}"; + await Verify.VerifyAsync(source); + } + + [Fact] + public async Task RandomShared_OutsideStep_IsFlagged() + { + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + var n = {|#0:Random.Shared|}.Next(); + await context.StepAsync((s, ct) => Task.FromResult(n)); + } +}"; + var expected = Verify.Diagnostic(DurableDiagnostics.NonDeterministicCallOutsideStep) + .WithLocation(0).WithArguments("Random.Shared"); + await Verify.VerifyAsync(source, expected); + } + + [Fact] + public async Task NonDeterminism_NestedInNonDurableLambdaInsideStep_IsNotFlagged() + { + // DateTime.UtcNow inside a LINQ projection that itself runs inside a step is fine — + // the enclosing durable delegate (StepAsync) is step-wrapped. + var source = Usings + @" +using System.Linq; +class W +{ + public async Task Run(string input, IDurableContext context) + { + await context.StepAsync((s, ct) => + { + var times = Enumerable.Range(0, 3).Select(x => DateTime.UtcNow).ToArray(); + return Task.FromResult(times.Length); + }); + } +}"; + await Verify.VerifyAsync(source); + } + + [Fact] + public async Task DateTimeNow_InNonWorkflowMethod_IsNotFlagged() + { + // A method with no IDurableContext parameter is not workflow code. + var source = Usings + @" +class W +{ + public DateTime Helper() => DateTime.Now; +}"; + await Verify.VerifyAsync(source); + } + + [Fact] + public async Task DateTimeNow_InsideChildContextBranch_IsFlagged() + { + // A child-context body is still workflow code that must be deterministic. + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + await context.RunInChildContextAsync(async (child, ct) => + { + var now = {|#0:DateTime.Now|}; + await child.StepAsync((s, c) => Task.FromResult(now)); + }); + } +}"; + var expected = Verify.Diagnostic(DurableDiagnostics.NonDeterministicCallOutsideStep) + .WithLocation(0).WithArguments("DateTime.Now"); + await Verify.VerifyAsync(source, expected); + } + + [Fact] + public async Task UnrelatedStepMethodOnOtherType_DoesNotSuppress() + { + // Semantic matching: an unrelated type's StepAsync must NOT be treated as a durable step, + // so it does not suppress non-determinism. DateTime.Now nested in it is still workflow code + // outside any real durable step, so it IS flagged. + var source = Usings + @" +class NotDurable { public Task StepAsync(Func> f) => f(); } +class W +{ + public async Task Run(string input, IDurableContext context) + { + var other = new NotDurable(); + await other.StepAsync(() => Task.FromResult({|#0:DateTime.Now|})); + await context.StepAsync((s, ct) => Task.CompletedTask); + } +}"; + var expected = Verify.Diagnostic(DurableDiagnostics.NonDeterministicCallOutsideStep) + .WithLocation(0).WithArguments("DateTime.Now"); + await Verify.VerifyAsync(source, expected); + } + + [Fact] + public async Task UnrelatedTypeProducesNoDiagnostic_OutsideWorkflow() + { + // The same unrelated StepAsync, but in a method with no IDurableContext — not workflow code. + var source = Usings + @" +class NotDurable { public Task StepAsync(Func> f) => f(); } +class W +{ + public async Task Helper() + { + var other = new NotDurable(); + await other.StepAsync(() => Task.FromResult(DateTime.Now)); + } +}"; + await Verify.VerifyAsync(source); + } + } +} diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/NonDeterministicCallCodeFixTests.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/NonDeterministicCallCodeFixTests.cs new file mode 100644 index 000000000..ccedd3171 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/NonDeterministicCallCodeFixTests.cs @@ -0,0 +1,48 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.Testing; +using Xunit; +using Verify = Amazon.Lambda.DurableExecution.Analyzers.Tests.CodeFixVerifier< + Amazon.Lambda.DurableExecution.Analyzers.NonDeterministicCallAnalyzer, + Amazon.Lambda.DurableExecution.Analyzers.NonDeterministicCallCodeFixProvider>; + +namespace Amazon.Lambda.DurableExecution.Analyzers.Tests +{ + public class NonDeterministicCallCodeFixTests + { + private const string Usings = @" +using System; +using System.Threading; +using System.Threading.Tasks; +using Amazon.Lambda.DurableExecution; +"; + + [Fact] + public async Task WrapsDateTimeNow_InStepAsync() + { + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + var now = {|#0:DateTime.UtcNow|}; + return now; + } +}"; + var fixedSource = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + var now = await context.StepAsync((_, __) => System.Threading.Tasks.Task.FromResult(DateTime.UtcNow)); + return now; + } +}"; + var expected = Verify.Diagnostic(DurableDiagnostics.NonDeterministicCallOutsideStep) + .WithLocation(0).WithArguments("DateTime.UtcNow"); + await Verify.VerifyAsync(source, fixedSource, expected); + } + } +} diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/Verifiers.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/Verifiers.cs new file mode 100644 index 000000000..4e5e77810 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/Verifiers.cs @@ -0,0 +1,74 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Testing; +using Microsoft.CodeAnalysis.Testing.Verifiers; + +namespace Amazon.Lambda.DurableExecution.Analyzers.Tests +{ + /// + /// Analyzer-only verifier that injects the durable stub surface into every test compilation and + /// targets the net8.0 reference assemblies. + /// + internal static class AnalyzerVerifier + where TAnalyzer : DiagnosticAnalyzer, new() + { + internal static DiagnosticResult Diagnostic(DiagnosticDescriptor descriptor) => + new DiagnosticResult(descriptor); + + internal static Task VerifyAsync(string source, params DiagnosticResult[] expected) + { + var test = new Test { TestCode = source }; + test.ExpectedDiagnostics.AddRange(expected); + return test.RunAsync(CancellationToken.None); + } + + private sealed class Test : CSharpAnalyzerTest + { + public Test() + { + ReferenceAssemblies = ReferenceAssemblies.Net.Net80; + TestState.Sources.Add(DurableStubs.Source); + } + } + } + + /// + /// Code-fix verifier mirroring with the stub injected + /// into both the pre-fix and post-fix compilations. + /// + internal static class CodeFixVerifier + where TAnalyzer : DiagnosticAnalyzer, new() + where TCodeFix : CodeFixProvider, new() + { + internal static DiagnosticResult Diagnostic(DiagnosticDescriptor descriptor) => + new DiagnosticResult(descriptor); + + internal static Task VerifyAsync(string source, string fixedSource, params DiagnosticResult[] expected) + { + var test = new Test + { + TestCode = source, + FixedCode = fixedSource, + }; + test.ExpectedDiagnostics.AddRange(expected); + return test.RunAsync(CancellationToken.None); + } + + private sealed class Test : CSharpCodeFixTest + { + public Test() + { + ReferenceAssemblies = ReferenceAssemblies.Net.Net80; + TestState.Sources.Add(DurableStubs.Source); + FixedState.Sources.Add(DurableStubs.Source); + } + } + } +}