From 5602af60abe0ee4ab52e1e374eeb5f3b3c26035f Mon Sep 17 00:00:00 2001 From: Garrett Beatty Date: Tue, 7 Jul 2026 16:19:15 -0400 Subject: [PATCH] Add AWSLambda0145 diagnostic for unregistered durable envelope types When a [DurableExecution] function registers the source-generator serializer (SourceGeneratorLambdaJsonSerializer), the durable invocation envelope types DurableExecutionInvocationInput and DurableExecutionInvocationOutput must be registered on the JsonSerializerContext with [JsonSerializable]. Unlike the reflection-based DefaultLambdaJsonSerializer, the context only serializes types explicitly registered, so a missing registration fails only at invocation time. The generator now emits AWSLambda0145 (Warning) at build time when either envelope type is missing. --- ...durable-serializer-context-diagnostic.json | 11 ++ .../Diagnostics/AnalyzerReleases.Unshipped.md | 1 + .../Diagnostics/DiagnosticDescriptors.cs | 7 + .../TypeFullNames.cs | 6 + .../Validation/LambdaFunctionValidator.cs | 73 +++++++++ ...cutionSerializerContextDiagnosticsTests.cs | 155 ++++++++++++++++++ 6 files changed, 253 insertions(+) create mode 100644 .autover/changes/durable-serializer-context-diagnostic.json create mode 100644 Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/DurableExecutionSerializerContextDiagnosticsTests.cs diff --git a/.autover/changes/durable-serializer-context-diagnostic.json b/.autover/changes/durable-serializer-context-diagnostic.json new file mode 100644 index 000000000..49f888f55 --- /dev/null +++ b/.autover/changes/durable-serializer-context-diagnostic.json @@ -0,0 +1,11 @@ +{ + "Projects": [ + { + "Name": "Amazon.Lambda.Annotations", + "Type": "Minor", + "ChangelogMessages": [ + "Add diagnostic AWSLambda0145 (Warning) for durable execution functions. When a [DurableExecution] function registers the source-generator serializer (SourceGeneratorLambdaJsonSerializer), the durable invocation envelope types DurableExecutionInvocationInput and DurableExecutionInvocationOutput must be registered on the JsonSerializerContext with [JsonSerializable]. The generator now warns at build time when either is missing, instead of the function failing at invocation time. Preview." + ] + } + ] +} diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/AnalyzerReleases.Unshipped.md b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/AnalyzerReleases.Unshipped.md index 14ac0721d..ffb739ddf 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/AnalyzerReleases.Unshipped.md +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/AnalyzerReleases.Unshipped.md @@ -27,3 +27,4 @@ AWSLambda0139 | AWSLambdaCSharpGenerator | Error | Invalid ScheduleEventAttribut AWSLambda0142 | AWSLambdaCSharpGenerator | Error | Invalid DurableExecution method signature AWSLambda0143 | AWSLambdaCSharpGenerator | Info | DurableExecution function with explicit Role needs checkpoint permissions AWSLambda0144 | AWSLambdaCSharpGenerator | Error | Invalid DurableExecutionAttribute +AWSLambda0145 | AWSLambdaCSharpGenerator | Warning | Durable execution envelope types not registered with JsonSerializerContext diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/DiagnosticDescriptors.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/DiagnosticDescriptors.cs index fab62c579..cc40ce60a 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/DiagnosticDescriptors.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/DiagnosticDescriptors.cs @@ -323,5 +323,12 @@ public static class DiagnosticDescriptors category: "AWSLambdaCSharpGenerator", DiagnosticSeverity.Error, isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor DurableExecutionMissingSerializableEnvelope = new DiagnosticDescriptor(id: "AWSLambda0145", + title: "Durable execution envelope types not registered with JsonSerializerContext", + messageFormat: "The [DurableExecution] function uses SourceGeneratorLambdaJsonSerializer<{0}>, but {0} does not register {1} with a [JsonSerializable] attribute. The durable runtime serializes the invocation envelope with this context, so add [JsonSerializable(typeof({1}))] to {0} or the function will fail at invocation time.", + category: "AWSLambdaCSharpGenerator", + DiagnosticSeverity.Warning, + isEnabledByDefault: true); } } diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs index 59244ae83..6662f0c94 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs @@ -80,6 +80,12 @@ public static class TypeFullNames public const string LambdaSerializerAttribute = "Amazon.Lambda.Core.LambdaSerializerAttribute"; public const string DefaultLambdaSerializer = "Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer"; + // The source-generator serializer is generic: SourceGeneratorLambdaJsonSerializer where + // TContext is the user's JsonSerializerContext. Compared against INamedTypeSymbol.ConstructedFrom by + // metadata name (the `1 arity suffix is part of the metadata name for a generic type). + public const string SourceGeneratorLambdaSerializer = "Amazon.Lambda.Serialization.SystemTextJson.SourceGeneratorLambdaJsonSerializer`1"; + public const string JsonSerializableAttribute = "System.Text.Json.Serialization.JsonSerializableAttribute"; + public const string LambdaSerializerAttributeWithoutNamespace = "LambdaSerializerAttribute"; public static HashSet ApiGatewayRequests = new HashSet diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs index 188956d03..b580f2466 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs @@ -118,6 +118,79 @@ private static void ValidateDurableExecution(GeneratorExecutionContext context, { diagnostics.Add(Diagnostic.Create(DiagnosticDescriptors.DurableExecutionExplicitRoleNeedsCheckpointPolicy, methodLocation)); } + + // When the registered serializer is the source-generator serializer, the durable invocation + // envelope (DurableExecutionInvocationInput / DurableExecutionInvocationOutput) is (de)serialized + // through the user's JsonSerializerContext. Unlike the reflection-based DefaultLambdaJsonSerializer, + // the context only handles types explicitly registered via [JsonSerializable]. If the envelope types + // are missing, the failure only surfaces at invocation time, so warn about it at build time here. + ValidateDurableExecutionSerializerContext(context, lambdaMethodSymbol, methodLocation, diagnostics); + } + + private static void ValidateDurableExecutionSerializerContext(GeneratorExecutionContext context, IMethodSymbol lambdaMethodSymbol, Location methodLocation, List diagnostics) + { + // The serializer is registered via [assembly: LambdaSerializer(typeof(...))] or the same attribute on + // the method. The method-level attribute takes precedence, matching GetSerializerInfoAttribute. + var serializerAttribute = lambdaMethodSymbol.GetAttributeData(context, TypeFullNames.LambdaSerializerAttribute) + ?? lambdaMethodSymbol.ContainingAssembly.GetAttributeData(context, TypeFullNames.LambdaSerializerAttribute); + if (serializerAttribute == null) + { + return; + } + + // The single constructor argument is the serializer Type. Only the source-generator serializer, + // SourceGeneratorLambdaJsonSerializer, routes serialization through a JsonSerializerContext. + // Any other serializer (e.g. the reflection-based DefaultLambdaJsonSerializer) needs no registration. + if (!(serializerAttribute.ConstructorArguments.FirstOrDefault(arg => arg.Kind == TypedConstantKind.Type).Value is INamedTypeSymbol serializerType)) + { + return; + } + + var sourceGeneratorSerializerSymbol = context.Compilation.GetTypeByMetadataName(TypeFullNames.SourceGeneratorLambdaSerializer); + if (sourceGeneratorSerializerSymbol == null + || !SymbolEqualityComparer.Default.Equals(serializerType.ConstructedFrom?.OriginalDefinition, sourceGeneratorSerializerSymbol) + || serializerType.TypeArguments.Length != 1 + || !(serializerType.TypeArguments[0] is INamedTypeSymbol contextType)) + { + return; + } + + // Collect every type registered on the context via [JsonSerializable(typeof(T))], walking base + // contexts too since [JsonSerializable] attributes are inherited across a context hierarchy. + var jsonSerializableAttributeSymbol = context.Compilation.GetTypeByMetadataName(TypeFullNames.JsonSerializableAttribute); + var registeredTypes = new HashSet(); + for (var current = contextType; current != null; current = current.BaseType) + { + foreach (var att in current.GetAttributes()) + { + if (!SymbolEqualityComparer.Default.Equals(att.AttributeClass, jsonSerializableAttributeSymbol)) + { + continue; + } + + if (att.ConstructorArguments.FirstOrDefault(arg => arg.Kind == TypedConstantKind.Type).Value is INamedTypeSymbol registeredType) + { + registeredTypes.Add(registeredType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)); + } + } + } + + var contextDisplayName = contextType.ToDisplayString(); + foreach (var envelopeTypeName in new[] { TypeFullNames.DurableExecutionInvocationInput, TypeFullNames.DurableExecutionInvocationOutput }) + { + var envelopeTypeSymbol = context.Compilation.GetTypeByMetadataName(envelopeTypeName); + // If the durable envelope type cannot be resolved the durable package is not referenced; other + // diagnostics/compile errors will surface that, so skip rather than emit a misleading warning. + if (envelopeTypeSymbol == null) + { + continue; + } + + if (!registeredTypes.Contains(envelopeTypeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat))) + { + diagnostics.Add(Diagnostic.Create(DiagnosticDescriptors.DurableExecutionMissingSerializableEnvelope, methodLocation, contextDisplayName, envelopeTypeName)); + } + } } private static void ValidateDurableExecutionSignature(IMethodSymbol lambdaMethodSymbol, LambdaFunctionModel lambdaFunctionModel, Location methodLocation, List diagnostics) diff --git a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/DurableExecutionSerializerContextDiagnosticsTests.cs b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/DurableExecutionSerializerContextDiagnosticsTests.cs new file mode 100644 index 000000000..4d984a471 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/DurableExecutionSerializerContextDiagnosticsTests.cs @@ -0,0 +1,155 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.IO; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Testing; +using Xunit; +using VerifyCS = Amazon.Lambda.Annotations.SourceGenerators.Tests.CSharpSourceGeneratorVerifier; + +namespace Amazon.Lambda.Annotations.SourceGenerators.Tests +{ + /// + /// Tests for AWSLambda0145: when a [DurableExecution] function registers the source-generator + /// serializer (SourceGeneratorLambdaJsonSerializer<TContext>), the durable invocation envelope + /// types must be registered on TContext with [JsonSerializable], otherwise serialization fails at + /// invocation time. + /// + public class DurableExecutionSerializerContextDiagnosticsTests + { + // Minimal durable SDK stubs. The real Amazon.Lambda.DurableExecution package cannot be referenced + // by this test project (its AWSSDK.Core 4.x conflicts with the 3.7.x pin required by the generator + // test framework), so the types the generator resolves by metadata name are supplied as source. + private const string DurableStubs = @" +namespace Amazon.Lambda.DurableExecution +{ + public interface IDurableContext { } + public sealed class DurableExecutionInvocationInput { } + public sealed class DurableExecutionInvocationOutput { } +} +"; + + private static async Task AnnotationsSource(string fileName) => + await File.ReadAllTextAsync(Path.Combine("Amazon.Lambda.Annotations", fileName)); + + // The user source registers the serializer itself (via [assembly: LambdaSerializer]) so the default + // DefaultLambdaJsonSerializer registration is intentionally not added here. + private static async Task NewTestAsync(string userSource) + { + var test = new VerifyCS.Test + { + TestState = + { + OutputKind = OutputKind.ConsoleApplication, + Sources = + { + ("Workflow.cs", userSource), + ("DurableStubs.cs", DurableStubs), + }, + } + }; + test.TestState.Sources.Add((Path.Combine("Amazon.Lambda.Annotations", "LambdaFunctionAttribute.cs"), await AnnotationsSource("LambdaFunctionAttribute.cs"))); + test.TestState.Sources.Add((Path.Combine("Amazon.Lambda.Annotations", "DurableExecutionAttribute.cs"), await AnnotationsSource("DurableExecutionAttribute.cs"))); + + // Generation proceeds (Warning severity does not halt it); ignore the per-file codegen Info + // (AWSLambda0103), the generated-source list, and compiler errors from the deliberately + // unimplemented JsonSerializerContext (only the generator's symbol resolution matters here). + test.DisabledDiagnostics.Add("AWSLambda0103"); + test.TestBehaviors |= TestBehaviors.SkipGeneratedSourcesCheck; + test.CompilerDiagnostics = CompilerDiagnostics.None; + return test; + } + + // Builds a workflow that registers SourceGeneratorLambdaJsonSerializer, where MyContext + // registers whichever envelope types are passed in via extraJsonSerializable. + private static string WorkflowSource(string extraJsonSerializable) => $@" +using System.Threading.Tasks; +using System.Text.Json.Serialization; +using Amazon.Lambda.Annotations; +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; +using Amazon.Lambda.Serialization.SystemTextJson; + +[assembly: LambdaSerializer(typeof(SourceGeneratorLambdaJsonSerializer))] + +namespace MyApp +{{ +{extraJsonSerializable} + public partial class MyContext : JsonSerializerContext {{ }} + + public class Workflows + {{ + [LambdaFunction] + [DurableExecution(executionTimeout: 300)] + public Task Run(string input, IDurableContext ctx) => Task.FromResult(input); + }} +}}"; + + [Fact] + public async Task BothEnvelopeTypesRegistered_NoDiagnostic() + { + var source = WorkflowSource( + " [JsonSerializable(typeof(DurableExecutionInvocationInput))]\n" + + " [JsonSerializable(typeof(DurableExecutionInvocationOutput))]"); + var test = await NewTestAsync(source); + // No AWSLambda0145 expected. + await test.RunAsync(); + } + + [Fact] + public async Task OutputEnvelopeTypeMissing_ReportsWarning() + { + var source = WorkflowSource( + " [JsonSerializable(typeof(DurableExecutionInvocationInput))]"); + var test = await NewTestAsync(source); + test.TestState.ExpectedDiagnostics.Add( + new DiagnosticResult("AWSLambda0145", DiagnosticSeverity.Warning) + .WithSpan("Workflow.cs", 18, 9, 20, 94) + .WithArguments("MyApp.MyContext", "Amazon.Lambda.DurableExecution.DurableExecutionInvocationOutput")); + await test.RunAsync(); + } + + [Fact] + public async Task BothEnvelopeTypesMissing_ReportsTwoWarnings() + { + var source = WorkflowSource(string.Empty); + var test = await NewTestAsync(source); + test.TestState.ExpectedDiagnostics.Add( + new DiagnosticResult("AWSLambda0145", DiagnosticSeverity.Warning) + .WithSpan("Workflow.cs", 18, 9, 20, 94) + .WithArguments("MyApp.MyContext", "Amazon.Lambda.DurableExecution.DurableExecutionInvocationInput")); + test.TestState.ExpectedDiagnostics.Add( + new DiagnosticResult("AWSLambda0145", DiagnosticSeverity.Warning) + .WithSpan("Workflow.cs", 18, 9, 20, 94) + .WithArguments("MyApp.MyContext", "Amazon.Lambda.DurableExecution.DurableExecutionInvocationOutput")); + await test.RunAsync(); + } + + [Fact] + public async Task DefaultSerializer_NoDiagnostic() + { + // The reflection-based DefaultLambdaJsonSerializer needs no [JsonSerializable] registration, + // so the durable envelope check does not apply and AWSLambda0145 is never emitted. + var source = @" +using System.Threading.Tasks; +using Amazon.Lambda.Annotations; +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +[assembly: LambdaSerializer(typeof(Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer))] + +namespace MyApp +{ + public class Workflows + { + [LambdaFunction] + [DurableExecution(executionTimeout: 300)] + public Task Run(string input, IDurableContext ctx) => Task.FromResult(input); + } +}"; + var test = await NewTestAsync(source); + await test.RunAsync(); + } + } +}