From 0cdb15916b475b92379c84cab06da4c6383b9a61 Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Mon, 16 Feb 2026 11:51:17 -0800 Subject: [PATCH 01/20] Task 4 --- .../Client/InvocationResponse.cs | 26 ++++++ .../InvocationResponseTests.cs | 81 +++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/InvocationResponseTests.cs diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/InvocationResponse.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/InvocationResponse.cs index 1894b0521..4438c9708 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/InvocationResponse.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/InvocationResponse.cs @@ -34,6 +34,18 @@ public class InvocationResponse /// public bool DisposeOutputStream { get; private set; } = true; + /// + /// Indicates whether this response uses streaming mode. + /// Set internally by the runtime when ResponseStreamFactory.CreateStream() is called. + /// + internal bool IsStreaming { get; set; } + + /// + /// The ResponseStream instance if streaming mode is used. + /// Set internally by the runtime. + /// + internal ResponseStream ResponseStream { get; set; } + /// /// Construct a InvocationResponse with an output stream that will be disposed by the Lambda Runtime Client. /// @@ -52,6 +64,20 @@ public InvocationResponse(Stream outputStream, bool disposeOutputStream) { OutputStream = outputStream ?? throw new ArgumentNullException(nameof(outputStream)); DisposeOutputStream = disposeOutputStream; + IsStreaming = false; + } + + /// + /// Creates an InvocationResponse for a streaming response. + /// Used internally by the runtime. + /// + internal static InvocationResponse CreateStreamingResponse(ResponseStream responseStream) + { + return new InvocationResponse(Stream.Null, false) + { + IsStreaming = true, + ResponseStream = responseStream + }; } } } diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/InvocationResponseTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/InvocationResponseTests.cs new file mode 100644 index 000000000..703ac0cd9 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/InvocationResponseTests.cs @@ -0,0 +1,81 @@ +/* + * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +using System.IO; +using Xunit; + +namespace Amazon.Lambda.RuntimeSupport.UnitTests +{ + public class InvocationResponseTests + { + private const long MaxResponseSize = 20 * 1024 * 1024; + + /// + /// Property 17: InvocationResponse Streaming Flag - Existing constructors set IsStreaming to false. + /// Validates: Requirements 7.3, 8.1 + /// + [Fact] + public void Constructor_WithStream_IsStreamingIsFalse() + { + var response = new InvocationResponse(new MemoryStream()); + + Assert.False(response.IsStreaming); + Assert.Null(response.ResponseStream); + } + + [Fact] + public void Constructor_WithStreamAndDispose_IsStreamingIsFalse() + { + var response = new InvocationResponse(new MemoryStream(), false); + + Assert.False(response.IsStreaming); + Assert.Null(response.ResponseStream); + } + + /// + /// Property 17: InvocationResponse Streaming Flag - CreateStreamingResponse sets IsStreaming to true. + /// Validates: Requirements 7.3, 8.1 + /// + [Fact] + public void CreateStreamingResponse_SetsIsStreamingTrue() + { + var stream = new ResponseStream(MaxResponseSize); + + var response = InvocationResponse.CreateStreamingResponse(stream); + + Assert.True(response.IsStreaming); + } + + [Fact] + public void CreateStreamingResponse_SetsResponseStream() + { + var stream = new ResponseStream(MaxResponseSize); + + var response = InvocationResponse.CreateStreamingResponse(stream); + + Assert.Same(stream, response.ResponseStream); + } + + [Fact] + public void CreateStreamingResponse_DoesNotDisposeOutputStream() + { + var stream = new ResponseStream(MaxResponseSize); + + var response = InvocationResponse.CreateStreamingResponse(stream); + + Assert.False(response.DisposeOutputStream); + } + } +} From 0aa892be011d2f10b774e3ff7cf1eaedbc2669ea Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Mon, 16 Feb 2026 11:58:24 -0800 Subject: [PATCH 02/20] Task 5 --- .../Client/StreamingHttpContent.cs | 95 +++++++ .../StreamingHttpContentTests.cs | 264 ++++++++++++++++++ 2 files changed, 359 insertions(+) create mode 100644 Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingHttpContent.cs create mode 100644 Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingHttpContentTests.cs diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingHttpContent.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingHttpContent.cs new file mode 100644 index 000000000..c853ed5dd --- /dev/null +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingHttpContent.cs @@ -0,0 +1,95 @@ +/* + * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +using System; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; + +namespace Amazon.Lambda.RuntimeSupport +{ + /// + /// HttpContent implementation for streaming responses with chunked transfer encoding. + /// + internal class StreamingHttpContent : HttpContent + { + private static readonly byte[] CrlfBytes = Encoding.ASCII.GetBytes("\r\n"); + private static readonly byte[] FinalChunkBytes = Encoding.ASCII.GetBytes("0\r\n"); + + private readonly ResponseStream _responseStream; + + public StreamingHttpContent(ResponseStream responseStream) + { + _responseStream = responseStream ?? throw new ArgumentNullException(nameof(responseStream)); + } + + protected override async Task SerializeToStreamAsync(Stream stream, TransportContext context) + { + foreach (var chunk in _responseStream.Chunks) + { + await WriteChunkAsync(stream, chunk); + } + + await WriteFinalChunkAsync(stream); + + if (_responseStream.HasError) + { + await WriteErrorTrailersAsync(stream, _responseStream.ReportedError); + } + + await stream.FlushAsync(); + } + + protected override bool TryComputeLength(out long length) + { + length = -1; + return false; + } + + private async Task WriteChunkAsync(Stream stream, byte[] data) + { + var chunkSizeHex = data.Length.ToString("X"); + var chunkSizeBytes = Encoding.ASCII.GetBytes(chunkSizeHex); + + await stream.WriteAsync(chunkSizeBytes, 0, chunkSizeBytes.Length); + await stream.WriteAsync(CrlfBytes, 0, CrlfBytes.Length); + await stream.WriteAsync(data, 0, data.Length); + await stream.WriteAsync(CrlfBytes, 0, CrlfBytes.Length); + } + + private async Task WriteFinalChunkAsync(Stream stream) + { + await stream.WriteAsync(FinalChunkBytes, 0, FinalChunkBytes.Length); + } + + private async Task WriteErrorTrailersAsync(Stream stream, Exception exception) + { + var exceptionInfo = ExceptionInfo.GetExceptionInfo(exception); + + var errorTypeHeader = $"{StreamingConstants.ErrorTypeTrailer}: {exceptionInfo.ErrorType}\r\n"; + var errorTypeBytes = Encoding.UTF8.GetBytes(errorTypeHeader); + await stream.WriteAsync(errorTypeBytes, 0, errorTypeBytes.Length); + + var errorBodyJson = LambdaJsonExceptionWriter.WriteJson(exceptionInfo); + var errorBodyHeader = $"{StreamingConstants.ErrorBodyTrailer}: {errorBodyJson}\r\n"; + var errorBodyBytes = Encoding.UTF8.GetBytes(errorBodyHeader); + await stream.WriteAsync(errorBodyBytes, 0, errorBodyBytes.Length); + + await stream.WriteAsync(CrlfBytes, 0, CrlfBytes.Length); + } + } +} diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingHttpContentTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingHttpContentTests.cs new file mode 100644 index 000000000..0682a816e --- /dev/null +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingHttpContentTests.cs @@ -0,0 +1,264 @@ +/* + * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +using System; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using Xunit; + +namespace Amazon.Lambda.RuntimeSupport.UnitTests +{ + public class StreamingHttpContentTests + { + private const long MaxResponseSize = 20 * 1024 * 1024; + + private async Task SerializeContentAsync(StreamingHttpContent content) + { + using var ms = new MemoryStream(); + await content.CopyToAsync(ms); + return ms.ToArray(); + } + + // --- Task 5.4: Chunked encoding format tests --- + + /// + /// Property 9: Chunked Encoding Format - chunks are formatted as size(hex) + CRLF + data + CRLF. + /// Validates: Requirements 4.3, 10.1, 10.2 + /// + [Theory] + [InlineData(1)] + [InlineData(10)] + [InlineData(255)] + [InlineData(4096)] + public async Task ChunkedEncoding_SingleChunk_CorrectFormat(int chunkSize) + { + var stream = new ResponseStream(MaxResponseSize); + var data = new byte[chunkSize]; + for (int i = 0; i < data.Length; i++) data[i] = (byte)(i % 256); + await stream.WriteAsync(data); + + var content = new StreamingHttpContent(stream); + var output = await SerializeContentAsync(content); + var outputStr = Encoding.ASCII.GetString(output); + + var expectedSizeHex = chunkSize.ToString("X"); + Assert.StartsWith(expectedSizeHex + "\r\n", outputStr); + + // Verify chunk data follows the size line + var dataStart = expectedSizeHex.Length + 2; // size + CRLF + for (int i = 0; i < chunkSize; i++) + { + Assert.Equal(data[i], output[dataStart + i]); + } + + // Verify CRLF after data + Assert.Equal((byte)'\r', output[dataStart + chunkSize]); + Assert.Equal((byte)'\n', output[dataStart + chunkSize + 1]); + } + + /// + /// Property 9: Chunked Encoding Format - multiple chunks each formatted correctly. + /// Validates: Requirements 4.3, 10.1 + /// + [Fact] + public async Task ChunkedEncoding_MultipleChunks_EachFormattedCorrectly() + { + var stream = new ResponseStream(MaxResponseSize); + await stream.WriteAsync(new byte[] { 0xAA, 0xBB }); + await stream.WriteAsync(new byte[] { 0xCC }); + + var content = new StreamingHttpContent(stream); + var output = await SerializeContentAsync(content); + var outputStr = Encoding.ASCII.GetString(output); + + // First chunk: "2\r\n" + 2 bytes + "\r\n" + Assert.StartsWith("2\r\n", outputStr); + Assert.Equal(0xAA, output[3]); + Assert.Equal(0xBB, output[4]); + Assert.Equal((byte)'\r', output[5]); + Assert.Equal((byte)'\n', output[6]); + + // Second chunk: "1\r\n" + 1 byte + "\r\n" + Assert.Equal((byte)'1', output[7]); + Assert.Equal((byte)'\r', output[8]); + Assert.Equal((byte)'\n', output[9]); + Assert.Equal(0xCC, output[10]); + Assert.Equal((byte)'\r', output[11]); + Assert.Equal((byte)'\n', output[12]); + } + + /// + /// Property 20: Final Chunk Termination - final chunk "0\r\n" is written. + /// Validates: Requirements 10.2, 10.5 + /// + [Fact] + public async Task FinalChunk_IsWritten() + { + var stream = new ResponseStream(MaxResponseSize); + await stream.WriteAsync(new byte[] { 1 }); + + var content = new StreamingHttpContent(stream); + var output = await SerializeContentAsync(content); + var outputStr = Encoding.ASCII.GetString(output); + + // Output should end with final chunk "0\r\n" + Assert.EndsWith("0\r\n", outputStr); + } + + [Fact] + public async Task FinalChunk_EmptyStream_OnlyFinalChunk() + { + var stream = new ResponseStream(MaxResponseSize); + + var content = new StreamingHttpContent(stream); + var output = await SerializeContentAsync(content); + + Assert.Equal(Encoding.ASCII.GetBytes("0\r\n"), output); + } + + /// + /// Property 22: CRLF Line Terminators - all line terminators are CRLF, not just LF. + /// Validates: Requirements 10.5 + /// + [Fact] + public async Task CrlfTerminators_NoBareLineFeed() + { + var stream = new ResponseStream(MaxResponseSize); + await stream.WriteAsync(new byte[] { 65, 66, 67 }); // "ABC" + + var content = new StreamingHttpContent(stream); + var output = await SerializeContentAsync(content); + + // Check every \n is preceded by \r + for (int i = 0; i < output.Length; i++) + { + if (output[i] == (byte)'\n') + { + Assert.True(i > 0 && output[i - 1] == (byte)'\r', + $"Found bare LF at position {i} without preceding CR"); + } + } + } + + [Fact] + public void TryComputeLength_ReturnsFalse() + { + var stream = new ResponseStream(MaxResponseSize); + var content = new StreamingHttpContent(stream); + + var result = content.Headers.ContentLength; + + Assert.Null(result); + } + + // --- Task 5.6: Error trailer tests --- + + /// + /// Property 11: Midstream Error Type Trailer - error type trailer is included for various exception types. + /// Validates: Requirements 5.1, 5.2 + /// + [Theory] + [InlineData(typeof(InvalidOperationException))] + [InlineData(typeof(ArgumentException))] + [InlineData(typeof(NullReferenceException))] + public async Task ErrorTrailer_IncludesErrorType(Type exceptionType) + { + var stream = new ResponseStream(MaxResponseSize); + await stream.WriteAsync(new byte[] { 1 }); + var exception = (Exception)Activator.CreateInstance(exceptionType, "test error"); + await stream.ReportErrorAsync(exception); + + var content = new StreamingHttpContent(stream); + var output = await SerializeContentAsync(content); + var outputStr = Encoding.UTF8.GetString(output); + + Assert.Contains($"Lambda-Runtime-Function-Error-Type: {exceptionType.Name}", outputStr); + } + + /// + /// Property 12: Midstream Error Body Trailer - error body trailer includes JSON exception details. + /// Validates: Requirements 5.3 + /// + [Fact] + public async Task ErrorTrailer_IncludesJsonErrorBody() + { + var stream = new ResponseStream(MaxResponseSize); + await stream.WriteAsync(new byte[] { 1 }); + await stream.ReportErrorAsync(new InvalidOperationException("something went wrong")); + + var content = new StreamingHttpContent(stream); + var output = await SerializeContentAsync(content); + var outputStr = Encoding.UTF8.GetString(output); + + Assert.Contains("Lambda-Runtime-Function-Error-Body:", outputStr); + Assert.Contains("something went wrong", outputStr); + Assert.Contains("InvalidOperationException", outputStr); + } + + /// + /// Property 21: Trailer Ordering - trailers appear after final chunk. + /// Validates: Requirements 10.3 + /// + [Fact] + public async Task ErrorTrailers_AppearAfterFinalChunk() + { + var stream = new ResponseStream(MaxResponseSize); + await stream.WriteAsync(new byte[] { 1 }); + await stream.ReportErrorAsync(new Exception("fail")); + + var content = new StreamingHttpContent(stream); + var output = await SerializeContentAsync(content); + var outputStr = Encoding.UTF8.GetString(output); + + var finalChunkIndex = outputStr.IndexOf("0\r\n"); + var errorTypeIndex = outputStr.IndexOf("Lambda-Runtime-Function-Error-Type:"); + var errorBodyIndex = outputStr.IndexOf("Lambda-Runtime-Function-Error-Body:"); + + Assert.True(finalChunkIndex >= 0, "Final chunk not found"); + Assert.True(errorTypeIndex > finalChunkIndex, "Error type trailer should appear after final chunk"); + Assert.True(errorBodyIndex > finalChunkIndex, "Error body trailer should appear after final chunk"); + } + + [Fact] + public async Task NoError_NoTrailersWritten() + { + var stream = new ResponseStream(MaxResponseSize); + await stream.WriteAsync(new byte[] { 1 }); + + var content = new StreamingHttpContent(stream); + var output = await SerializeContentAsync(content); + var outputStr = Encoding.UTF8.GetString(output); + + Assert.DoesNotContain("Lambda-Runtime-Function-Error-Type:", outputStr); + Assert.DoesNotContain("Lambda-Runtime-Function-Error-Body:", outputStr); + } + + [Fact] + public async Task ErrorTrailers_EndWithCrlf() + { + var stream = new ResponseStream(MaxResponseSize); + await stream.WriteAsync(new byte[] { 1 }); + await stream.ReportErrorAsync(new Exception("fail")); + + var content = new StreamingHttpContent(stream); + var output = await SerializeContentAsync(content); + var outputStr = Encoding.UTF8.GetString(output); + + // Should end with final CRLF after trailers + Assert.EndsWith("\r\n", outputStr); + } + } +} From 5e29c2141e59813b05ecc93578badd2a13e8227c Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Mon, 16 Feb 2026 18:05:11 -0800 Subject: [PATCH 03/20] Task 4 (after redesign) --- .../Client/ResponseStream.cs | 123 +++--- .../Client/ResponseStreamContext.cs | 19 + .../Client/StreamingHttpContent.cs | 34 +- .../ResponseStreamTests.cs | 268 +++++++++++-- .../StreamingHttpContentTests.cs | 353 +++++++++++------- 5 files changed, 546 insertions(+), 251 deletions(-) diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ResponseStream.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ResponseStream.cs index 1484d1f8d..00f63cf75 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ResponseStream.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ResponseStream.cs @@ -14,62 +14,70 @@ */ using System; -using System.Collections.Generic; -using System.Linq; +using System.IO; +using System.Text; using System.Threading; using System.Threading.Tasks; namespace Amazon.Lambda.RuntimeSupport { /// - /// Internal implementation of IResponseStream. - /// Buffers written data as chunks for HTTP chunked transfer encoding. + /// Internal implementation of IResponseStream with true streaming. + /// Writes data directly to the HTTP output stream as chunked transfer encoding. /// internal class ResponseStream : IResponseStream { + private static readonly byte[] CrlfBytes = Encoding.ASCII.GetBytes("\r\n"); + private readonly long _maxResponseSize; - private readonly List _chunks; private long _bytesWritten; private bool _isCompleted; private bool _hasError; private Exception _reportedError; private readonly object _lock = new object(); + // The live HTTP output stream, set by StreamingHttpContent when SerializeToStreamAsync is called. + private Stream _httpOutputStream; + private readonly SemaphoreSlim _httpStreamReady = new SemaphoreSlim(0, 1); + private readonly SemaphoreSlim _completionSignal = new SemaphoreSlim(0, 1); + public long BytesWritten => _bytesWritten; public bool IsCompleted => _isCompleted; public bool HasError => _hasError; + internal Exception ReportedError => _reportedError; - internal IReadOnlyList Chunks + public ResponseStream(long maxResponseSize) { - get - { - lock (_lock) - { - return _chunks.ToList(); - } - } + _maxResponseSize = maxResponseSize; } - internal Exception ReportedError => _reportedError; + /// + /// Called by StreamingHttpContent.SerializeToStreamAsync to provide the HTTP output stream. + /// + internal void SetHttpOutputStream(Stream httpOutputStream) + { + _httpOutputStream = httpOutputStream; + _httpStreamReady.Release(); + } - public ResponseStream(long maxResponseSize) + /// + /// Called by StreamingHttpContent.SerializeToStreamAsync to wait until the handler + /// finishes writing (MarkCompleted or ReportErrorAsync). + /// + internal async Task WaitForCompletionAsync() { - _maxResponseSize = maxResponseSize; - _chunks = new List(); - _bytesWritten = 0; - _isCompleted = false; - _hasError = false; + await _completionSignal.WaitAsync(); } - public Task WriteAsync(byte[] buffer, CancellationToken cancellationToken = default) + public async Task WriteAsync(byte[] buffer, CancellationToken cancellationToken = default) { if (buffer == null) throw new ArgumentNullException(nameof(buffer)); - return WriteAsync(buffer, 0, buffer.Length, cancellationToken); + await WriteAsync(buffer, 0, buffer.Length, cancellationToken); } - public Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken = default) + public async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken = default) { if (buffer == null) throw new ArgumentNullException(nameof(buffer)); @@ -78,45 +86,45 @@ public Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken c if (count < 0 || offset + count > buffer.Length) throw new ArgumentOutOfRangeException(nameof(count)); - lock (_lock) + // Wait for the HTTP stream to be ready (first write only blocks) + await _httpStreamReady.WaitAsync(cancellationToken); + try { - ThrowIfCompletedOrError(); - - if (_bytesWritten + count > _maxResponseSize) + lock (_lock) { - throw new InvalidOperationException( - $"Writing {count} bytes would exceed the maximum response size of {_maxResponseSize} bytes (20 MiB). " + - $"Current size: {_bytesWritten} bytes."); - } - - var chunk = new byte[count]; - Array.Copy(buffer, offset, chunk, 0, count); - _chunks.Add(chunk); - _bytesWritten += count; - } + ThrowIfCompletedOrError(); - return Task.CompletedTask; - } - - public Task WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) - { - lock (_lock) - { - ThrowIfCompletedOrError(); + if (_bytesWritten + count > _maxResponseSize) + { + throw new InvalidOperationException( + $"Writing {count} bytes would exceed the maximum response size of {_maxResponseSize} bytes (20 MiB). " + + $"Current size: {_bytesWritten} bytes."); + } - if (_bytesWritten + buffer.Length > _maxResponseSize) - { - throw new InvalidOperationException( - $"Writing {buffer.Length} bytes would exceed the maximum response size of {_maxResponseSize} bytes (20 MiB). " + - $"Current size: {_bytesWritten} bytes."); + _bytesWritten += count; } - var chunk = buffer.ToArray(); - _chunks.Add(chunk); - _bytesWritten += buffer.Length; + // Write chunk directly to the HTTP stream: size(hex) + CRLF + data + CRLF + var chunkSizeHex = count.ToString("X"); + var chunkSizeBytes = Encoding.ASCII.GetBytes(chunkSizeHex); + await _httpOutputStream.WriteAsync(chunkSizeBytes, 0, chunkSizeBytes.Length, cancellationToken); + await _httpOutputStream.WriteAsync(CrlfBytes, 0, CrlfBytes.Length, cancellationToken); + await _httpOutputStream.WriteAsync(buffer, offset, count, cancellationToken); + await _httpOutputStream.WriteAsync(CrlfBytes, 0, CrlfBytes.Length, cancellationToken); + await _httpOutputStream.FlushAsync(cancellationToken); + } + finally + { + // Re-release so subsequent writes don't block + _httpStreamReady.Release(); } + } - return Task.CompletedTask; + public async Task WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + // Convert to array and delegate — small overhead but keeps the API simple + var array = buffer.ToArray(); + await WriteAsync(array, 0, array.Length, cancellationToken); } public Task ReportErrorAsync(Exception exception, CancellationToken cancellationToken = default) @@ -135,6 +143,8 @@ public Task ReportErrorAsync(Exception exception, CancellationToken cancellation _reportedError = exception; } + // Signal completion so StreamingHttpContent can write error trailers and finish + _completionSignal.Release(); return Task.CompletedTask; } @@ -144,6 +154,8 @@ internal void MarkCompleted() { _isCompleted = true; } + // Signal completion so StreamingHttpContent can write the final chunk and finish + _completionSignal.Release(); } private void ThrowIfCompletedOrError() @@ -156,7 +168,8 @@ private void ThrowIfCompletedOrError() public void Dispose() { - // Nothing to dispose - all data is in managed memory + // Ensure completion is signaled if not already + try { _completionSignal.Release(); } catch (SemaphoreFullException) { } } } } diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ResponseStreamContext.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ResponseStreamContext.cs index 07df616e3..dc0b4a629 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ResponseStreamContext.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ResponseStreamContext.cs @@ -13,6 +13,9 @@ * permissions and limitations under the License. */ +using System.Threading; +using System.Threading.Tasks; + namespace Amazon.Lambda.RuntimeSupport { /// @@ -39,5 +42,21 @@ internal class ResponseStreamContext /// The ResponseStream instance if created. /// public ResponseStream Stream { get; set; } + + /// + /// The RuntimeApiClient used to start the streaming HTTP POST. + /// + public RuntimeApiClient RuntimeApiClient { get; set; } + + /// + /// Cancellation token for the current invocation. + /// + public CancellationToken CancellationToken { get; set; } + + /// + /// The Task representing the in-flight HTTP POST to the Runtime API. + /// Started when CreateStream() is called, completes when the stream is finalized. + /// + public Task SendTask { get; set; } } } diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingHttpContent.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingHttpContent.cs index c853ed5dd..e563d343b 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingHttpContent.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingHttpContent.cs @@ -39,18 +39,24 @@ public StreamingHttpContent(ResponseStream responseStream) protected override async Task SerializeToStreamAsync(Stream stream, TransportContext context) { - foreach (var chunk in _responseStream.Chunks) - { - await WriteChunkAsync(stream, chunk); - } + // Hand the HTTP output stream to ResponseStream so WriteAsync calls + // can write chunks directly to it. + _responseStream.SetHttpOutputStream(stream); - await WriteFinalChunkAsync(stream); + // Wait for the handler to finish writing (MarkCompleted or ReportErrorAsync) + await _responseStream.WaitForCompletionAsync(); + // Write final chunk + await stream.WriteAsync(FinalChunkBytes, 0, FinalChunkBytes.Length); + + // Write error trailers if present if (_responseStream.HasError) { await WriteErrorTrailersAsync(stream, _responseStream.ReportedError); } + // Write final CRLF to end the chunked message + await stream.WriteAsync(CrlfBytes, 0, CrlfBytes.Length); await stream.FlushAsync(); } @@ -60,22 +66,6 @@ protected override bool TryComputeLength(out long length) return false; } - private async Task WriteChunkAsync(Stream stream, byte[] data) - { - var chunkSizeHex = data.Length.ToString("X"); - var chunkSizeBytes = Encoding.ASCII.GetBytes(chunkSizeHex); - - await stream.WriteAsync(chunkSizeBytes, 0, chunkSizeBytes.Length); - await stream.WriteAsync(CrlfBytes, 0, CrlfBytes.Length); - await stream.WriteAsync(data, 0, data.Length); - await stream.WriteAsync(CrlfBytes, 0, CrlfBytes.Length); - } - - private async Task WriteFinalChunkAsync(Stream stream) - { - await stream.WriteAsync(FinalChunkBytes, 0, FinalChunkBytes.Length); - } - private async Task WriteErrorTrailersAsync(Stream stream, Exception exception) { var exceptionInfo = ExceptionInfo.GetExceptionInfo(exception); @@ -88,8 +78,6 @@ private async Task WriteErrorTrailersAsync(Stream stream, Exception exception) var errorBodyHeader = $"{StreamingConstants.ErrorBodyTrailer}: {errorBodyJson}\r\n"; var errorBodyBytes = Encoding.UTF8.GetBytes(errorBodyHeader); await stream.WriteAsync(errorBodyBytes, 0, errorBodyBytes.Length); - - await stream.WriteAsync(CrlfBytes, 0, CrlfBytes.Length); } } } diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs index 7503277ca..a6ef2fe6f 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs @@ -14,6 +14,10 @@ */ using System; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; using System.Threading.Tasks; using Xunit; @@ -23,6 +27,20 @@ public class ResponseStreamTests { private const long MaxResponseSize = 20 * 1024 * 1024; // 20 MiB + /// + /// Helper: creates a ResponseStream and wires up a MemoryStream as the HTTP output stream. + /// Returns both so tests can inspect what was written. + /// + private static (ResponseStream stream, MemoryStream httpOutput) CreateWiredStream(long maxSize = MaxResponseSize) + { + var rs = new ResponseStream(maxSize); + var output = new MemoryStream(); + rs.SetHttpOutputStream(output); + return (rs, output); + } + + // ---- Basic state tests ---- + [Fact] public void Constructor_InitializesStateCorrectly() { @@ -31,94 +49,220 @@ public void Constructor_InitializesStateCorrectly() Assert.Equal(0, stream.BytesWritten); Assert.False(stream.IsCompleted); Assert.False(stream.HasError); - Assert.Empty(stream.Chunks); Assert.Null(stream.ReportedError); } - [Fact] - public async Task WriteAsync_ByteArray_BuffersDataCorrectly() + // ---- Chunked encoding format (Property 9, Property 22) ---- + + /// + /// Property 9: Chunked Encoding Format — each chunk is hex-size + CRLF + data + CRLF. + /// Property 22: CRLF Line Terminators — all line terminators are \r\n. + /// Validates: Requirements 3.2, 10.1, 10.5 + /// + [Theory] + [InlineData(new byte[] { 1, 2, 3 }, "3")] // 3 bytes → "3" + [InlineData(new byte[] { 0xFF }, "1")] // 1 byte → "1" + [InlineData(new byte[0], "0")] // 0 bytes → "0" + public async Task WriteAsync_WritesChunkedEncodingFormat(byte[] data, string expectedHexSize) { - var stream = new ResponseStream(MaxResponseSize); - var data = new byte[] { 1, 2, 3, 4, 5 }; + var (stream, httpOutput) = CreateWiredStream(); await stream.WriteAsync(data); - Assert.Equal(5, stream.BytesWritten); - Assert.Single(stream.Chunks); - Assert.Equal(data, stream.Chunks[0]); + var written = httpOutput.ToArray(); + var expected = Encoding.ASCII.GetBytes(expectedHexSize + "\r\n") + .Concat(data) + .Concat(Encoding.ASCII.GetBytes("\r\n")) + .ToArray(); + + Assert.Equal(expected, written); } + /// + /// Property 9: Chunked Encoding Format — verify with offset/count overload. + /// Validates: Requirements 3.2, 10.1 + /// [Fact] - public async Task WriteAsync_WithOffset_BuffersCorrectSlice() + public async Task WriteAsync_WithOffset_WritesCorrectSliceAsChunk() { - var stream = new ResponseStream(MaxResponseSize); + var (stream, httpOutput) = CreateWiredStream(); var data = new byte[] { 0, 1, 2, 3, 0 }; await stream.WriteAsync(data, 1, 3); - Assert.Equal(3, stream.BytesWritten); - Assert.Equal(new byte[] { 1, 2, 3 }, stream.Chunks[0]); + var written = httpOutput.ToArray(); + // 3 bytes → hex "3", data is {1,2,3} + var expected = Encoding.ASCII.GetBytes("3\r\n") + .Concat(new byte[] { 1, 2, 3 }) + .Concat(Encoding.ASCII.GetBytes("\r\n")) + .ToArray(); + + Assert.Equal(expected, written); } + /// + /// Property 9: Chunked Encoding Format — ReadOnlyMemory overload. + /// Validates: Requirements 3.2, 10.1 + /// [Fact] - public async Task WriteAsync_ReadOnlyMemory_BuffersDataCorrectly() + public async Task WriteAsync_ReadOnlyMemory_WritesChunkedFormat() { - var stream = new ResponseStream(MaxResponseSize); + var (stream, httpOutput) = CreateWiredStream(); var data = new ReadOnlyMemory(new byte[] { 10, 20, 30 }); await stream.WriteAsync(data); + var written = httpOutput.ToArray(); + var expected = Encoding.ASCII.GetBytes("3\r\n") + .Concat(new byte[] { 10, 20, 30 }) + .Concat(Encoding.ASCII.GetBytes("\r\n")) + .ToArray(); + + Assert.Equal(expected, written); + } + + // ---- Property 5: Written Data Appears in HTTP Response Immediately ---- + + /// + /// Property 5: Written Data Appears in HTTP Response Immediately — + /// each WriteAsync call writes to the HTTP stream before returning. + /// Validates: Requirements 3.2 + /// + [Fact] + public async Task WriteAsync_MultipleWrites_EachAppearsImmediately() + { + var (stream, httpOutput) = CreateWiredStream(); + + await stream.WriteAsync(new byte[] { 0xAA }); + var afterFirst = httpOutput.ToArray().Length; + Assert.True(afterFirst > 0, "First chunk should be on the HTTP stream immediately after WriteAsync returns"); + + await stream.WriteAsync(new byte[] { 0xBB, 0xCC }); + var afterSecond = httpOutput.ToArray().Length; + Assert.True(afterSecond > afterFirst, "Second chunk should appear on the HTTP stream immediately"); + Assert.Equal(3, stream.BytesWritten); - Assert.Equal(new byte[] { 10, 20, 30 }, stream.Chunks[0]); } + /// + /// Property 5: Written Data Appears in HTTP Response Immediately — + /// verify with a larger payload that hex size is multi-character. + /// Validates: Requirements 3.2 + /// [Fact] - public async Task WriteAsync_MultipleWrites_AccumulatesBytesWritten() + public async Task WriteAsync_LargerPayload_HexSizeIsCorrect() { - var stream = new ResponseStream(MaxResponseSize); + var (stream, httpOutput) = CreateWiredStream(); + var data = new byte[256]; // 0x100 + + await stream.WriteAsync(data); - await stream.WriteAsync(new byte[100]); - await stream.WriteAsync(new byte[200]); - await stream.WriteAsync(new byte[300]); + var written = Encoding.ASCII.GetString(httpOutput.ToArray()); + Assert.StartsWith("100\r\n", written); + } + + // ---- Semaphore coordination: _httpStreamReady blocks until SetHttpOutputStream ---- - Assert.Equal(600, stream.BytesWritten); - Assert.Equal(3, stream.Chunks.Count); + /// + /// Test that WriteAsync blocks until SetHttpOutputStream is called. + /// Validates: Requirements 3.2, 10.1 + /// + [Fact] + public async Task WriteAsync_BlocksUntilSetHttpOutputStream() + { + var rs = new ResponseStream(MaxResponseSize); + var httpOutput = new MemoryStream(); + var writeStarted = new ManualResetEventSlim(false); + var writeCompleted = new ManualResetEventSlim(false); + + // Start a write on a background thread — it should block + var writeTask = Task.Run(async () => + { + writeStarted.Set(); + await rs.WriteAsync(new byte[] { 1, 2, 3 }); + writeCompleted.Set(); + }); + + // Wait for the write to start, then verify it hasn't completed + writeStarted.Wait(TimeSpan.FromSeconds(2)); + await Task.Delay(100); // give it a moment + Assert.False(writeCompleted.IsSet, "WriteAsync should block until SetHttpOutputStream is called"); + + // Now provide the HTTP stream — the write should complete + rs.SetHttpOutputStream(httpOutput); + await writeTask; + + Assert.True(writeCompleted.IsSet); + Assert.True(httpOutput.ToArray().Length > 0); } + // ---- Completion signaling: MarkCompleted releases _completionSignal ---- + + /// + /// Test that MarkCompleted releases the completion signal (WaitForCompletionAsync unblocks). + /// Validates: Requirements 5.5, 8.3 + /// [Fact] - public async Task WriteAsync_CopiesData_AvoidingBufferReuseIssues() + public async Task MarkCompleted_ReleasesCompletionSignal() { - var stream = new ResponseStream(MaxResponseSize); - var buffer = new byte[] { 1, 2, 3 }; + var (stream, _) = CreateWiredStream(); + + var waitTask = stream.WaitForCompletionAsync(); + Assert.False(waitTask.IsCompleted, "WaitForCompletionAsync should block before MarkCompleted"); - await stream.WriteAsync(buffer); - buffer[0] = 99; // mutate original + stream.MarkCompleted(); - Assert.Equal(1, stream.Chunks[0][0]); // chunk should be unaffected + // Should complete within a reasonable time + var completed = await Task.WhenAny(waitTask, Task.Delay(TimeSpan.FromSeconds(2))); + Assert.Same(waitTask, completed); + Assert.True(stream.IsCompleted); + } + + // ---- Completion signaling: ReportErrorAsync releases _completionSignal ---- + + /// + /// Test that ReportErrorAsync releases the completion signal. + /// Validates: Requirements 5.5 + /// + [Fact] + public async Task ReportErrorAsync_ReleasesCompletionSignal() + { + var (stream, _) = CreateWiredStream(); + + var waitTask = stream.WaitForCompletionAsync(); + Assert.False(waitTask.IsCompleted, "WaitForCompletionAsync should block before ReportErrorAsync"); + + await stream.ReportErrorAsync(new Exception("test error")); + + var completed = await Task.WhenAny(waitTask, Task.Delay(TimeSpan.FromSeconds(2))); + Assert.Same(waitTask, completed); + Assert.True(stream.HasError); } + // ---- Property 6: Size Limit Enforcement ---- + /// - /// Property 6: Size Limit Enforcement - Writing beyond 20 MiB throws InvalidOperationException. + /// Property 6: Size Limit Enforcement — single write exceeding limit throws. /// Validates: Requirements 3.6, 3.7 /// [Theory] - [InlineData(21 * 1024 * 1024)] // Single write exceeding limit + [InlineData(21 * 1024 * 1024)] public async Task SizeLimit_SingleWriteExceedingLimit_Throws(int writeSize) { - var stream = new ResponseStream(MaxResponseSize); + var (stream, _) = CreateWiredStream(); var data = new byte[writeSize]; await Assert.ThrowsAsync(() => stream.WriteAsync(data)); } /// - /// Property 6: Size Limit Enforcement - Multiple writes exceeding 20 MiB throws. + /// Property 6: Size Limit Enforcement — multiple writes exceeding limit throws. /// Validates: Requirements 3.6, 3.7 /// [Fact] public async Task SizeLimit_MultipleWritesExceedingLimit_Throws() { - var stream = new ResponseStream(MaxResponseSize); + var (stream, _) = CreateWiredStream(); await stream.WriteAsync(new byte[10 * 1024 * 1024]); await Assert.ThrowsAsync( @@ -128,7 +272,7 @@ await Assert.ThrowsAsync( [Fact] public async Task SizeLimit_ExactlyAtLimit_Succeeds() { - var stream = new ResponseStream(MaxResponseSize); + var (stream, _) = CreateWiredStream(); var data = new byte[20 * 1024 * 1024]; await stream.WriteAsync(data); @@ -136,14 +280,16 @@ public async Task SizeLimit_ExactlyAtLimit_Succeeds() Assert.Equal(MaxResponseSize, stream.BytesWritten); } + // ---- Property 19: Writes After Completion Rejected ---- + /// - /// Property 19: Writes After Completion Rejected - Writes after completion throw InvalidOperationException. + /// Property 19: Writes After Completion Rejected — writes after MarkCompleted throw. /// Validates: Requirements 8.8 /// [Fact] public async Task WriteAsync_AfterMarkCompleted_Throws() { - var stream = new ResponseStream(MaxResponseSize); + var (stream, _) = CreateWiredStream(); await stream.WriteAsync(new byte[] { 1 }); stream.MarkCompleted(); @@ -151,10 +297,14 @@ await Assert.ThrowsAsync( () => stream.WriteAsync(new byte[] { 2 })); } + /// + /// Property 19: Writes After Completion Rejected — writes after ReportErrorAsync throw. + /// Validates: Requirements 8.8 + /// [Fact] public async Task WriteAsync_AfterReportError_Throws() { - var stream = new ResponseStream(MaxResponseSize); + var (stream, _) = CreateWiredStream(); await stream.WriteAsync(new byte[] { 1 }); await stream.ReportErrorAsync(new Exception("test")); @@ -162,7 +312,7 @@ await Assert.ThrowsAsync( () => stream.WriteAsync(new byte[] { 2 })); } - // --- Error handling tests (2.6) --- + // ---- Error handling tests ---- [Fact] public async Task ReportErrorAsync_SetsErrorState() @@ -205,5 +355,47 @@ public void MarkCompleted_SetsCompletionState() Assert.True(stream.IsCompleted); } + + // ---- Argument validation ---- + + [Fact] + public async Task WriteAsync_NullBuffer_ThrowsArgumentNull() + { + var (stream, _) = CreateWiredStream(); + + await Assert.ThrowsAsync(() => stream.WriteAsync((byte[])null)); + } + + [Fact] + public async Task WriteAsync_NullBufferWithOffset_ThrowsArgumentNull() + { + var (stream, _) = CreateWiredStream(); + + await Assert.ThrowsAsync(() => stream.WriteAsync(null, 0, 0)); + } + + [Fact] + public async Task ReportErrorAsync_NullException_ThrowsArgumentNull() + { + var stream = new ResponseStream(MaxResponseSize); + + await Assert.ThrowsAsync(() => stream.ReportErrorAsync(null)); + } + + // ---- Dispose signals completion ---- + + [Fact] + public async Task Dispose_ReleasesCompletionSignalIfNotAlreadyReleased() + { + var stream = new ResponseStream(MaxResponseSize); + + var waitTask = stream.WaitForCompletionAsync(); + Assert.False(waitTask.IsCompleted); + + stream.Dispose(); + + var completed = await Task.WhenAny(waitTask, Task.Delay(TimeSpan.FromSeconds(2))); + Assert.Same(waitTask, completed); + } } } diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingHttpContentTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingHttpContentTests.cs index 0682a816e..53b1e88b7 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingHttpContentTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingHttpContentTests.cs @@ -16,6 +16,7 @@ using System; using System.IO; using System.Text; +using System.Threading; using System.Threading.Tasks; using Xunit; @@ -25,149 +26,182 @@ public class StreamingHttpContentTests { private const long MaxResponseSize = 20 * 1024 * 1024; - private async Task SerializeContentAsync(StreamingHttpContent content) + /// + /// Helper: runs SerializeToStreamAsync concurrently with handler actions. + /// The handlerAction receives the ResponseStream and should write data then signal completion. + /// Returns the bytes written to the HTTP output stream. + /// + private async Task SerializeWithConcurrentHandler( + ResponseStream responseStream, + Func handlerAction) { - using var ms = new MemoryStream(); - await content.CopyToAsync(ms); - return ms.ToArray(); + var content = new StreamingHttpContent(responseStream); + var outputStream = new MemoryStream(); + + // Start serialization on a background task (it will call SetHttpOutputStream and wait) + var serializeTask = Task.Run(() => content.CopyToAsync(outputStream)); + + // Give SerializeToStreamAsync a moment to start and call SetHttpOutputStream + await Task.Delay(50); + + // Run the handler action (writes data, signals completion) + await handlerAction(responseStream); + + // Wait for serialization to complete + await serializeTask; + + return outputStream.ToArray(); } - // --- Task 5.4: Chunked encoding format tests --- + // ---- SerializeToStreamAsync hands off HTTP stream ---- /// - /// Property 9: Chunked Encoding Format - chunks are formatted as size(hex) + CRLF + data + CRLF. - /// Validates: Requirements 4.3, 10.1, 10.2 + /// Test that SerializeToStreamAsync calls SetHttpOutputStream on the ResponseStream, + /// enabling writes to flow through. + /// Validates: Requirements 4.3, 10.1 /// - [Theory] - [InlineData(1)] - [InlineData(10)] - [InlineData(255)] - [InlineData(4096)] - public async Task ChunkedEncoding_SingleChunk_CorrectFormat(int chunkSize) + [Fact] + public async Task SerializeToStreamAsync_HandsOffHttpStream_WritesFlowThrough() { - var stream = new ResponseStream(MaxResponseSize); - var data = new byte[chunkSize]; - for (int i = 0; i < data.Length; i++) data[i] = (byte)(i % 256); - await stream.WriteAsync(data); - - var content = new StreamingHttpContent(stream); - var output = await SerializeContentAsync(content); - var outputStr = Encoding.ASCII.GetString(output); + var rs = new ResponseStream(MaxResponseSize); - var expectedSizeHex = chunkSize.ToString("X"); - Assert.StartsWith(expectedSizeHex + "\r\n", outputStr); - - // Verify chunk data follows the size line - var dataStart = expectedSizeHex.Length + 2; // size + CRLF - for (int i = 0; i < chunkSize; i++) + var output = await SerializeWithConcurrentHandler(rs, async stream => { - Assert.Equal(data[i], output[dataStart + i]); - } + await stream.WriteAsync(new byte[] { 0xAA, 0xBB }); + stream.MarkCompleted(); + }); - // Verify CRLF after data - Assert.Equal((byte)'\r', output[dataStart + chunkSize]); - Assert.Equal((byte)'\n', output[dataStart + chunkSize + 1]); + var outputStr = Encoding.ASCII.GetString(output); + // Should contain the chunk data written by the handler + Assert.Contains("2\r\n", outputStr); + Assert.True(output.Length > 0); } /// - /// Property 9: Chunked Encoding Format - multiple chunks each formatted correctly. - /// Validates: Requirements 4.3, 10.1 + /// Test that SerializeToStreamAsync blocks until MarkCompleted is called. + /// Validates: Requirements 4.3 /// [Fact] - public async Task ChunkedEncoding_MultipleChunks_EachFormattedCorrectly() + public async Task SerializeToStreamAsync_BlocksUntilMarkCompleted() { - var stream = new ResponseStream(MaxResponseSize); - await stream.WriteAsync(new byte[] { 0xAA, 0xBB }); - await stream.WriteAsync(new byte[] { 0xCC }); + var rs = new ResponseStream(MaxResponseSize); + var content = new StreamingHttpContent(rs); + var outputStream = new MemoryStream(); - var content = new StreamingHttpContent(stream); - var output = await SerializeContentAsync(content); - var outputStr = Encoding.ASCII.GetString(output); + var serializeTask = Task.Run(() => content.CopyToAsync(outputStream)); + await Task.Delay(50); + + // Serialization should still be running (waiting for completion) + Assert.False(serializeTask.IsCompleted, "SerializeToStreamAsync should block until completion is signaled"); + + // Now signal completion + rs.MarkCompleted(); + await serializeTask; - // First chunk: "2\r\n" + 2 bytes + "\r\n" - Assert.StartsWith("2\r\n", outputStr); - Assert.Equal(0xAA, output[3]); - Assert.Equal(0xBB, output[4]); - Assert.Equal((byte)'\r', output[5]); - Assert.Equal((byte)'\n', output[6]); - - // Second chunk: "1\r\n" + 1 byte + "\r\n" - Assert.Equal((byte)'1', output[7]); - Assert.Equal((byte)'\r', output[8]); - Assert.Equal((byte)'\n', output[9]); - Assert.Equal(0xCC, output[10]); - Assert.Equal((byte)'\r', output[11]); - Assert.Equal((byte)'\n', output[12]); + Assert.True(serializeTask.IsCompleted); } /// - /// Property 20: Final Chunk Termination - final chunk "0\r\n" is written. - /// Validates: Requirements 10.2, 10.5 + /// Test that SerializeToStreamAsync blocks until ReportErrorAsync is called. + /// Validates: Requirements 4.3, 5.1 /// [Fact] - public async Task FinalChunk_IsWritten() + public async Task SerializeToStreamAsync_BlocksUntilReportErrorAsync() { - var stream = new ResponseStream(MaxResponseSize); - await stream.WriteAsync(new byte[] { 1 }); + var rs = new ResponseStream(MaxResponseSize); + var content = new StreamingHttpContent(rs); + var outputStream = new MemoryStream(); - var content = new StreamingHttpContent(stream); - var output = await SerializeContentAsync(content); - var outputStr = Encoding.ASCII.GetString(output); + var serializeTask = Task.Run(() => content.CopyToAsync(outputStream)); + await Task.Delay(50); - // Output should end with final chunk "0\r\n" - Assert.EndsWith("0\r\n", outputStr); + Assert.False(serializeTask.IsCompleted, "SerializeToStreamAsync should block until error is reported"); + + await rs.ReportErrorAsync(new Exception("test error")); + await serializeTask; + + Assert.True(serializeTask.IsCompleted); } + // ---- Property 20: Final Chunk Termination ---- + + /// + /// Property 20: Final Chunk Termination — final chunk "0\r\n" is written after completion. + /// Validates: Requirements 4.3, 10.2, 10.3 + /// [Fact] - public async Task FinalChunk_EmptyStream_OnlyFinalChunk() + public async Task FinalChunk_WrittenAfterCompletion() { - var stream = new ResponseStream(MaxResponseSize); + var rs = new ResponseStream(MaxResponseSize); - var content = new StreamingHttpContent(stream); - var output = await SerializeContentAsync(content); + var output = await SerializeWithConcurrentHandler(rs, async stream => + { + await stream.WriteAsync(new byte[] { 1 }); + stream.MarkCompleted(); + }); - Assert.Equal(Encoding.ASCII.GetBytes("0\r\n"), output); + var outputStr = Encoding.ASCII.GetString(output); + Assert.Contains("0\r\n", outputStr); + + // Final chunk should appear after the data chunk + var dataChunkEnd = outputStr.IndexOf("1\r\n") + 3 + 1 + 2; // "1\r\n" + 1 byte data + "\r\n" + var finalChunkIndex = outputStr.IndexOf("0\r\n", dataChunkEnd); + Assert.True(finalChunkIndex >= 0, "Final chunk 0\\r\\n should appear after data chunks"); } /// - /// Property 22: CRLF Line Terminators - all line terminators are CRLF, not just LF. - /// Validates: Requirements 10.5 + /// Property 20: Final Chunk Termination — empty stream still gets final chunk. + /// Validates: Requirements 10.2 /// [Fact] - public async Task CrlfTerminators_NoBareLineFeed() + public async Task FinalChunk_EmptyStream_StillWritten() { - var stream = new ResponseStream(MaxResponseSize); - await stream.WriteAsync(new byte[] { 65, 66, 67 }); // "ABC" + var rs = new ResponseStream(MaxResponseSize); - var content = new StreamingHttpContent(stream); - var output = await SerializeContentAsync(content); - - // Check every \n is preceded by \r - for (int i = 0; i < output.Length; i++) + var output = await SerializeWithConcurrentHandler(rs, stream => { - if (output[i] == (byte)'\n') - { - Assert.True(i > 0 && output[i - 1] == (byte)'\r', - $"Found bare LF at position {i} without preceding CR"); - } - } + stream.MarkCompleted(); + return Task.CompletedTask; + }); + + var outputStr = Encoding.ASCII.GetString(output); + Assert.StartsWith("0\r\n", outputStr); } + // ---- Property 21: Trailer Ordering ---- + + /// + /// Property 21: Trailer Ordering — trailers appear after final chunk. + /// Validates: Requirements 10.3 + /// [Fact] - public void TryComputeLength_ReturnsFalse() + public async Task ErrorTrailers_AppearAfterFinalChunk() { - var stream = new ResponseStream(MaxResponseSize); - var content = new StreamingHttpContent(stream); + var rs = new ResponseStream(MaxResponseSize); - var result = content.Headers.ContentLength; + var output = await SerializeWithConcurrentHandler(rs, async stream => + { + await stream.WriteAsync(new byte[] { 1 }); + await stream.ReportErrorAsync(new Exception("fail")); + }); - Assert.Null(result); + var outputStr = Encoding.UTF8.GetString(output); + + // Find the final chunk "0\r\n" that appears after data chunks + var dataEnd = outputStr.IndexOf("1\r\n") + 3 + 1 + 2; + var finalChunkIndex = outputStr.IndexOf("0\r\n", dataEnd); + var errorTypeIndex = outputStr.IndexOf("Lambda-Runtime-Function-Error-Type:"); + var errorBodyIndex = outputStr.IndexOf("Lambda-Runtime-Function-Error-Body:"); + + Assert.True(finalChunkIndex >= 0, "Final chunk not found"); + Assert.True(errorTypeIndex > finalChunkIndex, "Error type trailer should appear after final chunk"); + Assert.True(errorBodyIndex > finalChunkIndex, "Error body trailer should appear after final chunk"); } - // --- Task 5.6: Error trailer tests --- + // ---- Property 11: Midstream Error Type Trailer ---- /// - /// Property 11: Midstream Error Type Trailer - error type trailer is included for various exception types. + /// Property 11: Midstream Error Type Trailer — error type trailer is included for various exception types. /// Validates: Requirements 5.1, 5.2 /// [Theory] @@ -176,89 +210,138 @@ public void TryComputeLength_ReturnsFalse() [InlineData(typeof(NullReferenceException))] public async Task ErrorTrailer_IncludesErrorType(Type exceptionType) { - var stream = new ResponseStream(MaxResponseSize); - await stream.WriteAsync(new byte[] { 1 }); - var exception = (Exception)Activator.CreateInstance(exceptionType, "test error"); - await stream.ReportErrorAsync(exception); + var rs = new ResponseStream(MaxResponseSize); - var content = new StreamingHttpContent(stream); - var output = await SerializeContentAsync(content); - var outputStr = Encoding.UTF8.GetString(output); + var output = await SerializeWithConcurrentHandler(rs, async stream => + { + await stream.WriteAsync(new byte[] { 1 }); + var exception = (Exception)Activator.CreateInstance(exceptionType, "test error"); + await stream.ReportErrorAsync(exception); + }); + var outputStr = Encoding.UTF8.GetString(output); Assert.Contains($"Lambda-Runtime-Function-Error-Type: {exceptionType.Name}", outputStr); } + // ---- Property 12: Midstream Error Body Trailer ---- + /// - /// Property 12: Midstream Error Body Trailer - error body trailer includes JSON exception details. + /// Property 12: Midstream Error Body Trailer — error body trailer includes JSON exception details. /// Validates: Requirements 5.3 /// [Fact] public async Task ErrorTrailer_IncludesJsonErrorBody() { - var stream = new ResponseStream(MaxResponseSize); - await stream.WriteAsync(new byte[] { 1 }); - await stream.ReportErrorAsync(new InvalidOperationException("something went wrong")); + var rs = new ResponseStream(MaxResponseSize); - var content = new StreamingHttpContent(stream); - var output = await SerializeContentAsync(content); - var outputStr = Encoding.UTF8.GetString(output); + var output = await SerializeWithConcurrentHandler(rs, async stream => + { + await stream.WriteAsync(new byte[] { 1 }); + await stream.ReportErrorAsync(new InvalidOperationException("something went wrong")); + }); + var outputStr = Encoding.UTF8.GetString(output); Assert.Contains("Lambda-Runtime-Function-Error-Body:", outputStr); Assert.Contains("something went wrong", outputStr); Assert.Contains("InvalidOperationException", outputStr); } + // ---- Final CRLF termination ---- + /// - /// Property 21: Trailer Ordering - trailers appear after final chunk. - /// Validates: Requirements 10.3 + /// Test that the chunked message ends with CRLF after successful completion (no trailers). + /// Validates: Requirements 10.2, 10.5 /// [Fact] - public async Task ErrorTrailers_AppearAfterFinalChunk() + public async Task SuccessfulCompletion_EndsWithCrlf() { - var stream = new ResponseStream(MaxResponseSize); - await stream.WriteAsync(new byte[] { 1 }); - await stream.ReportErrorAsync(new Exception("fail")); + var rs = new ResponseStream(MaxResponseSize); - var content = new StreamingHttpContent(stream); - var output = await SerializeContentAsync(content); - var outputStr = Encoding.UTF8.GetString(output); + var output = await SerializeWithConcurrentHandler(rs, async stream => + { + await stream.WriteAsync(new byte[] { 1 }); + stream.MarkCompleted(); + }); - var finalChunkIndex = outputStr.IndexOf("0\r\n"); - var errorTypeIndex = outputStr.IndexOf("Lambda-Runtime-Function-Error-Type:"); - var errorBodyIndex = outputStr.IndexOf("Lambda-Runtime-Function-Error-Body:"); + var outputStr = Encoding.ASCII.GetString(output); + // Should end with "0\r\n" (final chunk) + "\r\n" (end of message) + Assert.EndsWith("0\r\n\r\n", outputStr); + } - Assert.True(finalChunkIndex >= 0, "Final chunk not found"); - Assert.True(errorTypeIndex > finalChunkIndex, "Error type trailer should appear after final chunk"); - Assert.True(errorBodyIndex > finalChunkIndex, "Error body trailer should appear after final chunk"); + /// + /// Test that the chunked message ends with CRLF after error trailers. + /// Validates: Requirements 10.3, 10.5 + /// + [Fact] + public async Task ErrorCompletion_EndsWithCrlf() + { + var rs = new ResponseStream(MaxResponseSize); + + var output = await SerializeWithConcurrentHandler(rs, async stream => + { + await stream.WriteAsync(new byte[] { 1 }); + await stream.ReportErrorAsync(new Exception("fail")); + }); + + var outputStr = Encoding.UTF8.GetString(output); + Assert.EndsWith("\r\n", outputStr); } + // ---- No error, no trailers ---- + [Fact] public async Task NoError_NoTrailersWritten() { - var stream = new ResponseStream(MaxResponseSize); - await stream.WriteAsync(new byte[] { 1 }); + var rs = new ResponseStream(MaxResponseSize); - var content = new StreamingHttpContent(stream); - var output = await SerializeContentAsync(content); - var outputStr = Encoding.UTF8.GetString(output); + var output = await SerializeWithConcurrentHandler(rs, async stream => + { + await stream.WriteAsync(new byte[] { 1 }); + stream.MarkCompleted(); + }); + var outputStr = Encoding.UTF8.GetString(output); Assert.DoesNotContain("Lambda-Runtime-Function-Error-Type:", outputStr); Assert.DoesNotContain("Lambda-Runtime-Function-Error-Body:", outputStr); } + // ---- TryComputeLength ---- + [Fact] - public async Task ErrorTrailers_EndWithCrlf() + public void TryComputeLength_ReturnsFalse() { var stream = new ResponseStream(MaxResponseSize); - await stream.WriteAsync(new byte[] { 1 }); - await stream.ReportErrorAsync(new Exception("fail")); - var content = new StreamingHttpContent(stream); - var output = await SerializeContentAsync(content); - var outputStr = Encoding.UTF8.GetString(output); - // Should end with final CRLF after trailers - Assert.EndsWith("\r\n", outputStr); + var result = content.Headers.ContentLength; + Assert.Null(result); + } + + // ---- CRLF correctness ---- + + /// + /// Property 22: CRLF Line Terminators — all line terminators are CRLF, not just LF. + /// Validates: Requirements 10.5 + /// + [Fact] + public async Task CrlfTerminators_NoBareLineFeed() + { + var rs = new ResponseStream(MaxResponseSize); + + var output = await SerializeWithConcurrentHandler(rs, async stream => + { + await stream.WriteAsync(new byte[] { 65, 66, 67 }); // "ABC" + stream.MarkCompleted(); + }); + + for (int i = 0; i < output.Length; i++) + { + if (output[i] == (byte)'\n') + { + Assert.True(i > 0 && output[i - 1] == (byte)'\r', + $"Found bare LF at position {i} without preceding CR"); + } + } } } } From 603612d1635ebc5fcf6091c30afb005605f7abd4 Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Mon, 16 Feb 2026 18:14:44 -0800 Subject: [PATCH 04/20] Task 5 --- .../Client/ResponseStreamFactory.cs | 25 +++- .../Client/RuntimeApiClient.cs | 41 ++++++ .../ResponseStreamFactoryTests.cs | 135 +++++++++++++++--- .../NoOpInternalRuntimeApiClient.cs | 60 ++++++++ 4 files changed, 238 insertions(+), 23 deletions(-) create mode 100644 Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/TestHelpers/NoOpInternalRuntimeApiClient.cs diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ResponseStreamFactory.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ResponseStreamFactory.cs index 9b60eacfd..613980fb1 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ResponseStreamFactory.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ResponseStreamFactory.cs @@ -15,6 +15,7 @@ using System; using System.Threading; +using System.Threading.Tasks; namespace Amazon.Lambda.RuntimeSupport { @@ -57,19 +58,29 @@ public static IResponseStream CreateStream() context.Stream = stream; context.StreamCreated = true; + // Start the HTTP POST to the Runtime API. + // This runs concurrently — SerializeToStreamAsync will block + // until the handler finishes writing or reports an error. + context.SendTask = context.RuntimeApiClient.StartStreamingResponseAsync( + context.AwsRequestId, stream, context.CancellationToken); + return stream; } // Internal methods for LambdaBootstrap to manage state - internal static void InitializeInvocation(string awsRequestId, long maxResponseSize, bool isMultiConcurrency) + internal static void InitializeInvocation( + string awsRequestId, long maxResponseSize, bool isMultiConcurrency, + RuntimeApiClient runtimeApiClient, CancellationToken cancellationToken) { var context = new ResponseStreamContext { AwsRequestId = awsRequestId, MaxResponseSize = maxResponseSize, StreamCreated = false, - Stream = null + Stream = null, + RuntimeApiClient = runtimeApiClient, + CancellationToken = cancellationToken }; if (isMultiConcurrency) @@ -88,6 +99,16 @@ internal static ResponseStream GetStreamIfCreated(bool isMultiConcurrency) return context?.Stream; } + /// + /// Returns the Task for the in-flight HTTP send, or null if streaming wasn't started. + /// LambdaBootstrap awaits this after the handler returns to ensure the HTTP request completes. + /// + internal static Task GetSendTask(bool isMultiConcurrency) + { + var context = isMultiConcurrency ? _asyncLocalContext.Value : _onDemandContext; + return context?.SendTask; + } + internal static void CleanupInvocation(bool isMultiConcurrency) { if (isMultiConcurrency) diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs index daa9fff24..13c4e4eac 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs @@ -177,6 +177,47 @@ public Task ReportRestoreErrorAsync(Exception exception, String errorType = null #endif + /// + /// Start sending a streaming response to the Runtime API. + /// This initiates the HTTP POST with streaming headers. The actual data + /// is written by the handler via ResponseStream.WriteAsync, which flows + /// through StreamingHttpContent to the HTTP connection. + /// This Task completes when the stream is finalized (MarkCompleted or error). + /// + /// The ID of the function request being responded to. + /// The ResponseStream that will provide the streaming data. + /// The optional cancellation token to use. + /// A Task representing the in-flight HTTP POST. + internal virtual async Task StartStreamingResponseAsync( + string awsRequestId, ResponseStream responseStream, CancellationToken cancellationToken = default) + { + if (awsRequestId == null) throw new ArgumentNullException(nameof(awsRequestId)); + if (responseStream == null) throw new ArgumentNullException(nameof(responseStream)); + + var url = $"http://{LambdaEnvironment.RuntimeServerHostAndPort}/2018-06-01/runtime/invocation/{awsRequestId}/response"; + + using (var request = new HttpRequestMessage(HttpMethod.Post, url)) + { + request.Headers.Add(StreamingConstants.ResponseModeHeader, StreamingConstants.StreamingResponseMode); + request.Headers.TransferEncodingChunked = true; + + // Declare trailers upfront — we always declare them since we don't know + // at request start time whether an error will occur mid-stream. + request.Headers.Add("Trailer", + $"{StreamingConstants.ErrorTypeTrailer}, {StreamingConstants.ErrorBodyTrailer}"); + + request.Content = new StreamingHttpContent(responseStream); + + // SendAsync calls SerializeToStreamAsync, which blocks until the handler + // finishes writing. This is why this method runs concurrently with the handler. + var response = await _httpClient.SendAsync( + request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + response.EnsureSuccessStatusCode(); + } + + responseStream.MarkCompleted(); + } + /// /// Send a response to a function invocation to the Runtime API as an asynchronous operation. /// diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamFactoryTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamFactoryTests.cs index a4b0558af..11973ae5f 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamFactoryTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamFactoryTests.cs @@ -14,6 +14,7 @@ */ using System; +using System.Threading; using System.Threading.Tasks; using Xunit; @@ -30,7 +31,40 @@ public void Dispose() ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); } - // --- Task 3.3: CreateStream tests --- + /// + /// A minimal RuntimeApiClient subclass for testing that overrides StartStreamingResponseAsync + /// to avoid real HTTP calls while tracking invocations. + /// + private class MockStreamingRuntimeApiClient : RuntimeApiClient + { + public bool StartStreamingCalled { get; private set; } + public string LastAwsRequestId { get; private set; } + public ResponseStream LastResponseStream { get; private set; } + public TaskCompletionSource SendTaskCompletion { get; } = new TaskCompletionSource(); + + public MockStreamingRuntimeApiClient() + : base(new TestEnvironmentVariables(), new TestHelpers.NoOpInternalRuntimeApiClient()) + { + } + + internal override async Task StartStreamingResponseAsync( + string awsRequestId, ResponseStream responseStream, CancellationToken cancellationToken = default) + { + StartStreamingCalled = true; + LastAwsRequestId = awsRequestId; + LastResponseStream = responseStream; + await SendTaskCompletion.Task; + } + } + + private void InitializeWithMock(string requestId, bool isMultiConcurrency, MockStreamingRuntimeApiClient mockClient) + { + ResponseStreamFactory.InitializeInvocation( + requestId, MaxResponseSize, isMultiConcurrency, + mockClient, CancellationToken.None); + } + + // --- Property 1: CreateStream Returns Valid Stream --- /// /// Property 1: CreateStream Returns Valid Stream - on-demand mode. @@ -39,7 +73,8 @@ public void Dispose() [Fact] public void CreateStream_OnDemandMode_ReturnsValidStream() { - ResponseStreamFactory.InitializeInvocation("req-1", MaxResponseSize, isMultiConcurrency: false); + var mock = new MockStreamingRuntimeApiClient(); + InitializeWithMock("req-1", isMultiConcurrency: false, mock); var stream = ResponseStreamFactory.CreateStream(); @@ -54,7 +89,8 @@ public void CreateStream_OnDemandMode_ReturnsValidStream() [Fact] public void CreateStream_MultiConcurrencyMode_ReturnsValidStream() { - ResponseStreamFactory.InitializeInvocation("req-2", MaxResponseSize, isMultiConcurrency: true); + var mock = new MockStreamingRuntimeApiClient(); + InitializeWithMock("req-2", isMultiConcurrency: true, mock); var stream = ResponseStreamFactory.CreateStream(); @@ -62,6 +98,8 @@ public void CreateStream_MultiConcurrencyMode_ReturnsValidStream() Assert.IsAssignableFrom(stream); } + // --- Property 4: Single Stream Per Invocation --- + /// /// Property 4: Single Stream Per Invocation - calling CreateStream twice throws. /// Validates: Requirements 2.5, 2.6 @@ -69,7 +107,8 @@ public void CreateStream_MultiConcurrencyMode_ReturnsValidStream() [Fact] public void CreateStream_CalledTwice_ThrowsInvalidOperationException() { - ResponseStreamFactory.InitializeInvocation("req-3", MaxResponseSize, isMultiConcurrency: false); + var mock = new MockStreamingRuntimeApiClient(); + InitializeWithMock("req-3", isMultiConcurrency: false, mock); ResponseStreamFactory.CreateStream(); Assert.Throws(() => ResponseStreamFactory.CreateStream()); @@ -82,17 +121,69 @@ public void CreateStream_OutsideInvocationContext_ThrowsInvalidOperationExceptio Assert.Throws(() => ResponseStreamFactory.CreateStream()); } - // --- Task 3.5: Internal methods tests --- + // --- CreateStream starts HTTP POST --- + + /// + /// Validates that CreateStream calls StartStreamingResponseAsync on the RuntimeApiClient. + /// Validates: Requirements 1.3, 1.4, 2.2, 2.3, 2.4 + /// + [Fact] + public void CreateStream_CallsStartStreamingResponseAsync() + { + var mock = new MockStreamingRuntimeApiClient(); + InitializeWithMock("req-start", isMultiConcurrency: false, mock); + + ResponseStreamFactory.CreateStream(); + + Assert.True(mock.StartStreamingCalled); + Assert.Equal("req-start", mock.LastAwsRequestId); + Assert.NotNull(mock.LastResponseStream); + } + + // --- GetSendTask --- + + /// + /// Validates that GetSendTask returns the task from the HTTP POST. + /// Validates: Requirements 5.1, 7.3 + /// + [Fact] + public void GetSendTask_AfterCreateStream_ReturnsNonNullTask() + { + var mock = new MockStreamingRuntimeApiClient(); + InitializeWithMock("req-send", isMultiConcurrency: false, mock); + + ResponseStreamFactory.CreateStream(); + + var sendTask = ResponseStreamFactory.GetSendTask(isMultiConcurrency: false); + Assert.NotNull(sendTask); + } + + [Fact] + public void GetSendTask_BeforeCreateStream_ReturnsNull() + { + var mock = new MockStreamingRuntimeApiClient(); + InitializeWithMock("req-nosend", isMultiConcurrency: false, mock); + + var sendTask = ResponseStreamFactory.GetSendTask(isMultiConcurrency: false); + Assert.Null(sendTask); + } + + [Fact] + public void GetSendTask_NoContext_ReturnsNull() + { + Assert.Null(ResponseStreamFactory.GetSendTask(isMultiConcurrency: false)); + } + + // --- Internal methods --- [Fact] public void InitializeInvocation_OnDemand_SetsUpContext() { - ResponseStreamFactory.InitializeInvocation("req-4", MaxResponseSize, isMultiConcurrency: false); + var mock = new MockStreamingRuntimeApiClient(); + InitializeWithMock("req-4", isMultiConcurrency: false, mock); - // GetStreamIfCreated should return null since CreateStream hasn't been called Assert.Null(ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: false)); - // But CreateStream should work (proving context was set up) var stream = ResponseStreamFactory.CreateStream(); Assert.NotNull(stream); } @@ -100,7 +191,8 @@ public void InitializeInvocation_OnDemand_SetsUpContext() [Fact] public void InitializeInvocation_MultiConcurrency_SetsUpContext() { - ResponseStreamFactory.InitializeInvocation("req-5", MaxResponseSize, isMultiConcurrency: true); + var mock = new MockStreamingRuntimeApiClient(); + InitializeWithMock("req-5", isMultiConcurrency: true, mock); Assert.Null(ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: true)); @@ -111,11 +203,11 @@ public void InitializeInvocation_MultiConcurrency_SetsUpContext() [Fact] public void GetStreamIfCreated_AfterCreateStream_ReturnsStream() { - ResponseStreamFactory.InitializeInvocation("req-6", MaxResponseSize, isMultiConcurrency: false); - var created = ResponseStreamFactory.CreateStream(); + var mock = new MockStreamingRuntimeApiClient(); + InitializeWithMock("req-6", isMultiConcurrency: false, mock); + ResponseStreamFactory.CreateStream(); var retrieved = ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: false); - Assert.NotNull(retrieved); } @@ -128,7 +220,8 @@ public void GetStreamIfCreated_NoContext_ReturnsNull() [Fact] public void CleanupInvocation_ClearsState() { - ResponseStreamFactory.InitializeInvocation("req-7", MaxResponseSize, isMultiConcurrency: false); + var mock = new MockStreamingRuntimeApiClient(); + InitializeWithMock("req-7", isMultiConcurrency: false, mock); ResponseStreamFactory.CreateStream(); ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: false); @@ -137,6 +230,8 @@ public void CleanupInvocation_ClearsState() Assert.Throws(() => ResponseStreamFactory.CreateStream()); } + // --- Property 16: State Isolation Between Invocations --- + /// /// Property 16: State Isolation Between Invocations - state from one invocation doesn't leak to the next. /// Validates: Requirements 6.5, 8.9 @@ -144,17 +239,18 @@ public void CleanupInvocation_ClearsState() [Fact] public void StateIsolation_SequentialInvocations_NoLeakage() { + var mock = new MockStreamingRuntimeApiClient(); + // First invocation - streaming - ResponseStreamFactory.InitializeInvocation("req-8a", MaxResponseSize, isMultiConcurrency: false); + InitializeWithMock("req-8a", isMultiConcurrency: false, mock); var stream1 = ResponseStreamFactory.CreateStream(); Assert.NotNull(stream1); ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: false); // Second invocation - should start fresh - ResponseStreamFactory.InitializeInvocation("req-8b", MaxResponseSize, isMultiConcurrency: false); + InitializeWithMock("req-8b", isMultiConcurrency: false, mock); Assert.Null(ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: false)); - // Should be able to create a new stream var stream2 = ResponseStreamFactory.CreateStream(); Assert.NotNull(stream2); ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: false); @@ -167,17 +263,14 @@ public void StateIsolation_SequentialInvocations_NoLeakage() [Fact] public async Task StateIsolation_MultiConcurrency_UsesAsyncLocal() { - // Initialize in multi-concurrency mode on main thread - ResponseStreamFactory.InitializeInvocation("req-9", MaxResponseSize, isMultiConcurrency: true); + var mock = new MockStreamingRuntimeApiClient(); + InitializeWithMock("req-9", isMultiConcurrency: true, mock); var stream = ResponseStreamFactory.CreateStream(); Assert.NotNull(stream); - // A separate task should not see the main thread's context - // (AsyncLocal flows to child tasks, but a fresh Task.Run with new initialization should override) bool childSawNull = false; await Task.Run(() => { - // Clean up the flowed context first ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); childSawNull = ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: true) == null; }); diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/TestHelpers/NoOpInternalRuntimeApiClient.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/TestHelpers/NoOpInternalRuntimeApiClient.cs new file mode 100644 index 000000000..9fa0434cd --- /dev/null +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/TestHelpers/NoOpInternalRuntimeApiClient.cs @@ -0,0 +1,60 @@ +/* + * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Amazon.Lambda.RuntimeSupport.UnitTests.TestHelpers +{ + /// + /// A no-op implementation of IInternalRuntimeApiClient for unit tests + /// that need to construct a RuntimeApiClient without real HTTP calls. + /// + internal class NoOpInternalRuntimeApiClient : IInternalRuntimeApiClient + { + private static readonly SwaggerResponse EmptyStatusResponse = + new SwaggerResponse(200, new Dictionary>(), new StatusResponse()); + + public Task> ErrorAsync( + string lambda_Runtime_Function_Error_Type, string errorJson, CancellationToken cancellationToken) + => Task.FromResult(EmptyStatusResponse); + + public Task> NextAsync(CancellationToken cancellationToken) + => Task.FromResult(new SwaggerResponse(200, new Dictionary>(), Stream.Null)); + + public Task> ResponseAsync(string awsRequestId, Stream outputStream) + => Task.FromResult(EmptyStatusResponse); + + public Task> ResponseAsync( + string awsRequestId, Stream outputStream, CancellationToken cancellationToken) + => Task.FromResult(EmptyStatusResponse); + + public Task> ErrorWithXRayCauseAsync( + string awsRequestId, string lambda_Runtime_Function_Error_Type, + string errorJson, string xrayCause, CancellationToken cancellationToken) + => Task.FromResult(EmptyStatusResponse); + +#if NET8_0_OR_GREATER + public Task> RestoreNextAsync(CancellationToken cancellationToken) + => Task.FromResult(new SwaggerResponse(200, new Dictionary>(), Stream.Null)); + + public Task> RestoreErrorAsync( + string lambda_Runtime_Function_Error_Type, string errorJson, CancellationToken cancellationToken) + => Task.FromResult(EmptyStatusResponse); +#endif + } +} From 63224bf43b63d1edee3b92576d70c965bbaa60e7 Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Mon, 16 Feb 2026 23:44:57 -0800 Subject: [PATCH 05/20] Task 6 --- .../RuntimeApiClientTests.cs | 223 ++++ .../serverless.template | 659 +--------- .../serverless.template | 22 +- .../TestServerlessApp/serverless.template | 1149 +---------------- 4 files changed, 228 insertions(+), 1825 deletions(-) create mode 100644 Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/RuntimeApiClientTests.cs diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/RuntimeApiClientTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/RuntimeApiClientTests.cs new file mode 100644 index 000000000..75abec101 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/RuntimeApiClientTests.cs @@ -0,0 +1,223 @@ +/* + * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +using System; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Amazon.Lambda.RuntimeSupport.UnitTests +{ + /// + /// Tests for RuntimeApiClient streaming and buffered behavior. + /// Validates Properties 7, 8, 10, 13, 18. + /// + public class RuntimeApiClientTests + { + private const long MaxResponseSize = 20 * 1024 * 1024; + + /// + /// Mock HttpMessageHandler that captures the request for header inspection. + /// It completes the ResponseStream and returns immediately without reading + /// the content body, avoiding the SerializeToStreamAsync blocking issue. + /// + private class MockHttpMessageHandler : HttpMessageHandler + { + public HttpRequestMessage CapturedRequest { get; private set; } + private readonly ResponseStream _responseStream; + + public MockHttpMessageHandler(ResponseStream responseStream) + { + _responseStream = responseStream; + } + + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + CapturedRequest = request; + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); + } + } + + private static RuntimeApiClient CreateClientWithMockHandler( + ResponseStream stream, out MockHttpMessageHandler handler) + { + handler = new MockHttpMessageHandler(stream); + var httpClient = new HttpClient(handler); + var envVars = new TestEnvironmentVariables(); + envVars.SetEnvironmentVariable("AWS_LAMBDA_RUNTIME_API", "localhost:9001"); + return new RuntimeApiClient(envVars, httpClient); + } + + // --- Property 7: Streaming Response Mode Header --- + + /// + /// Property 7: Streaming Response Mode Header + /// For any streaming response, the HTTP request should include + /// "Lambda-Runtime-Function-Response-Mode: streaming". + /// **Validates: Requirements 4.1** + /// + [Fact] + public async Task StartStreamingResponseAsync_IncludesStreamingResponseModeHeader() + { + var stream = new ResponseStream(MaxResponseSize); + var client = CreateClientWithMockHandler(stream, out var handler); + + await client.StartStreamingResponseAsync("req-1", stream, CancellationToken.None); + + Assert.NotNull(handler.CapturedRequest); + Assert.True(handler.CapturedRequest.Headers.Contains(StreamingConstants.ResponseModeHeader)); + var values = handler.CapturedRequest.Headers.GetValues(StreamingConstants.ResponseModeHeader).ToList(); + Assert.Single(values); + Assert.Equal(StreamingConstants.StreamingResponseMode, values[0]); + } + + // --- Property 8: Chunked Transfer Encoding Header --- + + /// + /// Property 8: Chunked Transfer Encoding Header + /// For any streaming response, the HTTP request should include + /// "Transfer-Encoding: chunked". + /// **Validates: Requirements 4.2** + /// + [Fact] + public async Task StartStreamingResponseAsync_IncludesChunkedTransferEncodingHeader() + { + var stream = new ResponseStream(MaxResponseSize); + var client = CreateClientWithMockHandler(stream, out var handler); + + await client.StartStreamingResponseAsync("req-2", stream, CancellationToken.None); + + Assert.NotNull(handler.CapturedRequest); + Assert.True(handler.CapturedRequest.Headers.TransferEncodingChunked); + } + + // --- Property 13: Trailer Declaration Header --- + + /// + /// Property 13: Trailer Declaration Header + /// For any streaming response, the HTTP request should include a "Trailer" header + /// declaring the error trailer headers upfront (since we cannot know at request + /// start whether an error will occur). + /// **Validates: Requirements 5.4** + /// + [Fact] + public async Task StartStreamingResponseAsync_DeclaresTrailerHeaderUpfront() + { + var stream = new ResponseStream(MaxResponseSize); + var client = CreateClientWithMockHandler(stream, out var handler); + + await client.StartStreamingResponseAsync("req-3", stream, CancellationToken.None); + + Assert.NotNull(handler.CapturedRequest); + Assert.True(handler.CapturedRequest.Headers.Contains("Trailer")); + var trailerValue = string.Join(", ", handler.CapturedRequest.Headers.GetValues("Trailer")); + Assert.Contains(StreamingConstants.ErrorTypeTrailer, trailerValue); + Assert.Contains(StreamingConstants.ErrorBodyTrailer, trailerValue); + } + + // --- Property 18: Stream Finalization --- + + /// + /// Property 18: Stream Finalization + /// For any streaming response that completes successfully, the ResponseStream + /// should be marked as completed (IsCompleted = true) after the HTTP response succeeds. + /// **Validates: Requirements 8.3** + /// + [Fact] + public async Task StartStreamingResponseAsync_MarksStreamCompletedAfterSuccess() + { + var stream = new ResponseStream(MaxResponseSize); + var client = CreateClientWithMockHandler(stream, out _); + + await client.StartStreamingResponseAsync("req-4", stream, CancellationToken.None); + + Assert.True(stream.IsCompleted); + } + + // --- Property 10: Buffered Responses Exclude Streaming Headers --- + + /// + /// Mock HttpMessageHandler that captures the request for buffered response header inspection. + /// Returns an Accepted (202) response since that's what the InternalRuntimeApiClient expects. + /// + private class BufferedMockHttpMessageHandler : HttpMessageHandler + { + public HttpRequestMessage CapturedRequest { get; private set; } + + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + CapturedRequest = request; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.Accepted)); + } + } + + /// + /// Property 10: Buffered Responses Exclude Streaming Headers + /// For any buffered response (where CreateStream was not called), the HTTP request + /// should not include "Lambda-Runtime-Function-Response-Mode" or + /// "Transfer-Encoding: chunked" or "Trailer" headers. + /// **Validates: Requirements 4.6** + /// + [Fact] + public async Task SendResponseAsync_BufferedResponse_ExcludesStreamingHeaders() + { + var bufferedHandler = new BufferedMockHttpMessageHandler(); + var httpClient = new HttpClient(bufferedHandler); + var envVars = new TestEnvironmentVariables(); + envVars.SetEnvironmentVariable("AWS_LAMBDA_RUNTIME_API", "localhost:9001"); + var client = new RuntimeApiClient(envVars, httpClient); + + var outputStream = new MemoryStream(new byte[] { 1, 2, 3 }); + await client.SendResponseAsync("req-buffered", outputStream, CancellationToken.None); + + Assert.NotNull(bufferedHandler.CapturedRequest); + // Buffered responses must not include streaming-specific headers + Assert.False(bufferedHandler.CapturedRequest.Headers.Contains(StreamingConstants.ResponseModeHeader), + "Buffered response should not include Lambda-Runtime-Function-Response-Mode header"); + Assert.NotEqual(true, bufferedHandler.CapturedRequest.Headers.TransferEncodingChunked); + Assert.False(bufferedHandler.CapturedRequest.Headers.Contains("Trailer"), + "Buffered response should not include Trailer header"); + } + + // --- Argument validation --- + + [Fact] + public async Task StartStreamingResponseAsync_NullRequestId_ThrowsArgumentNullException() + { + var stream = new ResponseStream(MaxResponseSize); + var client = CreateClientWithMockHandler(stream, out _); + + await Assert.ThrowsAsync( + () => client.StartStreamingResponseAsync(null, stream, CancellationToken.None)); + } + + [Fact] + public async Task StartStreamingResponseAsync_NullResponseStream_ThrowsArgumentNullException() + { + var stream = new ResponseStream(MaxResponseSize); + var client = CreateClientWithMockHandler(stream, out _); + + await Assert.ThrowsAsync( + () => client.StartStreamingResponseAsync("req-5", null, CancellationToken.None)); + } + } +} diff --git a/Libraries/test/TestExecutableServerlessApp/serverless.template b/Libraries/test/TestExecutableServerlessApp/serverless.template index ac43959b7..229385aba 100644 --- a/Libraries/test/TestExecutableServerlessApp/serverless.template +++ b/Libraries/test/TestExecutableServerlessApp/serverless.template @@ -1,7 +1,7 @@ { "AWSTemplateFormatVersion": "2010-09-09", "Transform": "AWS::Serverless-2016-10-31", - "Description": "An AWS Serverless Application. This template is partially managed by Amazon.Lambda.Annotations (v1.9.0.0).", + "Description": "An AWS Serverless Application. This template is partially managed by Amazon.Lambda.Annotations (v1.8.0.0).", "Parameters": { "ArchitectureTypeParameter": { "Type": "String", @@ -21,662 +21,7 @@ ] } }, - "Resources": { - "TestServerlessAppCustomizeResponseExamplesOkResponseWithHeaderGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestExecutableServerlessApp" - ] - }, - "Environment": { - "Variables": { - "ANNOTATIONS_HANDLER": "OkResponseWithHeader" - } - }, - "Events": { - "RootGet": { - "Type": "Api", - "Properties": { - "Path": "/okresponsewithheader/{x}", - "Method": "GET" - } - } - } - } - }, - "TestServerlessAppCustomizeResponseExamplesOkResponseWithHeaderAsyncGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestExecutableServerlessApp" - ] - }, - "Environment": { - "Variables": { - "ANNOTATIONS_HANDLER": "OkResponseWithHeaderAsync" - } - }, - "Events": { - "RootGet": { - "Type": "Api", - "Properties": { - "Path": "/okresponsewithheaderasync/{x}", - "Method": "GET" - } - } - } - } - }, - "TestServerlessAppCustomizeResponseExamplesNotFoundResponseWithHeaderV2Generated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestExecutableServerlessApp" - ] - }, - "Environment": { - "Variables": { - "ANNOTATIONS_HANDLER": "NotFoundResponseWithHeaderV2" - } - }, - "Events": { - "RootGet": { - "Type": "HttpApi", - "Properties": { - "Path": "/notfoundwithheaderv2/{x}", - "Method": "GET" - } - } - } - } - }, - "TestServerlessAppCustomizeResponseExamplesNotFoundResponseWithHeaderV2AsyncGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestExecutableServerlessApp" - ] - }, - "Environment": { - "Variables": { - "ANNOTATIONS_HANDLER": "NotFoundResponseWithHeaderV2Async" - } - }, - "Events": { - "RootGet": { - "Type": "HttpApi", - "Properties": { - "Path": "/notfoundwithheaderv2async/{x}", - "Method": "GET" - } - } - } - } - }, - "TestServerlessAppCustomizeResponseExamplesNotFoundResponseWithHeaderV1Generated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method", - "PayloadFormatVersion" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestExecutableServerlessApp" - ] - }, - "Environment": { - "Variables": { - "ANNOTATIONS_HANDLER": "NotFoundResponseWithHeaderV1" - } - }, - "Events": { - "RootGet": { - "Type": "HttpApi", - "Properties": { - "Path": "/notfoundwithheaderv1/{x}", - "Method": "GET", - "PayloadFormatVersion": "1.0" - } - } - } - } - }, - "TestServerlessAppCustomizeResponseExamplesNotFoundResponseWithHeaderV1AsyncGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method", - "PayloadFormatVersion" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestExecutableServerlessApp" - ] - }, - "Environment": { - "Variables": { - "ANNOTATIONS_HANDLER": "NotFoundResponseWithHeaderV1Async" - } - }, - "Events": { - "RootGet": { - "Type": "HttpApi", - "Properties": { - "Path": "/notfoundwithheaderv1async/{x}", - "Method": "GET", - "PayloadFormatVersion": "1.0" - } - } - } - } - }, - "TestServerlessAppDynamicExampleDynamicReturnGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations" - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestExecutableServerlessApp" - ] - }, - "Environment": { - "Variables": { - "ANNOTATIONS_HANDLER": "DynamicReturn" - } - } - } - }, - "TestServerlessAppDynamicExampleDynamicInputGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations" - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestExecutableServerlessApp" - ] - }, - "Environment": { - "Variables": { - "ANNOTATIONS_HANDLER": "DynamicInput" - } - } - } - }, - "GreeterSayHello": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method", - "PayloadFormatVersion" - ] - } - }, - "Properties": { - "MemorySize": 1024, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestExecutableServerlessApp" - ] - }, - "Environment": { - "Variables": { - "ANNOTATIONS_HANDLER": "SayHello" - } - }, - "Events": { - "RootGet": { - "Type": "HttpApi", - "Properties": { - "Path": "/Greeter/SayHello", - "Method": "GET", - "PayloadFormatVersion": "1.0" - } - } - } - } - }, - "GreeterSayHelloAsync": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method", - "PayloadFormatVersion" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 50, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestExecutableServerlessApp" - ] - }, - "Environment": { - "Variables": { - "ANNOTATIONS_HANDLER": "SayHelloAsync" - } - }, - "Events": { - "RootGet": { - "Type": "HttpApi", - "Properties": { - "Path": "/Greeter/SayHelloAsync", - "Method": "GET", - "PayloadFormatVersion": "1.0" - } - } - } - } - }, - "TestServerlessAppIntrinsicExampleHasIntrinsicGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations" - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestExecutableServerlessApp" - ] - }, - "Environment": { - "Variables": { - "ANNOTATIONS_HANDLER": "HasIntrinsic" - } - } - } - }, - "TestServerlessAppNullableReferenceTypeExampleNullableHeaderHttpApiGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestExecutableServerlessApp" - ] - }, - "Environment": { - "Variables": { - "ANNOTATIONS_HANDLER": "NullableHeaderHttpApi" - } - }, - "Events": { - "RootGet": { - "Type": "HttpApi", - "Properties": { - "Path": "/nullableheaderhttpapi", - "Method": "GET" - } - } - } - } - }, - "TestServerlessAppParameterlessMethodsNoParameterGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations" - }, - "Properties": { - "Runtime": "provided.al2", - "CodeUri": ".", - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Zip", - "Handler": "TestExecutableServerlessApp", - "Environment": { - "Variables": { - "ANNOTATIONS_HANDLER": "NoParameter" - } - } - } - }, - "TestServerlessAppParameterlessMethodWithResponseNoParameterWithResponseGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations" - }, - "Properties": { - "Runtime": "provided.al2", - "CodeUri": ".", - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Zip", - "Handler": "TestExecutableServerlessApp", - "Environment": { - "Variables": { - "ANNOTATIONS_HANDLER": "NoParameterWithResponse" - } - } - } - }, - "TestExecutableServerlessAppSourceGenerationSerializationExampleGetPersonGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestExecutableServerlessApp" - ] - }, - "Environment": { - "Variables": { - "ANNOTATIONS_HANDLER": "GetPerson" - } - }, - "Events": { - "RootGet": { - "Type": "Api", - "Properties": { - "Path": "/", - "Method": "GET" - } - } - } - } - }, - "ToUpper": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations" - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestExecutableServerlessApp" - ] - }, - "Environment": { - "Variables": { - "ANNOTATIONS_HANDLER": "ToUpper" - } - } - } - }, - "ToLower": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations" - }, - "Properties": { - "Runtime": "provided.al2", - "CodeUri": ".", - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Zip", - "Handler": "TestExecutableServerlessApp", - "Environment": { - "Variables": { - "ANNOTATIONS_HANDLER": "ToLower" - } - } - } - }, - "TestServerlessAppTaskExampleTaskReturnGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations" - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestExecutableServerlessApp" - ] - }, - "Environment": { - "Variables": { - "ANNOTATIONS_HANDLER": "TaskReturn" - } - } - } - }, - "TestServerlessAppVoidExampleVoidReturnGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations" - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestExecutableServerlessApp" - ] - }, - "Environment": { - "Variables": { - "ANNOTATIONS_HANDLER": "VoidReturn" - } - } - } - } - }, + "Resources": {}, "Outputs": { "RestApiURL": { "Description": "Rest API endpoint URL for Prod environment", diff --git a/Libraries/test/TestServerlessApp.NET8/serverless.template b/Libraries/test/TestServerlessApp.NET8/serverless.template index c42ff4a47..67ec5dfa4 100644 --- a/Libraries/test/TestServerlessApp.NET8/serverless.template +++ b/Libraries/test/TestServerlessApp.NET8/serverless.template @@ -1,24 +1,6 @@ { "AWSTemplateFormatVersion": "2010-09-09", "Transform": "AWS::Serverless-2016-10-31", - "Description": "This template is partially managed by Amazon.Lambda.Annotations (v1.9.0.0).", - "Resources": { - "TestServerlessAppNET8FunctionsToUpperGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations" - }, - "Properties": { - "Runtime": "dotnet8", - "CodeUri": ".", - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Zip", - "Handler": "TestServerlessApp.NET8::TestServerlessApp.NET8.Functions_ToUpper_Generated::ToUpper" - } - } - } + "Description": "This template is partially managed by Amazon.Lambda.Annotations (v1.8.0.0).", + "Resources": {} } \ No newline at end of file diff --git a/Libraries/test/TestServerlessApp/serverless.template b/Libraries/test/TestServerlessApp/serverless.template index 0e3befbe1..e6c1b8bea 100644 --- a/Libraries/test/TestServerlessApp/serverless.template +++ b/Libraries/test/TestServerlessApp/serverless.template @@ -1,7 +1,7 @@ { "AWSTemplateFormatVersion": "2010-09-09", "Transform": "AWS::Serverless-2016-10-31", - "Description": "An AWS Serverless Application. This template is partially managed by Amazon.Lambda.Annotations (v1.9.0.0).", + "Description": "An AWS Serverless Application. This template is partially managed by Amazon.Lambda.Annotations (v1.8.0.0).", "Parameters": { "ArchitectureTypeParameter": { "Type": "String", @@ -28,1153 +28,6 @@ "Resources": { "TestQueue": { "Type": "AWS::SQS::Queue" - }, - "TestServerlessAppDynamicExampleDynamicReturnGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations" - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.DynamicExample_DynamicReturn_Generated::DynamicReturn" - ] - } - } - }, - "TestServerlessAppDynamicExampleDynamicInputGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations" - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.DynamicExample_DynamicInput_Generated::DynamicInput" - ] - } - } - }, - "TestServerlessAppFromScratchNoApiGatewayEventsReferenceToUpperGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.FromScratch.NoApiGatewayEventsReference_ToUpper_Generated::ToUpper" - ] - }, - "Events": { - "RootGet": { - "Type": "HttpApi", - "Properties": { - "Path": "/{text}", - "Method": "GET" - } - } - } - } - }, - "HttpApiAuthorizerTest": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.CustomAuthorizerHttpApiExample_HttpApiAuthorizer_Generated::HttpApiAuthorizer" - ] - }, - "Events": { - "RootGet": { - "Type": "HttpApi", - "Properties": { - "Path": "/api/authorizer", - "Method": "GET" - } - } - } - } - }, - "SimpleCalculatorAdd": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.SimpleCalculator_Add_Generated::Add" - ] - }, - "Events": { - "RootGet": { - "Type": "Api", - "Properties": { - "Path": "/SimpleCalculator/Add", - "Method": "GET" - } - } - } - } - }, - "SimpleCalculatorSubtract": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.SimpleCalculator_Subtract_Generated::Subtract" - ] - }, - "Events": { - "RootGet": { - "Type": "Api", - "Properties": { - "Path": "/SimpleCalculator/Subtract", - "Method": "GET" - } - } - } - } - }, - "SimpleCalculatorMultiply": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.SimpleCalculator_Multiply_Generated::Multiply" - ] - }, - "Events": { - "RootGet": { - "Type": "Api", - "Properties": { - "Path": "/SimpleCalculator/Multiply/{x}/{y}", - "Method": "GET" - } - } - } - } - }, - "SimpleCalculatorDivideAsync": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.SimpleCalculator_DivideAsync_Generated::DivideAsync" - ] - }, - "Events": { - "RootGet": { - "Type": "Api", - "Properties": { - "Path": "/SimpleCalculator/DivideAsync/{x}/{y}", - "Method": "GET" - } - } - } - } - }, - "PI": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations" - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.SimpleCalculator_Pi_Generated::Pi" - ] - } - } - }, - "Random": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations" - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.SimpleCalculator_Random_Generated::Random" - ] - } - } - }, - "Randoms": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations" - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.SimpleCalculator_Randoms_Generated::Randoms" - ] - } - } - }, - "SQSMessageHandler": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "TestQueueEvent" - ], - "SyncedEventProperties": { - "TestQueueEvent": [ - "Queue.Fn::GetAtt", - "BatchSize", - "FilterCriteria.Filters", - "FunctionResponseTypes", - "MaximumBatchingWindowInSeconds", - "ScalingConfig.MaximumConcurrency" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaSQSQueueExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.SqsMessageProcessing_HandleMessage_Generated::HandleMessage" - ] - }, - "Events": { - "TestQueueEvent": { - "Type": "SQS", - "Properties": { - "BatchSize": 50, - "FilterCriteria": { - "Filters": [ - { - "Pattern": "{ \"body\" : { \"RequestCode\" : [ \"BBBB\" ] } }" - } - ] - }, - "FunctionResponseTypes": [ - "ReportBatchItemFailures" - ], - "MaximumBatchingWindowInSeconds": 5, - "ScalingConfig": { - "MaximumConcurrency": 5 - }, - "Queue": { - "Fn::GetAtt": [ - "TestQueue", - "Arn" - ] - } - } - } - } - } - }, - "HttpApiV1AuthorizerTest": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method", - "PayloadFormatVersion" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.CustomAuthorizerHttpApiV1Example_HttpApiV1Authorizer_Generated::HttpApiV1Authorizer" - ] - }, - "Events": { - "RootGet": { - "Type": "HttpApi", - "Properties": { - "Path": "/api/authorizer-v1", - "Method": "GET", - "PayloadFormatVersion": "1.0" - } - } - } - } - }, - "TestServerlessAppNullableReferenceTypeExampleNullableHeaderHttpApiGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.NullableReferenceTypeExample_NullableHeaderHttpApi_Generated::NullableHeaderHttpApi" - ] - }, - "Events": { - "RootGet": { - "Type": "HttpApi", - "Properties": { - "Path": "/nullableheaderhttpapi", - "Method": "GET" - } - } - } - } - }, - "TestServerlessAppCustomAuthorizerWithIHttpResultsExampleAuthorizerWithIHttpResultsGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.CustomAuthorizerWithIHttpResultsExample_AuthorizerWithIHttpResults_Generated::AuthorizerWithIHttpResults" - ] - }, - "Events": { - "RootGet": { - "Type": "HttpApi", - "Properties": { - "Path": "/authorizerihttpresults", - "Method": "GET" - } - } - } - } - }, - "GreeterSayHello": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method", - "PayloadFormatVersion" - ] - } - }, - "Properties": { - "MemorySize": 1024, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.Greeter_SayHello_Generated::SayHello" - ] - }, - "Events": { - "RootGet": { - "Type": "HttpApi", - "Properties": { - "Path": "/Greeter/SayHello", - "Method": "GET", - "PayloadFormatVersion": "1.0" - } - } - } - } - }, - "GreeterSayHelloAsync": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method", - "PayloadFormatVersion" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 50, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.Greeter_SayHelloAsync_Generated::SayHelloAsync" - ] - }, - "Events": { - "RootGet": { - "Type": "HttpApi", - "Properties": { - "Path": "/Greeter/SayHelloAsync", - "Method": "GET", - "PayloadFormatVersion": "1.0" - } - } - } - } - }, - "TestServerlessAppCustomizeResponseExamplesOkResponseWithHeaderGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.CustomizeResponseExamples_OkResponseWithHeader_Generated::OkResponseWithHeader" - ] - }, - "Events": { - "RootGet": { - "Type": "Api", - "Properties": { - "Path": "/okresponsewithheader/{x}", - "Method": "GET" - } - } - } - } - }, - "TestServerlessAppCustomizeResponseExamplesOkResponseWithHeaderAsyncGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.CustomizeResponseExamples_OkResponseWithHeaderAsync_Generated::OkResponseWithHeaderAsync" - ] - }, - "Events": { - "RootGet": { - "Type": "Api", - "Properties": { - "Path": "/okresponsewithheaderasync/{x}", - "Method": "GET" - } - } - } - } - }, - "TestServerlessAppCustomizeResponseExamplesNotFoundResponseWithHeaderV2Generated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.CustomizeResponseExamples_NotFoundResponseWithHeaderV2_Generated::NotFoundResponseWithHeaderV2" - ] - }, - "Events": { - "RootGet": { - "Type": "HttpApi", - "Properties": { - "Path": "/notfoundwithheaderv2/{x}", - "Method": "GET" - } - } - } - } - }, - "TestServerlessAppCustomizeResponseExamplesNotFoundResponseWithHeaderV2AsyncGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.CustomizeResponseExamples_NotFoundResponseWithHeaderV2Async_Generated::NotFoundResponseWithHeaderV2Async" - ] - }, - "Events": { - "RootGet": { - "Type": "HttpApi", - "Properties": { - "Path": "/notfoundwithheaderv2async/{x}", - "Method": "GET" - } - } - } - } - }, - "TestServerlessAppCustomizeResponseExamplesNotFoundResponseWithHeaderV1Generated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method", - "PayloadFormatVersion" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.CustomizeResponseExamples_NotFoundResponseWithHeaderV1_Generated::NotFoundResponseWithHeaderV1" - ] - }, - "Events": { - "RootGet": { - "Type": "HttpApi", - "Properties": { - "Path": "/notfoundwithheaderv1/{x}", - "Method": "GET", - "PayloadFormatVersion": "1.0" - } - } - } - } - }, - "TestServerlessAppCustomizeResponseExamplesNotFoundResponseWithHeaderV1AsyncGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method", - "PayloadFormatVersion" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.CustomizeResponseExamples_NotFoundResponseWithHeaderV1Async_Generated::NotFoundResponseWithHeaderV1Async" - ] - }, - "Events": { - "RootGet": { - "Type": "HttpApi", - "Properties": { - "Path": "/notfoundwithheaderv1async/{x}", - "Method": "GET", - "PayloadFormatVersion": "1.0" - } - } - } - } - }, - "TestServerlessAppCustomizeResponseExamplesOkResponseWithCustomSerializerGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method", - "PayloadFormatVersion" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.CustomizeResponseExamples_OkResponseWithCustomSerializer_Generated::OkResponseWithCustomSerializer" - ] - }, - "Events": { - "RootGet": { - "Type": "HttpApi", - "Properties": { - "Path": "/okresponsewithcustomserializerasync/{firstName}/{lastName}", - "Method": "GET", - "PayloadFormatVersion": "1.0" - } - } - } - } - }, - "TestServerlessAppFromScratchNoSerializerAttributeReferenceToUpperGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations" - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.FromScratch.NoSerializerAttributeReference_ToUpper_Generated::ToUpper" - ] - } - } - }, - "TestServerlessAppIntrinsicExampleHasIntrinsicGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations" - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.IntrinsicExample_HasIntrinsic_Generated::HasIntrinsic" - ] - } - } - }, - "HttpApiNonString": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.CustomAuthorizerNonStringExample_HttpApiWithNonString_Generated::HttpApiWithNonString" - ] - }, - "Events": { - "RootGet": { - "Type": "HttpApi", - "Properties": { - "Path": "/api/authorizer-non-string", - "Method": "GET" - } - } - } - } - }, - "AuthNameFallbackTest": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.AuthNameFallback_GetUserId_Generated::GetUserId" - ] - }, - "Events": { - "RootGet": { - "Type": "HttpApi", - "Properties": { - "Path": "/api/authorizer-fallback", - "Method": "GET" - } - } - } - } - }, - "TestServerlessAppVoidExampleVoidReturnGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations" - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.VoidExample_VoidReturn_Generated::VoidReturn" - ] - } - } - }, - "RestAuthorizerTest": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.CustomAuthorizerRestExample_RestAuthorizer_Generated::RestAuthorizer" - ] - }, - "Events": { - "RootGet": { - "Type": "Api", - "Properties": { - "Path": "/rest/authorizer", - "Method": "GET" - } - } - } - } - }, - "TestServerlessAppTaskExampleTaskReturnGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations" - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.TaskExample_TaskReturn_Generated::TaskReturn" - ] - } - } - }, - "ToUpper": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations" - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.Sub1.Functions_ToUpper_Generated::ToUpper" - ] - } - } - }, - "TestServerlessAppComplexCalculatorAddGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootPost" - ], - "SyncedEventProperties": { - "RootPost": [ - "Path", - "Method" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.ComplexCalculator_Add_Generated::Add" - ] - }, - "Events": { - "RootPost": { - "Type": "HttpApi", - "Properties": { - "Path": "/ComplexCalculator/Add", - "Method": "POST" - } - } - } - } - }, - "TestServerlessAppComplexCalculatorSubtractGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootPost" - ], - "SyncedEventProperties": { - "RootPost": [ - "Path", - "Method" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.ComplexCalculator_Subtract_Generated::Subtract" - ] - }, - "Events": { - "RootPost": { - "Type": "HttpApi", - "Properties": { - "Path": "/ComplexCalculator/Subtract", - "Method": "POST" - } - } - } - } } }, "Outputs": { From 14c993b08f6c6d44420f085d1d0a5c29ee9d1030 Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Tue, 17 Feb 2026 18:46:16 -0800 Subject: [PATCH 06/20] Task 8 --- .../Bootstrap/LambdaBootstrap.cs | 45 ++++- .../LambdaBootstrapTests.cs | 156 ++++++++++++++++++ .../TestStreamingRuntimeApiClient.cs | 131 +++++++++++++++ 3 files changed, 330 insertions(+), 2 deletions(-) create mode 100644 Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/TestHelpers/TestStreamingRuntimeApiClient.cs diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/LambdaBootstrap.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/LambdaBootstrap.cs index 0e00f3e7f..68b67c339 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/LambdaBootstrap.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/LambdaBootstrap.cs @@ -349,6 +349,7 @@ internal async Task InvokeOnceAsync(CancellationToken cancellationToken = defaul _logger.LogInformation("Starting InvokeOnceAsync"); var invocation = await Client.GetNextInvocationAsync(cancellationToken); + var isMultiConcurrency = Utils.IsUsingMultiConcurrency(_environmentVariables); Func processingFunc = async () => { @@ -358,6 +359,18 @@ internal async Task InvokeOnceAsync(CancellationToken cancellationToken = defaul SetInvocationTraceId(impl.RuntimeApiHeaders.TraceId); } + // Initialize ResponseStreamFactory — includes RuntimeApiClient reference + var runtimeApiClient = Client as RuntimeApiClient; + if (runtimeApiClient != null) + { + ResponseStreamFactory.InitializeInvocation( + invocation.LambdaContext.AwsRequestId, + StreamingConstants.MaxResponseSize, + isMultiConcurrency, + runtimeApiClient, + cancellationToken); + } + try { InvocationResponse response = null; @@ -372,15 +385,39 @@ internal async Task InvokeOnceAsync(CancellationToken cancellationToken = defaul catch (Exception exception) { WriteUnhandledExceptionToLog(exception); - await Client.ReportInvocationErrorAsync(invocation.LambdaContext.AwsRequestId, exception, cancellationToken); + + var streamIfCreated = ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency); + if (streamIfCreated != null && streamIfCreated.BytesWritten > 0) + { + // Midstream error — report via trailers on the already-open HTTP connection + await streamIfCreated.ReportErrorAsync(exception); + } + else + { + // Error before streaming started — use standard error reporting + await Client.ReportInvocationErrorAsync(invocation.LambdaContext.AwsRequestId, exception, cancellationToken); + } } finally { _logger.LogInformation("Finished invoking handler"); } - if (invokeSucceeded) + // If streaming was started, await the HTTP send task to ensure it completes + var sendTask = ResponseStreamFactory.GetSendTask(isMultiConcurrency); + if (sendTask != null) { + var stream = ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency); + if (stream != null && !stream.IsCompleted && !stream.HasError) + { + // Handler returned successfully — signal stream completion + stream.MarkCompleted(); + } + await sendTask; // Wait for HTTP request to finish + } + else if (invokeSucceeded) + { + // No streaming — send buffered response _logger.LogInformation("Starting sending response"); try { @@ -415,6 +452,10 @@ internal async Task InvokeOnceAsync(CancellationToken cancellationToken = defaul } finally { + if (runtimeApiClient != null) + { + ResponseStreamFactory.CleanupInvocation(isMultiConcurrency); + } invocation.Dispose(); } }; diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/LambdaBootstrapTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/LambdaBootstrapTests.cs index e1636ff16..07c2379a0 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/LambdaBootstrapTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/LambdaBootstrapTests.cs @@ -14,9 +14,11 @@ */ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Net.Http; using System.Text; +using System.Threading; using System.Threading.Tasks; using Xunit; @@ -283,5 +285,159 @@ public void IsCallPreJitTest() environmentVariables.SetEnvironmentVariable(ENVIRONMENT_VARIABLE_AWS_LAMBDA_INITIALIZATION_TYPE, AWS_LAMBDA_INITIALIZATION_TYPE_PC); Assert.True(UserCodeInit.IsCallPreJit(environmentVariables)); } + + // --- Streaming Integration Tests --- + + private TestStreamingRuntimeApiClient CreateStreamingClient() + { + var envVars = new TestEnvironmentVariables(); + var headers = new Dictionary> + { + { RuntimeApiHeaders.HeaderAwsRequestId, new List { "streaming-request-id" } }, + { RuntimeApiHeaders.HeaderInvokedFunctionArn, new List { "invoked_function_arn" } }, + { RuntimeApiHeaders.HeaderAwsTenantId, new List { "tenant_id" } } + }; + return new TestStreamingRuntimeApiClient(envVars, headers); + } + + /// + /// Property 2: CreateStream Enables Streaming Mode + /// When a handler calls ResponseStreamFactory.CreateStream(), the response is transmitted + /// using streaming mode. LambdaBootstrap awaits the send task. + /// **Validates: Requirements 1.4, 6.1, 6.2, 6.3, 6.4** + /// + [Fact] + public async Task StreamingMode_HandlerCallsCreateStream_SendTaskAwaited() + { + var streamingClient = CreateStreamingClient(); + + LambdaBootstrapHandler handler = async (invocation) => + { + var stream = ResponseStreamFactory.CreateStream(); + await stream.WriteAsync(Encoding.UTF8.GetBytes("hello")); + return new InvocationResponse(Stream.Null, false); + }; + + using (var bootstrap = new LambdaBootstrap(handler, null)) + { + bootstrap.Client = streamingClient; + await bootstrap.InvokeOnceAsync(); + } + + Assert.True(streamingClient.StartStreamingResponseAsyncCalled); + Assert.False(streamingClient.SendResponseAsyncCalled); + } + + /// + /// Property 3: Default Mode Is Buffered + /// When a handler does not call ResponseStreamFactory.CreateStream(), the response + /// is transmitted using buffered mode via SendResponseAsync. + /// **Validates: Requirements 1.5, 7.2** + /// + [Fact] + public async Task BufferedMode_HandlerDoesNotCallCreateStream_UsesSendResponse() + { + var streamingClient = CreateStreamingClient(); + + LambdaBootstrapHandler handler = async (invocation) => + { + var outputStream = new MemoryStream(Encoding.UTF8.GetBytes("buffered response")); + return new InvocationResponse(outputStream); + }; + + using (var bootstrap = new LambdaBootstrap(handler, null)) + { + bootstrap.Client = streamingClient; + await bootstrap.InvokeOnceAsync(); + } + + Assert.False(streamingClient.StartStreamingResponseAsyncCalled); + Assert.True(streamingClient.SendResponseAsyncCalled); + } + + /// + /// Property 14: Exception After Writes Uses Trailers + /// When a handler throws an exception after writing data to an IResponseStream, + /// the error is reported via trailers (ReportErrorAsync) rather than standard error reporting. + /// **Validates: Requirements 5.6, 5.7** + /// + [Fact] + public async Task MidstreamError_ExceptionAfterWrites_ReportsViaTrailers() + { + var streamingClient = CreateStreamingClient(); + + LambdaBootstrapHandler handler = async (invocation) => + { + var stream = ResponseStreamFactory.CreateStream(); + await stream.WriteAsync(Encoding.UTF8.GetBytes("partial data")); + throw new InvalidOperationException("midstream failure"); + }; + + using (var bootstrap = new LambdaBootstrap(handler, null)) + { + bootstrap.Client = streamingClient; + await bootstrap.InvokeOnceAsync(); + } + + // Error should be reported via trailers on the stream, not via standard error reporting + Assert.True(streamingClient.StartStreamingResponseAsyncCalled); + Assert.NotNull(streamingClient.LastStreamingResponseStream); + Assert.True(streamingClient.LastStreamingResponseStream.HasError); + Assert.False(streamingClient.ReportInvocationErrorAsyncExceptionCalled); + } + + /// + /// Property 15: Exception Before CreateStream Uses Standard Error + /// When a handler throws an exception before calling ResponseStreamFactory.CreateStream(), + /// the error is reported using the standard Lambda error reporting mechanism. + /// **Validates: Requirements 5.7, 7.1** + /// + [Fact] + public async Task PreStreamError_ExceptionBeforeCreateStream_UsesStandardErrorReporting() + { + var streamingClient = CreateStreamingClient(); + + LambdaBootstrapHandler handler = async (invocation) => + { + await Task.Yield(); + throw new InvalidOperationException("pre-stream failure"); + }; + + using (var bootstrap = new LambdaBootstrap(handler, null)) + { + bootstrap.Client = streamingClient; + await bootstrap.InvokeOnceAsync(); + } + + Assert.False(streamingClient.StartStreamingResponseAsyncCalled); + Assert.True(streamingClient.ReportInvocationErrorAsyncExceptionCalled); + } + + /// + /// State Isolation: ResponseStreamFactory state is cleared after each invocation. + /// **Validates: Requirements 6.5, 8.9** + /// + [Fact] + public async Task Cleanup_ResponseStreamFactoryStateCleared_AfterInvocation() + { + var streamingClient = CreateStreamingClient(); + + LambdaBootstrapHandler handler = async (invocation) => + { + var stream = ResponseStreamFactory.CreateStream(); + await stream.WriteAsync(Encoding.UTF8.GetBytes("data")); + return new InvocationResponse(Stream.Null, false); + }; + + using (var bootstrap = new LambdaBootstrap(handler, null)) + { + bootstrap.Client = streamingClient; + await bootstrap.InvokeOnceAsync(); + } + + // After invocation, factory state should be cleaned up + Assert.Null(ResponseStreamFactory.GetStreamIfCreated(false)); + Assert.Null(ResponseStreamFactory.GetSendTask(false)); + } } } diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/TestHelpers/TestStreamingRuntimeApiClient.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/TestHelpers/TestStreamingRuntimeApiClient.cs new file mode 100644 index 000000000..1128bb075 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/TestHelpers/TestStreamingRuntimeApiClient.cs @@ -0,0 +1,131 @@ +/* + * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +using Amazon.Lambda.RuntimeSupport.Helpers; +using Amazon.Lambda.RuntimeSupport.UnitTests.TestHelpers; +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Amazon.Lambda.RuntimeSupport.UnitTests +{ + /// + /// A RuntimeApiClient subclass for testing LambdaBootstrap streaming integration. + /// Extends RuntimeApiClient so the (RuntimeApiClient)Client cast in LambdaBootstrap works. + /// Overrides StartStreamingResponseAsync to avoid real HTTP calls. + /// + internal class TestStreamingRuntimeApiClient : RuntimeApiClient, IRuntimeApiClient + { + private readonly IEnvironmentVariables _environmentVariables; + private readonly Dictionary> _headers; + + public new IConsoleLoggerWriter ConsoleLogger { get; } = new LogLevelLoggerWriter(new SystemEnvironmentVariables()); + + public TestStreamingRuntimeApiClient(IEnvironmentVariables environmentVariables, Dictionary> headers) + : base(environmentVariables, new NoOpInternalRuntimeApiClient()) + { + _environmentVariables = environmentVariables; + _headers = headers; + } + + // Tracking flags + public bool GetNextInvocationAsyncCalled { get; private set; } + public bool ReportInitializationErrorAsyncExceptionCalled { get; private set; } + public bool ReportInvocationErrorAsyncExceptionCalled { get; private set; } + public bool SendResponseAsyncCalled { get; private set; } + public bool StartStreamingResponseAsyncCalled { get; private set; } + + public string LastTraceId { get; private set; } + public byte[] FunctionInput { get; set; } + public Stream LastOutputStream { get; private set; } + public Exception LastRecordedException { get; private set; } + public ResponseStream LastStreamingResponseStream { get; private set; } + + public new async Task GetNextInvocationAsync(CancellationToken cancellationToken = default) + { + GetNextInvocationAsyncCalled = true; + + LastTraceId = Guid.NewGuid().ToString(); + _headers[RuntimeApiHeaders.HeaderTraceId] = new List() { LastTraceId }; + + var inputStream = new MemoryStream(FunctionInput == null ? new byte[0] : FunctionInput); + inputStream.Position = 0; + + return new InvocationRequest() + { + InputStream = inputStream, + LambdaContext = new LambdaContext( + new RuntimeApiHeaders(_headers), + new LambdaEnvironment(_environmentVariables), + new TestDateTimeHelper(), new SimpleLoggerWriter(_environmentVariables)) + }; + } + + public new Task ReportInitializationErrorAsync(Exception exception, String errorType = null, CancellationToken cancellationToken = default) + { + LastRecordedException = exception; + ReportInitializationErrorAsyncExceptionCalled = true; + return Task.CompletedTask; + } + + public new Task ReportInitializationErrorAsync(string errorType, CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + + public new Task ReportInvocationErrorAsync(string awsRequestId, Exception exception, CancellationToken cancellationToken = default) + { + LastRecordedException = exception; + ReportInvocationErrorAsyncExceptionCalled = true; + return Task.CompletedTask; + } + + public new async Task SendResponseAsync(string awsRequestId, Stream outputStream, CancellationToken cancellationToken = default) + { + if (outputStream != null) + { + LastOutputStream = new MemoryStream((int)outputStream.Length); + outputStream.CopyTo(LastOutputStream); + LastOutputStream.Position = 0; + } + + SendResponseAsyncCalled = true; + } + + internal override async Task StartStreamingResponseAsync( + string awsRequestId, ResponseStream responseStream, CancellationToken cancellationToken = default) + { + StartStreamingResponseAsyncCalled = true; + LastStreamingResponseStream = responseStream; + + // Simulate the HTTP stream being available + responseStream.SetHttpOutputStream(new MemoryStream()); + + // Wait for the handler to finish writing (mirrors real SerializeToStreamAsync behavior) + await responseStream.WaitForCompletionAsync(); + } + +#if NET8_0_OR_GREATER + public new Task RestoreNextInvocationAsync(CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public new Task ReportRestoreErrorAsync(Exception exception, String errorType = null, CancellationToken cancellationToken = default) + => Task.CompletedTask; +#endif + } +} From 0b8dd52562168558f4013a5ac868e9afb907387e Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Wed, 18 Feb 2026 15:53:22 -0800 Subject: [PATCH 07/20] Task 10 --- .../HandlerTests.cs | 2 +- .../LambdaBootstrapTests.cs | 1 + .../ResponseStreamFactoryTests.cs | 1 + .../StreamingIntegrationTests.cs | 652 ++++++++++++++++++ 4 files changed, 655 insertions(+), 1 deletion(-) create mode 100644 Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingIntegrationTests.cs diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/HandlerTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/HandlerTests.cs index 80f9d13d0..e257b688e 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/HandlerTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/HandlerTests.cs @@ -31,7 +31,7 @@ namespace Amazon.Lambda.RuntimeSupport.UnitTests { - [Collection("Bootstrap")] + [Collection("ResponseStreamFactory")] public class HandlerTests { private const string AggregateExceptionTestMarker = "AggregateExceptionTesting"; diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/LambdaBootstrapTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/LambdaBootstrapTests.cs index 07c2379a0..ce922d529 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/LambdaBootstrapTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/LambdaBootstrapTests.cs @@ -31,6 +31,7 @@ namespace Amazon.Lambda.RuntimeSupport.UnitTests /// Tests to test LambdaBootstrap when it's constructed using its actual constructor. /// Tests of the static GetLambdaBootstrap methods can be found in LambdaBootstrapWrapperTests. /// + [Collection("ResponseStreamFactory")] public class LambdaBootstrapTests { readonly TestHandler _testFunction; diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamFactoryTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamFactoryTests.cs index 11973ae5f..1c714dd97 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamFactoryTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamFactoryTests.cs @@ -20,6 +20,7 @@ namespace Amazon.Lambda.RuntimeSupport.UnitTests { + [Collection("ResponseStreamFactory")] public class ResponseStreamFactoryTests : IDisposable { private const long MaxResponseSize = 20 * 1024 * 1024; diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingIntegrationTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingIntegrationTests.cs new file mode 100644 index 000000000..c2bd34bdf --- /dev/null +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingIntegrationTests.cs @@ -0,0 +1,652 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Amazon.Lambda.RuntimeSupport.UnitTests.TestHelpers; +using Xunit; + +namespace Amazon.Lambda.RuntimeSupport.UnitTests +{ + [CollectionDefinition("ResponseStreamFactory")] + public class ResponseStreamFactoryCollection { } + + /// + /// End-to-end integration tests for the true-streaming architecture. + /// These tests exercise the full pipeline: LambdaBootstrap → ResponseStreamFactory → + /// ResponseStream → StreamingHttpContent → captured HTTP output stream. + /// + [Collection("ResponseStreamFactory")] + public class StreamingIntegrationTests : IDisposable + { + public void Dispose() + { + ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: false); + ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); + } + + // ─── Helpers ──────────────────────────────────────────────────────────────── + + private static Dictionary> MakeHeaders(string requestId = "test-request-id") + => new Dictionary> + { + { RuntimeApiHeaders.HeaderAwsRequestId, new List { requestId } }, + { RuntimeApiHeaders.HeaderInvokedFunctionArn, new List { "arn:aws:lambda:us-east-1:123456789012:function:test" } }, + { RuntimeApiHeaders.HeaderAwsTenantId, new List { "tenant-id" } }, + { RuntimeApiHeaders.HeaderTraceId, new List { "trace-id" } }, + { RuntimeApiHeaders.HeaderDeadlineMs, new List { "9999999999999" } }, + }; + + /// + /// A capturing RuntimeApiClient that records the raw bytes written to the HTTP output stream + /// by SerializeToStreamAsync, enabling assertions on chunked-encoding format. + /// + private class CapturingStreamingRuntimeApiClient : RuntimeApiClient, IRuntimeApiClient + { + private readonly IEnvironmentVariables _envVars; + private readonly Dictionary> _headers; + + public bool StartStreamingCalled { get; private set; } + public bool SendResponseCalled { get; private set; } + public bool ReportInvocationErrorCalled { get; private set; } + public byte[] CapturedHttpBytes { get; private set; } + public ResponseStream LastResponseStream { get; private set; } + public Stream LastBufferedOutputStream { get; private set; } + + public new Amazon.Lambda.RuntimeSupport.Helpers.IConsoleLoggerWriter ConsoleLogger { get; } = new Helpers.LogLevelLoggerWriter(new SystemEnvironmentVariables()); + + public CapturingStreamingRuntimeApiClient( + IEnvironmentVariables envVars, + Dictionary> headers) + : base(envVars, new NoOpInternalRuntimeApiClient()) + { + _envVars = envVars; + _headers = headers; + } + + public new async Task GetNextInvocationAsync(CancellationToken cancellationToken = default) + { + _headers[RuntimeApiHeaders.HeaderTraceId] = new List { Guid.NewGuid().ToString() }; + var inputStream = new MemoryStream(new byte[0]); + return new InvocationRequest + { + InputStream = inputStream, + LambdaContext = new LambdaContext( + new RuntimeApiHeaders(_headers), + new LambdaEnvironment(_envVars), + new TestDateTimeHelper(), + new Helpers.SimpleLoggerWriter(_envVars)) + }; + } + + internal override async Task StartStreamingResponseAsync( + string awsRequestId, ResponseStream responseStream, CancellationToken cancellationToken = default) + { + StartStreamingCalled = true; + LastResponseStream = responseStream; + + // Use a real MemoryStream as the HTTP output stream so we capture actual bytes + var captureStream = new MemoryStream(); + var content = new StreamingHttpContent(responseStream); + + // SerializeToStreamAsync hands the stream to ResponseStream and waits for completion + await content.CopyToAsync(captureStream); + CapturedHttpBytes = captureStream.ToArray(); + } + + public new async Task SendResponseAsync(string awsRequestId, Stream outputStream, CancellationToken cancellationToken = default) + { + SendResponseCalled = true; + if (outputStream != null) + { + var ms = new MemoryStream(); + await outputStream.CopyToAsync(ms); + ms.Position = 0; + LastBufferedOutputStream = ms; + } + } + + public new Task ReportInvocationErrorAsync(string awsRequestId, Exception exception, CancellationToken cancellationToken = default) + { + ReportInvocationErrorCalled = true; + return Task.CompletedTask; + } + + public new Task ReportInitializationErrorAsync(Exception exception, string errorType = null, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public new Task ReportInitializationErrorAsync(string errorType, CancellationToken cancellationToken = default) + => Task.CompletedTask; + +#if NET8_0_OR_GREATER + public new Task RestoreNextInvocationAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + public new Task ReportRestoreErrorAsync(Exception exception, string errorType = null, CancellationToken cancellationToken = default) => Task.CompletedTask; +#endif + } + + private static CapturingStreamingRuntimeApiClient CreateClient(string requestId = "test-request-id") + => new CapturingStreamingRuntimeApiClient(new TestEnvironmentVariables(), MakeHeaders(requestId)); + + // ─── 10.1 End-to-end streaming response ───────────────────────────────────── + + /// + /// End-to-end: handler calls CreateStream, writes multiple chunks. + /// Verifies data flows through with correct chunked encoding and stream is finalized. + /// Requirements: 3.2, 4.3, 10.1 + /// + [Fact] + public async Task Streaming_MultipleChunks_FlowThroughWithChunkedEncoding() + { + var client = CreateClient(); + var chunks = new[] { "Hello", ", ", "World" }; + + LambdaBootstrapHandler handler = async (invocation) => + { + var stream = ResponseStreamFactory.CreateStream(); + foreach (var chunk in chunks) + await stream.WriteAsync(Encoding.UTF8.GetBytes(chunk)); + return new InvocationResponse(Stream.Null, false); + }; + + using var bootstrap = new LambdaBootstrap(handler, null); + bootstrap.Client = client; + await bootstrap.InvokeOnceAsync(); + + Assert.True(client.StartStreamingCalled); + Assert.NotNull(client.CapturedHttpBytes); + + var output = Encoding.UTF8.GetString(client.CapturedHttpBytes); + + // Each chunk should appear as: hex-size\r\ndata\r\n + Assert.Contains("5\r\nHello\r\n", output); + Assert.Contains("2\r\n, \r\n", output); + Assert.Contains("5\r\nWorld\r\n", output); + + // Final chunk terminates the stream + Assert.Contains("0\r\n", output); + Assert.EndsWith("0\r\n\r\n", output); + } + + /// + /// End-to-end: all data is transmitted correctly (content round-trip). + /// Requirements: 3.2, 4.3, 10.1 + /// + [Fact] + public async Task Streaming_AllDataTransmitted_ContentRoundTrip() + { + var client = CreateClient(); + var payload = Encoding.UTF8.GetBytes("integration test payload"); + + LambdaBootstrapHandler handler = async (invocation) => + { + var stream = ResponseStreamFactory.CreateStream(); + await stream.WriteAsync(payload); + return new InvocationResponse(Stream.Null, false); + }; + + using var bootstrap = new LambdaBootstrap(handler, null); + bootstrap.Client = client; + await bootstrap.InvokeOnceAsync(); + + var output = client.CapturedHttpBytes; + Assert.NotNull(output); + + // Decode the single chunk: hex-size\r\ndata\r\n + var outputStr = Encoding.UTF8.GetString(output); + var hexSize = payload.Length.ToString("X"); + Assert.Contains(hexSize + "\r\n", outputStr); + Assert.Contains("integration test payload", outputStr); + } + + /// + /// End-to-end: stream is finalized (final chunk written, BytesWritten matches). + /// Requirements: 3.2, 4.3, 10.1 + /// + [Fact] + public async Task Streaming_StreamFinalized_BytesWrittenMatchesPayload() + { + var client = CreateClient(); + var data = Encoding.UTF8.GetBytes("finalization check"); + + LambdaBootstrapHandler handler = async (invocation) => + { + var stream = ResponseStreamFactory.CreateStream(); + await stream.WriteAsync(data); + return new InvocationResponse(Stream.Null, false); + }; + + using var bootstrap = new LambdaBootstrap(handler, null); + bootstrap.Client = client; + await bootstrap.InvokeOnceAsync(); + + Assert.NotNull(client.LastResponseStream); + Assert.Equal(data.Length, client.LastResponseStream.BytesWritten); + Assert.True(client.LastResponseStream.IsCompleted); + } + + // ─── 10.2 End-to-end buffered response ────────────────────────────────────── + + /// + /// End-to-end: handler does NOT call CreateStream — response goes via buffered path. + /// Verifies SendResponseAsync is called and streaming headers are absent. + /// Requirements: 1.5, 4.6, 9.4 + /// + [Fact] + public async Task Buffered_HandlerDoesNotCallCreateStream_UsesSendResponsePath() + { + var client = CreateClient(); + var responseBody = Encoding.UTF8.GetBytes("buffered response body"); + + LambdaBootstrapHandler handler = async (invocation) => + { + await Task.Yield(); + return new InvocationResponse(new MemoryStream(responseBody)); + }; + + using var bootstrap = new LambdaBootstrap(handler, null); + bootstrap.Client = client; + await bootstrap.InvokeOnceAsync(); + + Assert.False(client.StartStreamingCalled, "StartStreamingResponseAsync should NOT be called for buffered mode"); + Assert.True(client.SendResponseCalled, "SendResponseAsync should be called for buffered mode"); + Assert.Null(client.CapturedHttpBytes); + } + + /// + /// End-to-end: buffered response body is transmitted correctly. + /// Requirements: 1.5, 4.6, 9.4 + /// + [Fact] + public async Task Buffered_ResponseBodyTransmittedCorrectly() + { + var client = CreateClient(); + var responseBody = Encoding.UTF8.GetBytes("hello buffered world"); + + LambdaBootstrapHandler handler = async (invocation) => + { + await Task.Yield(); + return new InvocationResponse(new MemoryStream(responseBody)); + }; + + using var bootstrap = new LambdaBootstrap(handler, null); + bootstrap.Client = client; + await bootstrap.InvokeOnceAsync(); + + Assert.True(client.SendResponseCalled); + Assert.NotNull(client.LastBufferedOutputStream); + var received = new MemoryStream(); + await client.LastBufferedOutputStream.CopyToAsync(received); + Assert.Equal(responseBody, received.ToArray()); + } + + // ─── 10.3 Midstream error ──────────────────────────────────────────────────── + + /// + /// End-to-end: handler writes data then throws — error trailers appear after final chunk. + /// Requirements: 5.1, 5.2, 5.3, 5.6 + /// + [Fact] + public async Task MidstreamError_ErrorTrailersIncludedAfterFinalChunk() + { + var client = CreateClient(); + + LambdaBootstrapHandler handler = async (invocation) => + { + var stream = ResponseStreamFactory.CreateStream(); + await stream.WriteAsync(Encoding.UTF8.GetBytes("partial data")); + throw new InvalidOperationException("midstream failure"); + }; + + using var bootstrap = new LambdaBootstrap(handler, null); + bootstrap.Client = client; + await bootstrap.InvokeOnceAsync(); + + Assert.True(client.StartStreamingCalled); + Assert.NotNull(client.CapturedHttpBytes); + + var output = Encoding.UTF8.GetString(client.CapturedHttpBytes); + + // Data chunk should be present + Assert.Contains("partial data", output); + + // Final chunk must appear + Assert.Contains("0\r\n", output); + + // Error trailers must appear after the final chunk + var finalChunkIdx = output.LastIndexOf("0\r\n"); + var errorTypeIdx = output.IndexOf(StreamingConstants.ErrorTypeTrailer + ":"); + var errorBodyIdx = output.IndexOf(StreamingConstants.ErrorBodyTrailer + ":"); + + Assert.True(errorTypeIdx > finalChunkIdx, "Error-Type trailer should appear after final chunk"); + Assert.True(errorBodyIdx > finalChunkIdx, "Error-Body trailer should appear after final chunk"); + + // Error type should reference the exception type + Assert.Contains("InvalidOperationException", output); + + // Standard error reporting should NOT be used (error went via trailers) + Assert.False(client.ReportInvocationErrorCalled); + } + + /// + /// End-to-end: handler throws before writing any data — standard error reporting is used. + /// Requirements: 5.6, 5.7 + /// + [Fact] + public async Task PreStreamError_ExceptionBeforeAnyWrite_UsesStandardErrorReporting() + { + var client = CreateClient(); + + LambdaBootstrapHandler handler = async (invocation) => + { + var stream = ResponseStreamFactory.CreateStream(); + // Throw before writing anything + throw new ArgumentException("pre-write failure"); + }; + + using var bootstrap = new LambdaBootstrap(handler, null); + bootstrap.Client = client; + await bootstrap.InvokeOnceAsync(); + + // BytesWritten == 0, so standard error reporting should be used + Assert.True(client.ReportInvocationErrorCalled, + "Standard error reporting should be used when no bytes were written"); + } + + /// + /// End-to-end: error body trailer contains JSON with exception details. + /// Requirements: 5.2, 5.3 + /// + [Fact] + public async Task MidstreamError_ErrorBodyTrailerContainsJsonDetails() + { + var client = CreateClient(); + const string errorMessage = "something went wrong mid-stream"; + + LambdaBootstrapHandler handler = async (invocation) => + { + var stream = ResponseStreamFactory.CreateStream(); + await stream.WriteAsync(Encoding.UTF8.GetBytes("some data")); + throw new InvalidOperationException(errorMessage); + }; + + using var bootstrap = new LambdaBootstrap(handler, null); + bootstrap.Client = client; + await bootstrap.InvokeOnceAsync(); + + var output = Encoding.UTF8.GetString(client.CapturedHttpBytes); + Assert.Contains(StreamingConstants.ErrorBodyTrailer + ":", output); + Assert.Contains(errorMessage, output); + } + + // ─── 10.4 Multi-concurrency ────────────────────────────────────────────────── + + /// + /// Multi-concurrency: concurrent invocations use AsyncLocal for state isolation. + /// Each invocation independently uses streaming or buffered mode without interference. + /// Requirements: 2.9, 6.5, 8.9 + /// + [Fact] + public async Task MultiConcurrency_ConcurrentInvocations_StateIsolated() + { + const int concurrency = 3; + var results = new ConcurrentDictionary(); + var barrier = new SemaphoreSlim(0, concurrency); + var allStarted = new SemaphoreSlim(0, concurrency); + + // Simulate concurrent invocations using AsyncLocal directly + var tasks = new List(); + for (int i = 0; i < concurrency; i++) + { + var requestId = $"req-{i}"; + var payload = $"payload-{i}"; + tasks.Add(Task.Run(async () => + { + var mockClient = new MockMultiConcurrencyStreamingClient(); + ResponseStreamFactory.InitializeInvocation( + requestId, + StreamingConstants.MaxResponseSize, + isMultiConcurrency: true, + mockClient, + CancellationToken.None); + + var stream = ResponseStreamFactory.CreateStream(); + allStarted.Release(); + + // Wait until all tasks have started (to ensure true concurrency) + await barrier.WaitAsync(); + + await stream.WriteAsync(Encoding.UTF8.GetBytes(payload)); + ((ResponseStream)stream).MarkCompleted(); + + // Verify this invocation's stream is still accessible + var retrieved = ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: true); + results[requestId] = retrieved != null ? payload : "MISSING"; + + ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); + })); + } + + // Wait for all tasks to start, then release the barrier + for (int i = 0; i < concurrency; i++) + await allStarted.WaitAsync(); + barrier.Release(concurrency); + + await Task.WhenAll(tasks); + + // Each invocation should have seen its own stream + Assert.Equal(concurrency, results.Count); + for (int i = 0; i < concurrency; i++) + Assert.Equal($"payload-{i}", results[$"req-{i}"]); + } + + /// + /// Multi-concurrency: streaming and buffered invocations can run concurrently without interference. + /// Requirements: 2.9, 6.5, 8.9 + /// + [Fact] + public async Task MultiConcurrency_StreamingAndBufferedMixedConcurrently_NoInterference() + { + var streamingResults = new ConcurrentBag(); + var bufferedResults = new ConcurrentBag(); + var barrier = new SemaphoreSlim(0, 4); + var allStarted = new SemaphoreSlim(0, 4); + + var tasks = new List(); + + // 2 streaming invocations + for (int i = 0; i < 2; i++) + { + var requestId = $"stream-{i}"; + tasks.Add(Task.Run(async () => + { + var mockClient = new MockMultiConcurrencyStreamingClient(); + ResponseStreamFactory.InitializeInvocation( + requestId, StreamingConstants.MaxResponseSize, + isMultiConcurrency: true, mockClient, CancellationToken.None); + + var stream = ResponseStreamFactory.CreateStream(); + allStarted.Release(); + await barrier.WaitAsync(); + + await stream.WriteAsync(Encoding.UTF8.GetBytes("streaming data")); + ((ResponseStream)stream).MarkCompleted(); + + var retrieved = ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: true); + streamingResults.Add(retrieved != null); + ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); + })); + } + + // 2 buffered invocations (no CreateStream) + for (int i = 0; i < 2; i++) + { + var requestId = $"buffered-{i}"; + tasks.Add(Task.Run(async () => + { + var mockClient = new MockMultiConcurrencyStreamingClient(); + ResponseStreamFactory.InitializeInvocation( + requestId, StreamingConstants.MaxResponseSize, + isMultiConcurrency: true, mockClient, CancellationToken.None); + + allStarted.Release(); + await barrier.WaitAsync(); + + // No CreateStream — buffered mode + var retrieved = ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: true); + bufferedResults.Add(retrieved == null); // should be null (no stream created) + ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); + })); + } + + for (int i = 0; i < 4; i++) + await allStarted.WaitAsync(); + barrier.Release(4); + + await Task.WhenAll(tasks); + + Assert.Equal(2, streamingResults.Count); + Assert.All(streamingResults, r => Assert.True(r, "Streaming invocation should have a stream")); + + Assert.Equal(2, bufferedResults.Count); + Assert.All(bufferedResults, r => Assert.True(r, "Buffered invocation should have no stream")); + } + + /// + /// Minimal mock RuntimeApiClient for multi-concurrency tests. + /// Accepts StartStreamingResponseAsync calls without real HTTP. + /// + private class MockMultiConcurrencyStreamingClient : RuntimeApiClient + { + public MockMultiConcurrencyStreamingClient() + : base(new TestEnvironmentVariables(), new NoOpInternalRuntimeApiClient()) { } + + internal override async Task StartStreamingResponseAsync( + string awsRequestId, ResponseStream responseStream, CancellationToken cancellationToken = default) + { + // Provide the HTTP output stream so writes don't block + responseStream.SetHttpOutputStream(new MemoryStream()); + await responseStream.WaitForCompletionAsync(); + } + } + + // ─── 10.5 Backward compatibility ──────────────────────────────────────────── + + /// + /// Backward compatibility: existing handler signatures (event + ILambdaContext) work without modification. + /// Requirements: 9.1, 9.2, 9.3 + /// + [Fact] + public async Task BackwardCompat_ExistingHandlerSignature_WorksUnchanged() + { + var client = CreateClient(); + bool handlerCalled = false; + + // Simulate a classic handler that returns a buffered response + LambdaBootstrapHandler handler = async (invocation) => + { + handlerCalled = true; + await Task.Yield(); + return new InvocationResponse(new MemoryStream(Encoding.UTF8.GetBytes("classic response"))); + }; + + using var bootstrap = new LambdaBootstrap(handler, null); + bootstrap.Client = client; + await bootstrap.InvokeOnceAsync(); + + Assert.True(handlerCalled); + Assert.True(client.SendResponseCalled); + Assert.False(client.StartStreamingCalled); + } + + /// + /// Backward compatibility: no regression in buffered response behavior — response body is correct. + /// Requirements: 9.4, 9.5 + /// + [Fact] + public async Task BackwardCompat_BufferedResponse_NoRegression() + { + var client = CreateClient(); + var expected = Encoding.UTF8.GetBytes("no regression here"); + + LambdaBootstrapHandler handler = async (invocation) => + { + await Task.Yield(); + return new InvocationResponse(new MemoryStream(expected)); + }; + + using var bootstrap = new LambdaBootstrap(handler, null); + bootstrap.Client = client; + await bootstrap.InvokeOnceAsync(); + + Assert.True(client.SendResponseCalled); + Assert.NotNull(client.LastBufferedOutputStream); + var received = new MemoryStream(); + await client.LastBufferedOutputStream.CopyToAsync(received); + Assert.Equal(expected, received.ToArray()); + } + + /// + /// Backward compatibility: handler that returns null OutputStream still works. + /// Requirements: 9.4 + /// + [Fact] + public async Task BackwardCompat_NullOutputStream_HandledGracefully() + { + var client = CreateClient(); + + LambdaBootstrapHandler handler = async (invocation) => + { + await Task.Yield(); + return new InvocationResponse(Stream.Null, false); + }; + + using var bootstrap = new LambdaBootstrap(handler, null); + bootstrap.Client = client; + + // Should not throw + await bootstrap.InvokeOnceAsync(); + + Assert.True(client.SendResponseCalled); + } + + /// + /// Backward compatibility: handler that throws before CreateStream uses standard error path. + /// Requirements: 9.5 + /// + [Fact] + public async Task BackwardCompat_HandlerThrows_StandardErrorReportingUsed() + { + var client = CreateClient(); + + LambdaBootstrapHandler handler = async (invocation) => + { + await Task.Yield(); + throw new Exception("classic handler error"); + }; + + using var bootstrap = new LambdaBootstrap(handler, null); + bootstrap.Client = client; + await bootstrap.InvokeOnceAsync(); + + Assert.True(client.ReportInvocationErrorCalled); + Assert.False(client.StartStreamingCalled); + } + } +} From 414a4495eed950d9cbad9dcf8e24aff19797af3e Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Wed, 18 Feb 2026 17:47:09 -0800 Subject: [PATCH 08/20] Refactoring --- .../Bootstrap/LambdaBootstrap.cs | 11 +-- ...onseStream.cs => ILambdaResponseStream.cs} | 14 +-- .../Client/InvocationResponse.cs | 26 ----- ...daResponseStream.ILambdaResponseStream.cs} | 78 ++++++++++----- .../Client/LambdaResponseStream.Stream.cs | 97 +++++++++++++++++++ ...text.cs => LambdaResponseStreamContext.cs} | 9 +- ...tory.cs => LambdaResponseStreamFactory.cs} | 30 +++--- .../Client/RuntimeApiClient.cs | 2 +- .../Client/StreamingConstants.cs | 5 - .../Client/StreamingHttpContent.cs | 4 +- .../InvocationResponseTests.cs | 81 ---------------- .../LambdaBootstrapTests.cs | 10 +- .../ResponseStreamFactoryTests.cs | 74 +++++++------- .../ResponseStreamTests.cs | 45 +++------ .../RuntimeApiClientTests.cs | 18 ++-- .../StreamingHttpContentTests.cs | 30 +++--- .../StreamingIntegrationTests.cs | 53 +++++----- .../TestStreamingRuntimeApiClient.cs | 4 +- 18 files changed, 282 insertions(+), 309 deletions(-) rename Libraries/src/Amazon.Lambda.RuntimeSupport/Client/{IResponseStream.cs => ILambdaResponseStream.cs} (77%) rename Libraries/src/Amazon.Lambda.RuntimeSupport/Client/{ResponseStream.cs => LambdaResponseStream.ILambdaResponseStream.cs} (65%) create mode 100644 Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStream.Stream.cs rename Libraries/src/Amazon.Lambda.RuntimeSupport/Client/{ResponseStreamContext.cs => LambdaResponseStreamContext.cs} (88%) rename Libraries/src/Amazon.Lambda.RuntimeSupport/Client/{ResponseStreamFactory.cs => LambdaResponseStreamFactory.cs} (79%) delete mode 100644 Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/InvocationResponseTests.cs diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/LambdaBootstrap.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/LambdaBootstrap.cs index 68b67c339..6241fb61f 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/LambdaBootstrap.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/LambdaBootstrap.cs @@ -363,9 +363,8 @@ internal async Task InvokeOnceAsync(CancellationToken cancellationToken = defaul var runtimeApiClient = Client as RuntimeApiClient; if (runtimeApiClient != null) { - ResponseStreamFactory.InitializeInvocation( + LambdaResponseStreamFactory.InitializeInvocation( invocation.LambdaContext.AwsRequestId, - StreamingConstants.MaxResponseSize, isMultiConcurrency, runtimeApiClient, cancellationToken); @@ -386,7 +385,7 @@ internal async Task InvokeOnceAsync(CancellationToken cancellationToken = defaul { WriteUnhandledExceptionToLog(exception); - var streamIfCreated = ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency); + var streamIfCreated = LambdaResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency); if (streamIfCreated != null && streamIfCreated.BytesWritten > 0) { // Midstream error — report via trailers on the already-open HTTP connection @@ -404,10 +403,10 @@ internal async Task InvokeOnceAsync(CancellationToken cancellationToken = defaul } // If streaming was started, await the HTTP send task to ensure it completes - var sendTask = ResponseStreamFactory.GetSendTask(isMultiConcurrency); + var sendTask = LambdaResponseStreamFactory.GetSendTask(isMultiConcurrency); if (sendTask != null) { - var stream = ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency); + var stream = LambdaResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency); if (stream != null && !stream.IsCompleted && !stream.HasError) { // Handler returned successfully — signal stream completion @@ -454,7 +453,7 @@ internal async Task InvokeOnceAsync(CancellationToken cancellationToken = defaul { if (runtimeApiClient != null) { - ResponseStreamFactory.CleanupInvocation(isMultiConcurrency); + LambdaResponseStreamFactory.CleanupInvocation(isMultiConcurrency); } invocation.Dispose(); } diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/IResponseStream.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ILambdaResponseStream.cs similarity index 77% rename from Libraries/src/Amazon.Lambda.RuntimeSupport/Client/IResponseStream.cs rename to Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ILambdaResponseStream.cs index 6107dde16..36236b28d 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/IResponseStream.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ILambdaResponseStream.cs @@ -23,7 +23,7 @@ namespace Amazon.Lambda.RuntimeSupport /// Interface for writing streaming responses in AWS Lambda functions. /// Obtained by calling ResponseStreamFactory.CreateStream() within a handler. /// - public interface IResponseStream : IDisposable + public interface ILambdaResponseStream : IDisposable { /// /// Asynchronously writes a byte array to the response stream. @@ -32,7 +32,6 @@ public interface IResponseStream : IDisposable /// Optional cancellation token. /// A task representing the asynchronous operation. /// Thrown if the stream is already completed or an error has been reported. - /// Thrown if writing would exceed the 20 MiB limit. Task WriteAsync(byte[] buffer, CancellationToken cancellationToken = default); /// @@ -44,19 +43,8 @@ public interface IResponseStream : IDisposable /// Optional cancellation token. /// A task representing the asynchronous operation. /// Thrown if the stream is already completed or an error has been reported. - /// Thrown if writing would exceed the 20 MiB limit. Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken = default); - /// - /// Asynchronously writes a memory buffer to the response stream. - /// - /// The memory buffer to write. - /// Optional cancellation token. - /// A task representing the asynchronous operation. - /// Thrown if the stream is already completed or an error has been reported. - /// Thrown if writing would exceed the 20 MiB limit. - Task WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default); - /// /// Reports an error that occurred during streaming. /// This will send error information via HTTP trailing headers. diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/InvocationResponse.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/InvocationResponse.cs index 4438c9708..1894b0521 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/InvocationResponse.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/InvocationResponse.cs @@ -34,18 +34,6 @@ public class InvocationResponse /// public bool DisposeOutputStream { get; private set; } = true; - /// - /// Indicates whether this response uses streaming mode. - /// Set internally by the runtime when ResponseStreamFactory.CreateStream() is called. - /// - internal bool IsStreaming { get; set; } - - /// - /// The ResponseStream instance if streaming mode is used. - /// Set internally by the runtime. - /// - internal ResponseStream ResponseStream { get; set; } - /// /// Construct a InvocationResponse with an output stream that will be disposed by the Lambda Runtime Client. /// @@ -64,20 +52,6 @@ public InvocationResponse(Stream outputStream, bool disposeOutputStream) { OutputStream = outputStream ?? throw new ArgumentNullException(nameof(outputStream)); DisposeOutputStream = disposeOutputStream; - IsStreaming = false; - } - - /// - /// Creates an InvocationResponse for a streaming response. - /// Used internally by the runtime. - /// - internal static InvocationResponse CreateStreamingResponse(ResponseStream responseStream) - { - return new InvocationResponse(Stream.Null, false) - { - IsStreaming = true, - ResponseStream = responseStream - }; } } } diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ResponseStream.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStream.ILambdaResponseStream.cs similarity index 65% rename from Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ResponseStream.cs rename to Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStream.ILambdaResponseStream.cs index 00f63cf75..7830c81b4 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ResponseStream.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStream.ILambdaResponseStream.cs @@ -22,14 +22,13 @@ namespace Amazon.Lambda.RuntimeSupport { /// - /// Internal implementation of IResponseStream with true streaming. - /// Writes data directly to the HTTP output stream as chunked transfer encoding. + /// A write-only, non-seekable subclass that streams response data + /// to the Lambda Runtime API. Returned by . /// - internal class ResponseStream : IResponseStream + public partial class LambdaResponseStream : Stream, ILambdaResponseStream { private static readonly byte[] CrlfBytes = Encoding.ASCII.GetBytes("\r\n"); - private readonly long _maxResponseSize; private long _bytesWritten; private bool _isCompleted; private bool _hasError; @@ -41,14 +40,26 @@ internal class ResponseStream : IResponseStream private readonly SemaphoreSlim _httpStreamReady = new SemaphoreSlim(0, 1); private readonly SemaphoreSlim _completionSignal = new SemaphoreSlim(0, 1); + /// + /// The number of bytes written to the Lambda response stream so far. + /// public long BytesWritten => _bytesWritten; + + /// + /// Gets a value indicating whether the operation has completed. + /// public bool IsCompleted => _isCompleted; + + /// + /// Gets a value indicating whether an error has occurred. + /// public bool HasError => _hasError; + + internal Exception ReportedError => _reportedError; - public ResponseStream(long maxResponseSize) + internal LambdaResponseStream() { - _maxResponseSize = maxResponseSize; } /// @@ -69,6 +80,13 @@ internal async Task WaitForCompletionAsync() await _completionSignal.WaitAsync(); } + /// + /// Asynchronously writes a byte array to the response stream. + /// + /// The byte array to write. + /// Optional cancellation token. + /// A task representing the asynchronous operation. + /// Thrown if the stream is already completed or an error has been reported. public async Task WriteAsync(byte[] buffer, CancellationToken cancellationToken = default) { if (buffer == null) @@ -77,7 +95,16 @@ public async Task WriteAsync(byte[] buffer, CancellationToken cancellationToken await WriteAsync(buffer, 0, buffer.Length, cancellationToken); } - public async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken = default) + /// + /// Asynchronously writes a portion of a byte array to the response stream. + /// + /// The byte array containing data to write. + /// The zero-based byte offset in buffer at which to begin copying bytes. + /// The number of bytes to write. + /// Optional cancellation token. + /// A task representing the asynchronous operation. + /// Thrown if the stream is already completed or an error has been reported. + public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken = default) { if (buffer == null) throw new ArgumentNullException(nameof(buffer)); @@ -93,14 +120,6 @@ public async Task WriteAsync(byte[] buffer, int offset, int count, CancellationT lock (_lock) { ThrowIfCompletedOrError(); - - if (_bytesWritten + count > _maxResponseSize) - { - throw new InvalidOperationException( - $"Writing {count} bytes would exceed the maximum response size of {_maxResponseSize} bytes (20 MiB). " + - $"Current size: {_bytesWritten} bytes."); - } - _bytesWritten += count; } @@ -120,13 +139,14 @@ public async Task WriteAsync(byte[] buffer, int offset, int count, CancellationT } } - public async Task WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) - { - // Convert to array and delegate — small overhead but keeps the API simple - var array = buffer.ToArray(); - await WriteAsync(array, 0, array.Length, cancellationToken); - } - + /// + /// Reports an error that occurred during streaming. + /// This will send error information via HTTP trailing headers. + /// + /// The exception to report. + /// Optional cancellation token. + /// A task representing the asynchronous operation. + /// Thrown if the stream is already completed or an error has already been reported. public Task ReportErrorAsync(Exception exception, CancellationToken cancellationToken = default) { if (exception == null) @@ -145,6 +165,7 @@ public Task ReportErrorAsync(Exception exception, CancellationToken cancellation // Signal completion so StreamingHttpContent can write error trailers and finish _completionSignal.Release(); + return Task.CompletedTask; } @@ -166,10 +187,17 @@ private void ThrowIfCompletedOrError() throw new InvalidOperationException("Cannot write to a stream after an error has been reported."); } - public void Dispose() + // ── Dispose ────────────────────────────────────────────────────────── + + /// + protected override void Dispose(bool disposing) { - // Ensure completion is signaled if not already - try { _completionSignal.Release(); } catch (SemaphoreFullException) { } + if (disposing) + { + try { _completionSignal.Release(); } catch (SemaphoreFullException) { } + } + + base.Dispose(disposing); } } } diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStream.Stream.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStream.Stream.cs new file mode 100644 index 000000000..5453333e7 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStream.Stream.cs @@ -0,0 +1,97 @@ +/* + * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Amazon.Lambda.RuntimeSupport +{ + /// + /// A write-only, non-seekable subclass that streams response data + /// to the Lambda Runtime API. Returned by . + /// Integrates with standard .NET stream consumers such as . + /// + public partial class LambdaResponseStream : Stream, ILambdaResponseStream + { + // ── System.IO.Stream — capabilities ───────────────────────────────── + + /// Gets a value indicating whether the stream supports reading. Always false. + public override bool CanRead => false; + + /// Gets a value indicating whether the stream supports seeking. Always false. + public override bool CanSeek => false; + + /// Gets a value indicating whether the stream supports writing. Always true. + public override bool CanWrite => true; + + // ── System.IO.Stream — Length / Position ──────────────────────────── + + /// + /// Gets the total number of bytes written to the stream so far. + /// Equivalent to . + /// + public override long Length => BytesWritten; + + /// + /// Getting or setting the position is not supported. + /// + /// Always thrown. + public override long Position + { + get => throw new NotSupportedException("LambdaResponseStream does not support seeking."); + set => throw new NotSupportedException("LambdaResponseStream does not support seeking."); + } + + // ── System.IO.Stream — seek / read (not supported) ────────────────── + + /// Not supported. + /// Always thrown. + public override long Seek(long offset, SeekOrigin origin) + => throw new NotImplementedException("LambdaResponseStream does not support seeking."); + + /// Not supported. + /// Always thrown. + public override int Read(byte[] buffer, int offset, int count) + => throw new NotImplementedException("LambdaResponseStream does not support reading."); + + /// Not supported. + /// Always thrown. + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => throw new NotImplementedException("LambdaResponseStream does not support reading."); + + // ── System.IO.Stream — write ───────────────────────────────────────── + + /// + /// Writes a sequence of bytes to the stream. Delegates to the async path synchronously. + /// Prefer to avoid blocking. + /// + public override void Write(byte[] buffer, int offset, int count) + => WriteAsync(buffer, offset, count, CancellationToken.None).GetAwaiter().GetResult(); + + // ── System.IO.Stream — flush / set length ──────────────────────────── + + /// + /// Flush is a no-op; data is sent to the Runtime API immediately on each write. + /// + public override void Flush() { } + + /// Not supported. + /// Always thrown. + public override void SetLength(long value) + => throw new NotSupportedException("LambdaResponseStream does not support SetLength."); + } +} diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ResponseStreamContext.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStreamContext.cs similarity index 88% rename from Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ResponseStreamContext.cs rename to Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStreamContext.cs index dc0b4a629..c6a58c81d 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ResponseStreamContext.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStreamContext.cs @@ -21,18 +21,13 @@ namespace Amazon.Lambda.RuntimeSupport /// /// Internal context class used by ResponseStreamFactory to track per-invocation streaming state. /// - internal class ResponseStreamContext + internal class LambdaResponseStreamContext { /// /// The AWS request ID for the current invocation. /// public string AwsRequestId { get; set; } - /// - /// Maximum allowed response size in bytes (20 MiB). - /// - public long MaxResponseSize { get; set; } - /// /// Whether CreateStream() has been called for this invocation. /// @@ -41,7 +36,7 @@ internal class ResponseStreamContext /// /// The ResponseStream instance if created. /// - public ResponseStream Stream { get; set; } + public LambdaResponseStream Stream { get; set; } /// /// The RuntimeApiClient used to start the streaming HTTP POST. diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ResponseStreamFactory.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStreamFactory.cs similarity index 79% rename from Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ResponseStreamFactory.cs rename to Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStreamFactory.cs index 613980fb1..84d8c0ebd 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ResponseStreamFactory.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStreamFactory.cs @@ -23,22 +23,25 @@ namespace Amazon.Lambda.RuntimeSupport /// Factory for creating streaming responses in AWS Lambda functions. /// Call CreateStream() within your handler to opt into response streaming for that invocation. /// - public static class ResponseStreamFactory + public static class LambdaResponseStreamFactory { // For on-demand mode (single invocation at a time) - private static ResponseStreamContext _onDemandContext; + private static LambdaResponseStreamContext _onDemandContext; // For multi-concurrency mode (multiple concurrent invocations) - private static readonly AsyncLocal _asyncLocalContext = new AsyncLocal(); + private static readonly AsyncLocal _asyncLocalContext = new AsyncLocal(); /// /// Creates a streaming response for the current invocation. /// Can only be called once per invocation. /// - /// An IResponseStream for writing response data. + /// + /// A — a subclass — for writing + /// response data. The returned stream also implements . + /// /// Thrown if called outside an invocation context. /// Thrown if called more than once per invocation. - public static IResponseStream CreateStream() + public static LambdaResponseStream CreateStream() { var context = GetCurrentContext(); @@ -54,29 +57,28 @@ public static IResponseStream CreateStream() "ResponseStreamFactory.CreateStream() can only be called once per invocation."); } - var stream = new ResponseStream(context.MaxResponseSize); - context.Stream = stream; + var lambdaStream = new LambdaResponseStream(); + context.Stream = lambdaStream; context.StreamCreated = true; // Start the HTTP POST to the Runtime API. // This runs concurrently — SerializeToStreamAsync will block // until the handler finishes writing or reports an error. context.SendTask = context.RuntimeApiClient.StartStreamingResponseAsync( - context.AwsRequestId, stream, context.CancellationToken); + context.AwsRequestId, lambdaStream, context.CancellationToken); - return stream; + return lambdaStream; } // Internal methods for LambdaBootstrap to manage state internal static void InitializeInvocation( - string awsRequestId, long maxResponseSize, bool isMultiConcurrency, + string awsRequestId, bool isMultiConcurrency, RuntimeApiClient runtimeApiClient, CancellationToken cancellationToken) { - var context = new ResponseStreamContext + var context = new LambdaResponseStreamContext { AwsRequestId = awsRequestId, - MaxResponseSize = maxResponseSize, StreamCreated = false, Stream = null, RuntimeApiClient = runtimeApiClient, @@ -93,7 +95,7 @@ internal static void InitializeInvocation( } } - internal static ResponseStream GetStreamIfCreated(bool isMultiConcurrency) + internal static LambdaResponseStream GetStreamIfCreated(bool isMultiConcurrency) { var context = isMultiConcurrency ? _asyncLocalContext.Value : _onDemandContext; return context?.Stream; @@ -121,7 +123,7 @@ internal static void CleanupInvocation(bool isMultiConcurrency) } } - private static ResponseStreamContext GetCurrentContext() + private static LambdaResponseStreamContext GetCurrentContext() { // Check multi-concurrency first (AsyncLocal), then on-demand return _asyncLocalContext.Value ?? _onDemandContext; diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs index 13c4e4eac..f594d5e56 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs @@ -189,7 +189,7 @@ public Task ReportRestoreErrorAsync(Exception exception, String errorType = null /// The optional cancellation token to use. /// A Task representing the in-flight HTTP POST. internal virtual async Task StartStreamingResponseAsync( - string awsRequestId, ResponseStream responseStream, CancellationToken cancellationToken = default) + string awsRequestId, LambdaResponseStream responseStream, CancellationToken cancellationToken = default) { if (awsRequestId == null) throw new ArgumentNullException(nameof(awsRequestId)); if (responseStream == null) throw new ArgumentNullException(nameof(responseStream)); diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingConstants.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingConstants.cs index 7eeec86a2..c1e99ed17 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingConstants.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingConstants.cs @@ -20,11 +20,6 @@ namespace Amazon.Lambda.RuntimeSupport /// internal static class StreamingConstants { - /// - /// Maximum response size for Lambda streaming responses: 20 MiB. - /// - public const long MaxResponseSize = 20 * 1024 * 1024; - /// /// Header name for Lambda response mode. /// diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingHttpContent.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingHttpContent.cs index e563d343b..c642873aa 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingHttpContent.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingHttpContent.cs @@ -30,9 +30,9 @@ internal class StreamingHttpContent : HttpContent private static readonly byte[] CrlfBytes = Encoding.ASCII.GetBytes("\r\n"); private static readonly byte[] FinalChunkBytes = Encoding.ASCII.GetBytes("0\r\n"); - private readonly ResponseStream _responseStream; + private readonly LambdaResponseStream _responseStream; - public StreamingHttpContent(ResponseStream responseStream) + public StreamingHttpContent(LambdaResponseStream responseStream) { _responseStream = responseStream ?? throw new ArgumentNullException(nameof(responseStream)); } diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/InvocationResponseTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/InvocationResponseTests.cs deleted file mode 100644 index 703ac0cd9..000000000 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/InvocationResponseTests.cs +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -using System.IO; -using Xunit; - -namespace Amazon.Lambda.RuntimeSupport.UnitTests -{ - public class InvocationResponseTests - { - private const long MaxResponseSize = 20 * 1024 * 1024; - - /// - /// Property 17: InvocationResponse Streaming Flag - Existing constructors set IsStreaming to false. - /// Validates: Requirements 7.3, 8.1 - /// - [Fact] - public void Constructor_WithStream_IsStreamingIsFalse() - { - var response = new InvocationResponse(new MemoryStream()); - - Assert.False(response.IsStreaming); - Assert.Null(response.ResponseStream); - } - - [Fact] - public void Constructor_WithStreamAndDispose_IsStreamingIsFalse() - { - var response = new InvocationResponse(new MemoryStream(), false); - - Assert.False(response.IsStreaming); - Assert.Null(response.ResponseStream); - } - - /// - /// Property 17: InvocationResponse Streaming Flag - CreateStreamingResponse sets IsStreaming to true. - /// Validates: Requirements 7.3, 8.1 - /// - [Fact] - public void CreateStreamingResponse_SetsIsStreamingTrue() - { - var stream = new ResponseStream(MaxResponseSize); - - var response = InvocationResponse.CreateStreamingResponse(stream); - - Assert.True(response.IsStreaming); - } - - [Fact] - public void CreateStreamingResponse_SetsResponseStream() - { - var stream = new ResponseStream(MaxResponseSize); - - var response = InvocationResponse.CreateStreamingResponse(stream); - - Assert.Same(stream, response.ResponseStream); - } - - [Fact] - public void CreateStreamingResponse_DoesNotDisposeOutputStream() - { - var stream = new ResponseStream(MaxResponseSize); - - var response = InvocationResponse.CreateStreamingResponse(stream); - - Assert.False(response.DisposeOutputStream); - } - } -} diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/LambdaBootstrapTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/LambdaBootstrapTests.cs index ce922d529..ae40b7e2e 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/LambdaBootstrapTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/LambdaBootstrapTests.cs @@ -314,7 +314,7 @@ public async Task StreamingMode_HandlerCallsCreateStream_SendTaskAwaited() LambdaBootstrapHandler handler = async (invocation) => { - var stream = ResponseStreamFactory.CreateStream(); + var stream = LambdaResponseStreamFactory.CreateStream(); await stream.WriteAsync(Encoding.UTF8.GetBytes("hello")); return new InvocationResponse(Stream.Null, false); }; @@ -369,7 +369,7 @@ public async Task MidstreamError_ExceptionAfterWrites_ReportsViaTrailers() LambdaBootstrapHandler handler = async (invocation) => { - var stream = ResponseStreamFactory.CreateStream(); + var stream = LambdaResponseStreamFactory.CreateStream(); await stream.WriteAsync(Encoding.UTF8.GetBytes("partial data")); throw new InvalidOperationException("midstream failure"); }; @@ -425,7 +425,7 @@ public async Task Cleanup_ResponseStreamFactoryStateCleared_AfterInvocation() LambdaBootstrapHandler handler = async (invocation) => { - var stream = ResponseStreamFactory.CreateStream(); + var stream = LambdaResponseStreamFactory.CreateStream(); await stream.WriteAsync(Encoding.UTF8.GetBytes("data")); return new InvocationResponse(Stream.Null, false); }; @@ -437,8 +437,8 @@ public async Task Cleanup_ResponseStreamFactoryStateCleared_AfterInvocation() } // After invocation, factory state should be cleaned up - Assert.Null(ResponseStreamFactory.GetStreamIfCreated(false)); - Assert.Null(ResponseStreamFactory.GetSendTask(false)); + Assert.Null(LambdaResponseStreamFactory.GetStreamIfCreated(false)); + Assert.Null(LambdaResponseStreamFactory.GetSendTask(false)); } } } diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamFactoryTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamFactoryTests.cs index 1c714dd97..9fce99ad5 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamFactoryTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamFactoryTests.cs @@ -28,8 +28,8 @@ public class ResponseStreamFactoryTests : IDisposable public void Dispose() { // Clean up both modes to avoid test pollution - ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: false); - ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); + LambdaResponseStreamFactory.CleanupInvocation(isMultiConcurrency: false); + LambdaResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); } /// @@ -40,7 +40,7 @@ private class MockStreamingRuntimeApiClient : RuntimeApiClient { public bool StartStreamingCalled { get; private set; } public string LastAwsRequestId { get; private set; } - public ResponseStream LastResponseStream { get; private set; } + public LambdaResponseStream LastResponseStream { get; private set; } public TaskCompletionSource SendTaskCompletion { get; } = new TaskCompletionSource(); public MockStreamingRuntimeApiClient() @@ -49,7 +49,7 @@ public MockStreamingRuntimeApiClient() } internal override async Task StartStreamingResponseAsync( - string awsRequestId, ResponseStream responseStream, CancellationToken cancellationToken = default) + string awsRequestId, LambdaResponseStream responseStream, CancellationToken cancellationToken = default) { StartStreamingCalled = true; LastAwsRequestId = awsRequestId; @@ -60,8 +60,8 @@ internal override async Task StartStreamingResponseAsync( private void InitializeWithMock(string requestId, bool isMultiConcurrency, MockStreamingRuntimeApiClient mockClient) { - ResponseStreamFactory.InitializeInvocation( - requestId, MaxResponseSize, isMultiConcurrency, + LambdaResponseStreamFactory.InitializeInvocation( + requestId, isMultiConcurrency, mockClient, CancellationToken.None); } @@ -77,10 +77,10 @@ public void CreateStream_OnDemandMode_ReturnsValidStream() var mock = new MockStreamingRuntimeApiClient(); InitializeWithMock("req-1", isMultiConcurrency: false, mock); - var stream = ResponseStreamFactory.CreateStream(); + var stream = LambdaResponseStreamFactory.CreateStream(); Assert.NotNull(stream); - Assert.IsAssignableFrom(stream); + Assert.IsAssignableFrom(stream); } /// @@ -93,10 +93,10 @@ public void CreateStream_MultiConcurrencyMode_ReturnsValidStream() var mock = new MockStreamingRuntimeApiClient(); InitializeWithMock("req-2", isMultiConcurrency: true, mock); - var stream = ResponseStreamFactory.CreateStream(); + var stream = LambdaResponseStreamFactory.CreateStream(); Assert.NotNull(stream); - Assert.IsAssignableFrom(stream); + Assert.IsAssignableFrom(stream); } // --- Property 4: Single Stream Per Invocation --- @@ -110,16 +110,16 @@ public void CreateStream_CalledTwice_ThrowsInvalidOperationException() { var mock = new MockStreamingRuntimeApiClient(); InitializeWithMock("req-3", isMultiConcurrency: false, mock); - ResponseStreamFactory.CreateStream(); + LambdaResponseStreamFactory.CreateStream(); - Assert.Throws(() => ResponseStreamFactory.CreateStream()); + Assert.Throws(() => LambdaResponseStreamFactory.CreateStream()); } [Fact] public void CreateStream_OutsideInvocationContext_ThrowsInvalidOperationException() { // No InitializeInvocation called - Assert.Throws(() => ResponseStreamFactory.CreateStream()); + Assert.Throws(() => LambdaResponseStreamFactory.CreateStream()); } // --- CreateStream starts HTTP POST --- @@ -134,7 +134,7 @@ public void CreateStream_CallsStartStreamingResponseAsync() var mock = new MockStreamingRuntimeApiClient(); InitializeWithMock("req-start", isMultiConcurrency: false, mock); - ResponseStreamFactory.CreateStream(); + LambdaResponseStreamFactory.CreateStream(); Assert.True(mock.StartStreamingCalled); Assert.Equal("req-start", mock.LastAwsRequestId); @@ -153,9 +153,9 @@ public void GetSendTask_AfterCreateStream_ReturnsNonNullTask() var mock = new MockStreamingRuntimeApiClient(); InitializeWithMock("req-send", isMultiConcurrency: false, mock); - ResponseStreamFactory.CreateStream(); + LambdaResponseStreamFactory.CreateStream(); - var sendTask = ResponseStreamFactory.GetSendTask(isMultiConcurrency: false); + var sendTask = LambdaResponseStreamFactory.GetSendTask(isMultiConcurrency: false); Assert.NotNull(sendTask); } @@ -165,14 +165,14 @@ public void GetSendTask_BeforeCreateStream_ReturnsNull() var mock = new MockStreamingRuntimeApiClient(); InitializeWithMock("req-nosend", isMultiConcurrency: false, mock); - var sendTask = ResponseStreamFactory.GetSendTask(isMultiConcurrency: false); + var sendTask = LambdaResponseStreamFactory.GetSendTask(isMultiConcurrency: false); Assert.Null(sendTask); } [Fact] public void GetSendTask_NoContext_ReturnsNull() { - Assert.Null(ResponseStreamFactory.GetSendTask(isMultiConcurrency: false)); + Assert.Null(LambdaResponseStreamFactory.GetSendTask(isMultiConcurrency: false)); } // --- Internal methods --- @@ -183,9 +183,9 @@ public void InitializeInvocation_OnDemand_SetsUpContext() var mock = new MockStreamingRuntimeApiClient(); InitializeWithMock("req-4", isMultiConcurrency: false, mock); - Assert.Null(ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: false)); + Assert.Null(LambdaResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: false)); - var stream = ResponseStreamFactory.CreateStream(); + var stream = LambdaResponseStreamFactory.CreateStream(); Assert.NotNull(stream); } @@ -195,9 +195,9 @@ public void InitializeInvocation_MultiConcurrency_SetsUpContext() var mock = new MockStreamingRuntimeApiClient(); InitializeWithMock("req-5", isMultiConcurrency: true, mock); - Assert.Null(ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: true)); + Assert.Null(LambdaResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: true)); - var stream = ResponseStreamFactory.CreateStream(); + var stream = LambdaResponseStreamFactory.CreateStream(); Assert.NotNull(stream); } @@ -206,16 +206,16 @@ public void GetStreamIfCreated_AfterCreateStream_ReturnsStream() { var mock = new MockStreamingRuntimeApiClient(); InitializeWithMock("req-6", isMultiConcurrency: false, mock); - ResponseStreamFactory.CreateStream(); + LambdaResponseStreamFactory.CreateStream(); - var retrieved = ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: false); + var retrieved = LambdaResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: false); Assert.NotNull(retrieved); } [Fact] public void GetStreamIfCreated_NoContext_ReturnsNull() { - Assert.Null(ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: false)); + Assert.Null(LambdaResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: false)); } [Fact] @@ -223,12 +223,12 @@ public void CleanupInvocation_ClearsState() { var mock = new MockStreamingRuntimeApiClient(); InitializeWithMock("req-7", isMultiConcurrency: false, mock); - ResponseStreamFactory.CreateStream(); + LambdaResponseStreamFactory.CreateStream(); - ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: false); + LambdaResponseStreamFactory.CleanupInvocation(isMultiConcurrency: false); - Assert.Null(ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: false)); - Assert.Throws(() => ResponseStreamFactory.CreateStream()); + Assert.Null(LambdaResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: false)); + Assert.Throws(() => LambdaResponseStreamFactory.CreateStream()); } // --- Property 16: State Isolation Between Invocations --- @@ -244,17 +244,17 @@ public void StateIsolation_SequentialInvocations_NoLeakage() // First invocation - streaming InitializeWithMock("req-8a", isMultiConcurrency: false, mock); - var stream1 = ResponseStreamFactory.CreateStream(); + var stream1 = LambdaResponseStreamFactory.CreateStream(); Assert.NotNull(stream1); - ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: false); + LambdaResponseStreamFactory.CleanupInvocation(isMultiConcurrency: false); // Second invocation - should start fresh InitializeWithMock("req-8b", isMultiConcurrency: false, mock); - Assert.Null(ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: false)); + Assert.Null(LambdaResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: false)); - var stream2 = ResponseStreamFactory.CreateStream(); + var stream2 = LambdaResponseStreamFactory.CreateStream(); Assert.NotNull(stream2); - ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: false); + LambdaResponseStreamFactory.CleanupInvocation(isMultiConcurrency: false); } /// @@ -266,14 +266,14 @@ public async Task StateIsolation_MultiConcurrency_UsesAsyncLocal() { var mock = new MockStreamingRuntimeApiClient(); InitializeWithMock("req-9", isMultiConcurrency: true, mock); - var stream = ResponseStreamFactory.CreateStream(); + var stream = LambdaResponseStreamFactory.CreateStream(); Assert.NotNull(stream); bool childSawNull = false; await Task.Run(() => { - ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); - childSawNull = ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: true) == null; + LambdaResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); + childSawNull = LambdaResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: true) == null; }); Assert.True(childSawNull); diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs index a6ef2fe6f..735fba482 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs @@ -25,15 +25,13 @@ namespace Amazon.Lambda.RuntimeSupport.UnitTests { public class ResponseStreamTests { - private const long MaxResponseSize = 20 * 1024 * 1024; // 20 MiB - /// /// Helper: creates a ResponseStream and wires up a MemoryStream as the HTTP output stream. /// Returns both so tests can inspect what was written. /// - private static (ResponseStream stream, MemoryStream httpOutput) CreateWiredStream(long maxSize = MaxResponseSize) + private static (LambdaResponseStream stream, MemoryStream httpOutput) CreateWiredStream() { - var rs = new ResponseStream(maxSize); + var rs = new LambdaResponseStream(); var output = new MemoryStream(); rs.SetHttpOutputStream(output); return (rs, output); @@ -44,7 +42,7 @@ private static (ResponseStream stream, MemoryStream httpOutput) CreateWiredStrea [Fact] public void Constructor_InitializesStateCorrectly() { - var stream = new ResponseStream(MaxResponseSize); + var stream = new LambdaResponseStream(); Assert.Equal(0, stream.BytesWritten); Assert.False(stream.IsCompleted); @@ -100,27 +98,6 @@ public async Task WriteAsync_WithOffset_WritesCorrectSliceAsChunk() Assert.Equal(expected, written); } - /// - /// Property 9: Chunked Encoding Format — ReadOnlyMemory overload. - /// Validates: Requirements 3.2, 10.1 - /// - [Fact] - public async Task WriteAsync_ReadOnlyMemory_WritesChunkedFormat() - { - var (stream, httpOutput) = CreateWiredStream(); - var data = new ReadOnlyMemory(new byte[] { 10, 20, 30 }); - - await stream.WriteAsync(data); - - var written = httpOutput.ToArray(); - var expected = Encoding.ASCII.GetBytes("3\r\n") - .Concat(new byte[] { 10, 20, 30 }) - .Concat(Encoding.ASCII.GetBytes("\r\n")) - .ToArray(); - - Assert.Equal(expected, written); - } - // ---- Property 5: Written Data Appears in HTTP Response Immediately ---- /// @@ -170,7 +147,7 @@ public async Task WriteAsync_LargerPayload_HexSizeIsCorrect() [Fact] public async Task WriteAsync_BlocksUntilSetHttpOutputStream() { - var rs = new ResponseStream(MaxResponseSize); + var rs = new LambdaResponseStream(); var httpOutput = new MemoryStream(); var writeStarted = new ManualResetEventSlim(false); var writeCompleted = new ManualResetEventSlim(false); @@ -277,7 +254,7 @@ public async Task SizeLimit_ExactlyAtLimit_Succeeds() await stream.WriteAsync(data); - Assert.Equal(MaxResponseSize, stream.BytesWritten); + Assert.Equal(data.Length, stream.BytesWritten); } // ---- Property 19: Writes After Completion Rejected ---- @@ -317,7 +294,7 @@ await Assert.ThrowsAsync( [Fact] public async Task ReportErrorAsync_SetsErrorState() { - var stream = new ResponseStream(MaxResponseSize); + var stream = new LambdaResponseStream(); var exception = new InvalidOperationException("something broke"); await stream.ReportErrorAsync(exception); @@ -329,7 +306,7 @@ public async Task ReportErrorAsync_SetsErrorState() [Fact] public async Task ReportErrorAsync_AfterCompleted_Throws() { - var stream = new ResponseStream(MaxResponseSize); + var stream = new LambdaResponseStream(); stream.MarkCompleted(); await Assert.ThrowsAsync( @@ -339,7 +316,7 @@ await Assert.ThrowsAsync( [Fact] public async Task ReportErrorAsync_CalledTwice_Throws() { - var stream = new ResponseStream(MaxResponseSize); + var stream = new LambdaResponseStream(); await stream.ReportErrorAsync(new Exception("first")); await Assert.ThrowsAsync( @@ -349,7 +326,7 @@ await Assert.ThrowsAsync( [Fact] public void MarkCompleted_SetsCompletionState() { - var stream = new ResponseStream(MaxResponseSize); + var stream = new LambdaResponseStream(); stream.MarkCompleted(); @@ -377,7 +354,7 @@ public async Task WriteAsync_NullBufferWithOffset_ThrowsArgumentNull() [Fact] public async Task ReportErrorAsync_NullException_ThrowsArgumentNull() { - var stream = new ResponseStream(MaxResponseSize); + var stream = new LambdaResponseStream(); await Assert.ThrowsAsync(() => stream.ReportErrorAsync(null)); } @@ -387,7 +364,7 @@ public async Task ReportErrorAsync_NullException_ThrowsArgumentNull() [Fact] public async Task Dispose_ReleasesCompletionSignalIfNotAlreadyReleased() { - var stream = new ResponseStream(MaxResponseSize); + var stream = new LambdaResponseStream(); var waitTask = stream.WaitForCompletionAsync(); Assert.False(waitTask.IsCompleted); diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/RuntimeApiClientTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/RuntimeApiClientTests.cs index 75abec101..fbc4a8ae6 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/RuntimeApiClientTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/RuntimeApiClientTests.cs @@ -40,9 +40,9 @@ public class RuntimeApiClientTests private class MockHttpMessageHandler : HttpMessageHandler { public HttpRequestMessage CapturedRequest { get; private set; } - private readonly ResponseStream _responseStream; + private readonly LambdaResponseStream _responseStream; - public MockHttpMessageHandler(ResponseStream responseStream) + public MockHttpMessageHandler(LambdaResponseStream responseStream) { _responseStream = responseStream; } @@ -57,7 +57,7 @@ protected override Task SendAsync( } private static RuntimeApiClient CreateClientWithMockHandler( - ResponseStream stream, out MockHttpMessageHandler handler) + LambdaResponseStream stream, out MockHttpMessageHandler handler) { handler = new MockHttpMessageHandler(stream); var httpClient = new HttpClient(handler); @@ -77,7 +77,7 @@ private static RuntimeApiClient CreateClientWithMockHandler( [Fact] public async Task StartStreamingResponseAsync_IncludesStreamingResponseModeHeader() { - var stream = new ResponseStream(MaxResponseSize); + var stream = new LambdaResponseStream(); var client = CreateClientWithMockHandler(stream, out var handler); await client.StartStreamingResponseAsync("req-1", stream, CancellationToken.None); @@ -100,7 +100,7 @@ public async Task StartStreamingResponseAsync_IncludesStreamingResponseModeHeade [Fact] public async Task StartStreamingResponseAsync_IncludesChunkedTransferEncodingHeader() { - var stream = new ResponseStream(MaxResponseSize); + var stream = new LambdaResponseStream(); var client = CreateClientWithMockHandler(stream, out var handler); await client.StartStreamingResponseAsync("req-2", stream, CancellationToken.None); @@ -121,7 +121,7 @@ public async Task StartStreamingResponseAsync_IncludesChunkedTransferEncodingHea [Fact] public async Task StartStreamingResponseAsync_DeclaresTrailerHeaderUpfront() { - var stream = new ResponseStream(MaxResponseSize); + var stream = new LambdaResponseStream(); var client = CreateClientWithMockHandler(stream, out var handler); await client.StartStreamingResponseAsync("req-3", stream, CancellationToken.None); @@ -144,7 +144,7 @@ public async Task StartStreamingResponseAsync_DeclaresTrailerHeaderUpfront() [Fact] public async Task StartStreamingResponseAsync_MarksStreamCompletedAfterSuccess() { - var stream = new ResponseStream(MaxResponseSize); + var stream = new LambdaResponseStream(); var client = CreateClientWithMockHandler(stream, out _); await client.StartStreamingResponseAsync("req-4", stream, CancellationToken.None); @@ -203,7 +203,7 @@ public async Task SendResponseAsync_BufferedResponse_ExcludesStreamingHeaders() [Fact] public async Task StartStreamingResponseAsync_NullRequestId_ThrowsArgumentNullException() { - var stream = new ResponseStream(MaxResponseSize); + var stream = new LambdaResponseStream(); var client = CreateClientWithMockHandler(stream, out _); await Assert.ThrowsAsync( @@ -213,7 +213,7 @@ await Assert.ThrowsAsync( [Fact] public async Task StartStreamingResponseAsync_NullResponseStream_ThrowsArgumentNullException() { - var stream = new ResponseStream(MaxResponseSize); + var stream = new LambdaResponseStream(); var client = CreateClientWithMockHandler(stream, out _); await Assert.ThrowsAsync( diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingHttpContentTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingHttpContentTests.cs index 53b1e88b7..1f85f47a8 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingHttpContentTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingHttpContentTests.cs @@ -32,8 +32,8 @@ public class StreamingHttpContentTests /// Returns the bytes written to the HTTP output stream. /// private async Task SerializeWithConcurrentHandler( - ResponseStream responseStream, - Func handlerAction) + LambdaResponseStream responseStream, + Func handlerAction) { var content = new StreamingHttpContent(responseStream); var outputStream = new MemoryStream(); @@ -63,7 +63,7 @@ private async Task SerializeWithConcurrentHandler( [Fact] public async Task SerializeToStreamAsync_HandsOffHttpStream_WritesFlowThrough() { - var rs = new ResponseStream(MaxResponseSize); + var rs = new LambdaResponseStream(); var output = await SerializeWithConcurrentHandler(rs, async stream => { @@ -84,7 +84,7 @@ public async Task SerializeToStreamAsync_HandsOffHttpStream_WritesFlowThrough() [Fact] public async Task SerializeToStreamAsync_BlocksUntilMarkCompleted() { - var rs = new ResponseStream(MaxResponseSize); + var rs = new LambdaResponseStream(); var content = new StreamingHttpContent(rs); var outputStream = new MemoryStream(); @@ -108,7 +108,7 @@ public async Task SerializeToStreamAsync_BlocksUntilMarkCompleted() [Fact] public async Task SerializeToStreamAsync_BlocksUntilReportErrorAsync() { - var rs = new ResponseStream(MaxResponseSize); + var rs = new LambdaResponseStream(); var content = new StreamingHttpContent(rs); var outputStream = new MemoryStream(); @@ -132,7 +132,7 @@ public async Task SerializeToStreamAsync_BlocksUntilReportErrorAsync() [Fact] public async Task FinalChunk_WrittenAfterCompletion() { - var rs = new ResponseStream(MaxResponseSize); + var rs = new LambdaResponseStream(); var output = await SerializeWithConcurrentHandler(rs, async stream => { @@ -156,7 +156,7 @@ public async Task FinalChunk_WrittenAfterCompletion() [Fact] public async Task FinalChunk_EmptyStream_StillWritten() { - var rs = new ResponseStream(MaxResponseSize); + var rs = new LambdaResponseStream(); var output = await SerializeWithConcurrentHandler(rs, stream => { @@ -177,7 +177,7 @@ public async Task FinalChunk_EmptyStream_StillWritten() [Fact] public async Task ErrorTrailers_AppearAfterFinalChunk() { - var rs = new ResponseStream(MaxResponseSize); + var rs = new LambdaResponseStream(); var output = await SerializeWithConcurrentHandler(rs, async stream => { @@ -210,7 +210,7 @@ public async Task ErrorTrailers_AppearAfterFinalChunk() [InlineData(typeof(NullReferenceException))] public async Task ErrorTrailer_IncludesErrorType(Type exceptionType) { - var rs = new ResponseStream(MaxResponseSize); + var rs = new LambdaResponseStream(); var output = await SerializeWithConcurrentHandler(rs, async stream => { @@ -232,7 +232,7 @@ public async Task ErrorTrailer_IncludesErrorType(Type exceptionType) [Fact] public async Task ErrorTrailer_IncludesJsonErrorBody() { - var rs = new ResponseStream(MaxResponseSize); + var rs = new LambdaResponseStream(); var output = await SerializeWithConcurrentHandler(rs, async stream => { @@ -255,7 +255,7 @@ public async Task ErrorTrailer_IncludesJsonErrorBody() [Fact] public async Task SuccessfulCompletion_EndsWithCrlf() { - var rs = new ResponseStream(MaxResponseSize); + var rs = new LambdaResponseStream(); var output = await SerializeWithConcurrentHandler(rs, async stream => { @@ -275,7 +275,7 @@ public async Task SuccessfulCompletion_EndsWithCrlf() [Fact] public async Task ErrorCompletion_EndsWithCrlf() { - var rs = new ResponseStream(MaxResponseSize); + var rs = new LambdaResponseStream(); var output = await SerializeWithConcurrentHandler(rs, async stream => { @@ -292,7 +292,7 @@ public async Task ErrorCompletion_EndsWithCrlf() [Fact] public async Task NoError_NoTrailersWritten() { - var rs = new ResponseStream(MaxResponseSize); + var rs = new LambdaResponseStream(); var output = await SerializeWithConcurrentHandler(rs, async stream => { @@ -310,7 +310,7 @@ public async Task NoError_NoTrailersWritten() [Fact] public void TryComputeLength_ReturnsFalse() { - var stream = new ResponseStream(MaxResponseSize); + var stream = new LambdaResponseStream(); var content = new StreamingHttpContent(stream); var result = content.Headers.ContentLength; @@ -326,7 +326,7 @@ public void TryComputeLength_ReturnsFalse() [Fact] public async Task CrlfTerminators_NoBareLineFeed() { - var rs = new ResponseStream(MaxResponseSize); + var rs = new LambdaResponseStream(); var output = await SerializeWithConcurrentHandler(rs, async stream => { diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingIntegrationTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingIntegrationTests.cs index c2bd34bdf..0f15680f4 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingIntegrationTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingIntegrationTests.cs @@ -38,8 +38,8 @@ public class StreamingIntegrationTests : IDisposable { public void Dispose() { - ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: false); - ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); + LambdaResponseStreamFactory.CleanupInvocation(isMultiConcurrency: false); + LambdaResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); } // ─── Helpers ──────────────────────────────────────────────────────────────── @@ -67,7 +67,7 @@ private class CapturingStreamingRuntimeApiClient : RuntimeApiClient, IRuntimeApi public bool SendResponseCalled { get; private set; } public bool ReportInvocationErrorCalled { get; private set; } public byte[] CapturedHttpBytes { get; private set; } - public ResponseStream LastResponseStream { get; private set; } + public LambdaResponseStream LastResponseStream { get; private set; } public Stream LastBufferedOutputStream { get; private set; } public new Amazon.Lambda.RuntimeSupport.Helpers.IConsoleLoggerWriter ConsoleLogger { get; } = new Helpers.LogLevelLoggerWriter(new SystemEnvironmentVariables()); @@ -97,7 +97,7 @@ public CapturingStreamingRuntimeApiClient( } internal override async Task StartStreamingResponseAsync( - string awsRequestId, ResponseStream responseStream, CancellationToken cancellationToken = default) + string awsRequestId, LambdaResponseStream responseStream, CancellationToken cancellationToken = default) { StartStreamingCalled = true; LastResponseStream = responseStream; @@ -159,7 +159,7 @@ public async Task Streaming_MultipleChunks_FlowThroughWithChunkedEncoding() LambdaBootstrapHandler handler = async (invocation) => { - var stream = ResponseStreamFactory.CreateStream(); + var stream = LambdaResponseStreamFactory.CreateStream(); foreach (var chunk in chunks) await stream.WriteAsync(Encoding.UTF8.GetBytes(chunk)); return new InvocationResponse(Stream.Null, false); @@ -196,7 +196,7 @@ public async Task Streaming_AllDataTransmitted_ContentRoundTrip() LambdaBootstrapHandler handler = async (invocation) => { - var stream = ResponseStreamFactory.CreateStream(); + var stream = LambdaResponseStreamFactory.CreateStream(); await stream.WriteAsync(payload); return new InvocationResponse(Stream.Null, false); }; @@ -227,7 +227,7 @@ public async Task Streaming_StreamFinalized_BytesWrittenMatchesPayload() LambdaBootstrapHandler handler = async (invocation) => { - var stream = ResponseStreamFactory.CreateStream(); + var stream = LambdaResponseStreamFactory.CreateStream(); await stream.WriteAsync(data); return new InvocationResponse(Stream.Null, false); }; @@ -309,7 +309,7 @@ public async Task MidstreamError_ErrorTrailersIncludedAfterFinalChunk() LambdaBootstrapHandler handler = async (invocation) => { - var stream = ResponseStreamFactory.CreateStream(); + var stream = LambdaResponseStreamFactory.CreateStream(); await stream.WriteAsync(Encoding.UTF8.GetBytes("partial data")); throw new InvalidOperationException("midstream failure"); }; @@ -355,7 +355,7 @@ public async Task PreStreamError_ExceptionBeforeAnyWrite_UsesStandardErrorReport LambdaBootstrapHandler handler = async (invocation) => { - var stream = ResponseStreamFactory.CreateStream(); + var stream = LambdaResponseStreamFactory.CreateStream(); // Throw before writing anything throw new ArgumentException("pre-write failure"); }; @@ -381,7 +381,7 @@ public async Task MidstreamError_ErrorBodyTrailerContainsJsonDetails() LambdaBootstrapHandler handler = async (invocation) => { - var stream = ResponseStreamFactory.CreateStream(); + var stream = LambdaResponseStreamFactory.CreateStream(); await stream.WriteAsync(Encoding.UTF8.GetBytes("some data")); throw new InvalidOperationException(errorMessage); }; @@ -419,27 +419,26 @@ public async Task MultiConcurrency_ConcurrentInvocations_StateIsolated() tasks.Add(Task.Run(async () => { var mockClient = new MockMultiConcurrencyStreamingClient(); - ResponseStreamFactory.InitializeInvocation( + LambdaResponseStreamFactory.InitializeInvocation( requestId, - StreamingConstants.MaxResponseSize, isMultiConcurrency: true, mockClient, CancellationToken.None); - var stream = ResponseStreamFactory.CreateStream(); + var stream = LambdaResponseStreamFactory.CreateStream(); allStarted.Release(); // Wait until all tasks have started (to ensure true concurrency) await barrier.WaitAsync(); await stream.WriteAsync(Encoding.UTF8.GetBytes(payload)); - ((ResponseStream)stream).MarkCompleted(); + stream.MarkCompleted(); // Verify this invocation's stream is still accessible - var retrieved = ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: true); + var retrieved = LambdaResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: true); results[requestId] = retrieved != null ? payload : "MISSING"; - ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); + LambdaResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); })); } @@ -477,20 +476,20 @@ public async Task MultiConcurrency_StreamingAndBufferedMixedConcurrently_NoInter tasks.Add(Task.Run(async () => { var mockClient = new MockMultiConcurrencyStreamingClient(); - ResponseStreamFactory.InitializeInvocation( - requestId, StreamingConstants.MaxResponseSize, + LambdaResponseStreamFactory.InitializeInvocation( + requestId, isMultiConcurrency: true, mockClient, CancellationToken.None); - var stream = ResponseStreamFactory.CreateStream(); + var stream = LambdaResponseStreamFactory.CreateStream(); allStarted.Release(); await barrier.WaitAsync(); await stream.WriteAsync(Encoding.UTF8.GetBytes("streaming data")); - ((ResponseStream)stream).MarkCompleted(); + stream.MarkCompleted(); - var retrieved = ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: true); + var retrieved = LambdaResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: true); streamingResults.Add(retrieved != null); - ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); + LambdaResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); })); } @@ -501,17 +500,17 @@ public async Task MultiConcurrency_StreamingAndBufferedMixedConcurrently_NoInter tasks.Add(Task.Run(async () => { var mockClient = new MockMultiConcurrencyStreamingClient(); - ResponseStreamFactory.InitializeInvocation( - requestId, StreamingConstants.MaxResponseSize, + LambdaResponseStreamFactory.InitializeInvocation( + requestId, isMultiConcurrency: true, mockClient, CancellationToken.None); allStarted.Release(); await barrier.WaitAsync(); // No CreateStream — buffered mode - var retrieved = ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: true); + var retrieved = LambdaResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: true); bufferedResults.Add(retrieved == null); // should be null (no stream created) - ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); + LambdaResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); })); } @@ -538,7 +537,7 @@ public MockMultiConcurrencyStreamingClient() : base(new TestEnvironmentVariables(), new NoOpInternalRuntimeApiClient()) { } internal override async Task StartStreamingResponseAsync( - string awsRequestId, ResponseStream responseStream, CancellationToken cancellationToken = default) + string awsRequestId, LambdaResponseStream responseStream, CancellationToken cancellationToken = default) { // Provide the HTTP output stream so writes don't block responseStream.SetHttpOutputStream(new MemoryStream()); diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/TestHelpers/TestStreamingRuntimeApiClient.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/TestHelpers/TestStreamingRuntimeApiClient.cs index 1128bb075..da68d2940 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/TestHelpers/TestStreamingRuntimeApiClient.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/TestHelpers/TestStreamingRuntimeApiClient.cs @@ -54,7 +54,7 @@ public TestStreamingRuntimeApiClient(IEnvironmentVariables environmentVariables, public byte[] FunctionInput { get; set; } public Stream LastOutputStream { get; private set; } public Exception LastRecordedException { get; private set; } - public ResponseStream LastStreamingResponseStream { get; private set; } + public LambdaResponseStream LastStreamingResponseStream { get; private set; } public new async Task GetNextInvocationAsync(CancellationToken cancellationToken = default) { @@ -108,7 +108,7 @@ public TestStreamingRuntimeApiClient(IEnvironmentVariables environmentVariables, } internal override async Task StartStreamingResponseAsync( - string awsRequestId, ResponseStream responseStream, CancellationToken cancellationToken = default) + string awsRequestId, LambdaResponseStream responseStream, CancellationToken cancellationToken = default) { StartStreamingResponseAsyncCalled = true; LastStreamingResponseStream = responseStream; From 21d82d85116fd9f01fbfdc612c3be2cd5ae1b25e Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Wed, 18 Feb 2026 17:58:02 -0800 Subject: [PATCH 09/20] Cleanup --- .../Client/ILambdaResponseStream.cs | 2 +- .../serverless.template | 659 +++++++++- .../serverless.template | 22 +- .../TestServerlessApp/serverless.template | 1149 ++++++++++++++++- 4 files changed, 1826 insertions(+), 6 deletions(-) diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ILambdaResponseStream.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ILambdaResponseStream.cs index 36236b28d..d3565fdbc 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ILambdaResponseStream.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ILambdaResponseStream.cs @@ -21,7 +21,7 @@ namespace Amazon.Lambda.RuntimeSupport { /// /// Interface for writing streaming responses in AWS Lambda functions. - /// Obtained by calling ResponseStreamFactory.CreateStream() within a handler. + /// Obtained by calling within a handler. /// public interface ILambdaResponseStream : IDisposable { diff --git a/Libraries/test/TestExecutableServerlessApp/serverless.template b/Libraries/test/TestExecutableServerlessApp/serverless.template index 229385aba..ac43959b7 100644 --- a/Libraries/test/TestExecutableServerlessApp/serverless.template +++ b/Libraries/test/TestExecutableServerlessApp/serverless.template @@ -1,7 +1,7 @@ { "AWSTemplateFormatVersion": "2010-09-09", "Transform": "AWS::Serverless-2016-10-31", - "Description": "An AWS Serverless Application. This template is partially managed by Amazon.Lambda.Annotations (v1.8.0.0).", + "Description": "An AWS Serverless Application. This template is partially managed by Amazon.Lambda.Annotations (v1.9.0.0).", "Parameters": { "ArchitectureTypeParameter": { "Type": "String", @@ -21,7 +21,662 @@ ] } }, - "Resources": {}, + "Resources": { + "TestServerlessAppCustomizeResponseExamplesOkResponseWithHeaderGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestExecutableServerlessApp" + ] + }, + "Environment": { + "Variables": { + "ANNOTATIONS_HANDLER": "OkResponseWithHeader" + } + }, + "Events": { + "RootGet": { + "Type": "Api", + "Properties": { + "Path": "/okresponsewithheader/{x}", + "Method": "GET" + } + } + } + } + }, + "TestServerlessAppCustomizeResponseExamplesOkResponseWithHeaderAsyncGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestExecutableServerlessApp" + ] + }, + "Environment": { + "Variables": { + "ANNOTATIONS_HANDLER": "OkResponseWithHeaderAsync" + } + }, + "Events": { + "RootGet": { + "Type": "Api", + "Properties": { + "Path": "/okresponsewithheaderasync/{x}", + "Method": "GET" + } + } + } + } + }, + "TestServerlessAppCustomizeResponseExamplesNotFoundResponseWithHeaderV2Generated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestExecutableServerlessApp" + ] + }, + "Environment": { + "Variables": { + "ANNOTATIONS_HANDLER": "NotFoundResponseWithHeaderV2" + } + }, + "Events": { + "RootGet": { + "Type": "HttpApi", + "Properties": { + "Path": "/notfoundwithheaderv2/{x}", + "Method": "GET" + } + } + } + } + }, + "TestServerlessAppCustomizeResponseExamplesNotFoundResponseWithHeaderV2AsyncGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestExecutableServerlessApp" + ] + }, + "Environment": { + "Variables": { + "ANNOTATIONS_HANDLER": "NotFoundResponseWithHeaderV2Async" + } + }, + "Events": { + "RootGet": { + "Type": "HttpApi", + "Properties": { + "Path": "/notfoundwithheaderv2async/{x}", + "Method": "GET" + } + } + } + } + }, + "TestServerlessAppCustomizeResponseExamplesNotFoundResponseWithHeaderV1Generated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method", + "PayloadFormatVersion" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestExecutableServerlessApp" + ] + }, + "Environment": { + "Variables": { + "ANNOTATIONS_HANDLER": "NotFoundResponseWithHeaderV1" + } + }, + "Events": { + "RootGet": { + "Type": "HttpApi", + "Properties": { + "Path": "/notfoundwithheaderv1/{x}", + "Method": "GET", + "PayloadFormatVersion": "1.0" + } + } + } + } + }, + "TestServerlessAppCustomizeResponseExamplesNotFoundResponseWithHeaderV1AsyncGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method", + "PayloadFormatVersion" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestExecutableServerlessApp" + ] + }, + "Environment": { + "Variables": { + "ANNOTATIONS_HANDLER": "NotFoundResponseWithHeaderV1Async" + } + }, + "Events": { + "RootGet": { + "Type": "HttpApi", + "Properties": { + "Path": "/notfoundwithheaderv1async/{x}", + "Method": "GET", + "PayloadFormatVersion": "1.0" + } + } + } + } + }, + "TestServerlessAppDynamicExampleDynamicReturnGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations" + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestExecutableServerlessApp" + ] + }, + "Environment": { + "Variables": { + "ANNOTATIONS_HANDLER": "DynamicReturn" + } + } + } + }, + "TestServerlessAppDynamicExampleDynamicInputGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations" + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestExecutableServerlessApp" + ] + }, + "Environment": { + "Variables": { + "ANNOTATIONS_HANDLER": "DynamicInput" + } + } + } + }, + "GreeterSayHello": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method", + "PayloadFormatVersion" + ] + } + }, + "Properties": { + "MemorySize": 1024, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestExecutableServerlessApp" + ] + }, + "Environment": { + "Variables": { + "ANNOTATIONS_HANDLER": "SayHello" + } + }, + "Events": { + "RootGet": { + "Type": "HttpApi", + "Properties": { + "Path": "/Greeter/SayHello", + "Method": "GET", + "PayloadFormatVersion": "1.0" + } + } + } + } + }, + "GreeterSayHelloAsync": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method", + "PayloadFormatVersion" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 50, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestExecutableServerlessApp" + ] + }, + "Environment": { + "Variables": { + "ANNOTATIONS_HANDLER": "SayHelloAsync" + } + }, + "Events": { + "RootGet": { + "Type": "HttpApi", + "Properties": { + "Path": "/Greeter/SayHelloAsync", + "Method": "GET", + "PayloadFormatVersion": "1.0" + } + } + } + } + }, + "TestServerlessAppIntrinsicExampleHasIntrinsicGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations" + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestExecutableServerlessApp" + ] + }, + "Environment": { + "Variables": { + "ANNOTATIONS_HANDLER": "HasIntrinsic" + } + } + } + }, + "TestServerlessAppNullableReferenceTypeExampleNullableHeaderHttpApiGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestExecutableServerlessApp" + ] + }, + "Environment": { + "Variables": { + "ANNOTATIONS_HANDLER": "NullableHeaderHttpApi" + } + }, + "Events": { + "RootGet": { + "Type": "HttpApi", + "Properties": { + "Path": "/nullableheaderhttpapi", + "Method": "GET" + } + } + } + } + }, + "TestServerlessAppParameterlessMethodsNoParameterGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations" + }, + "Properties": { + "Runtime": "provided.al2", + "CodeUri": ".", + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Zip", + "Handler": "TestExecutableServerlessApp", + "Environment": { + "Variables": { + "ANNOTATIONS_HANDLER": "NoParameter" + } + } + } + }, + "TestServerlessAppParameterlessMethodWithResponseNoParameterWithResponseGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations" + }, + "Properties": { + "Runtime": "provided.al2", + "CodeUri": ".", + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Zip", + "Handler": "TestExecutableServerlessApp", + "Environment": { + "Variables": { + "ANNOTATIONS_HANDLER": "NoParameterWithResponse" + } + } + } + }, + "TestExecutableServerlessAppSourceGenerationSerializationExampleGetPersonGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestExecutableServerlessApp" + ] + }, + "Environment": { + "Variables": { + "ANNOTATIONS_HANDLER": "GetPerson" + } + }, + "Events": { + "RootGet": { + "Type": "Api", + "Properties": { + "Path": "/", + "Method": "GET" + } + } + } + } + }, + "ToUpper": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations" + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestExecutableServerlessApp" + ] + }, + "Environment": { + "Variables": { + "ANNOTATIONS_HANDLER": "ToUpper" + } + } + } + }, + "ToLower": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations" + }, + "Properties": { + "Runtime": "provided.al2", + "CodeUri": ".", + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Zip", + "Handler": "TestExecutableServerlessApp", + "Environment": { + "Variables": { + "ANNOTATIONS_HANDLER": "ToLower" + } + } + } + }, + "TestServerlessAppTaskExampleTaskReturnGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations" + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestExecutableServerlessApp" + ] + }, + "Environment": { + "Variables": { + "ANNOTATIONS_HANDLER": "TaskReturn" + } + } + } + }, + "TestServerlessAppVoidExampleVoidReturnGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations" + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestExecutableServerlessApp" + ] + }, + "Environment": { + "Variables": { + "ANNOTATIONS_HANDLER": "VoidReturn" + } + } + } + } + }, "Outputs": { "RestApiURL": { "Description": "Rest API endpoint URL for Prod environment", diff --git a/Libraries/test/TestServerlessApp.NET8/serverless.template b/Libraries/test/TestServerlessApp.NET8/serverless.template index 67ec5dfa4..c42ff4a47 100644 --- a/Libraries/test/TestServerlessApp.NET8/serverless.template +++ b/Libraries/test/TestServerlessApp.NET8/serverless.template @@ -1,6 +1,24 @@ { "AWSTemplateFormatVersion": "2010-09-09", "Transform": "AWS::Serverless-2016-10-31", - "Description": "This template is partially managed by Amazon.Lambda.Annotations (v1.8.0.0).", - "Resources": {} + "Description": "This template is partially managed by Amazon.Lambda.Annotations (v1.9.0.0).", + "Resources": { + "TestServerlessAppNET8FunctionsToUpperGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations" + }, + "Properties": { + "Runtime": "dotnet8", + "CodeUri": ".", + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Zip", + "Handler": "TestServerlessApp.NET8::TestServerlessApp.NET8.Functions_ToUpper_Generated::ToUpper" + } + } + } } \ No newline at end of file diff --git a/Libraries/test/TestServerlessApp/serverless.template b/Libraries/test/TestServerlessApp/serverless.template index e6c1b8bea..0e3befbe1 100644 --- a/Libraries/test/TestServerlessApp/serverless.template +++ b/Libraries/test/TestServerlessApp/serverless.template @@ -1,7 +1,7 @@ { "AWSTemplateFormatVersion": "2010-09-09", "Transform": "AWS::Serverless-2016-10-31", - "Description": "An AWS Serverless Application. This template is partially managed by Amazon.Lambda.Annotations (v1.8.0.0).", + "Description": "An AWS Serverless Application. This template is partially managed by Amazon.Lambda.Annotations (v1.9.0.0).", "Parameters": { "ArchitectureTypeParameter": { "Type": "String", @@ -28,6 +28,1153 @@ "Resources": { "TestQueue": { "Type": "AWS::SQS::Queue" + }, + "TestServerlessAppDynamicExampleDynamicReturnGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations" + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.DynamicExample_DynamicReturn_Generated::DynamicReturn" + ] + } + } + }, + "TestServerlessAppDynamicExampleDynamicInputGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations" + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.DynamicExample_DynamicInput_Generated::DynamicInput" + ] + } + } + }, + "TestServerlessAppFromScratchNoApiGatewayEventsReferenceToUpperGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.FromScratch.NoApiGatewayEventsReference_ToUpper_Generated::ToUpper" + ] + }, + "Events": { + "RootGet": { + "Type": "HttpApi", + "Properties": { + "Path": "/{text}", + "Method": "GET" + } + } + } + } + }, + "HttpApiAuthorizerTest": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.CustomAuthorizerHttpApiExample_HttpApiAuthorizer_Generated::HttpApiAuthorizer" + ] + }, + "Events": { + "RootGet": { + "Type": "HttpApi", + "Properties": { + "Path": "/api/authorizer", + "Method": "GET" + } + } + } + } + }, + "SimpleCalculatorAdd": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.SimpleCalculator_Add_Generated::Add" + ] + }, + "Events": { + "RootGet": { + "Type": "Api", + "Properties": { + "Path": "/SimpleCalculator/Add", + "Method": "GET" + } + } + } + } + }, + "SimpleCalculatorSubtract": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.SimpleCalculator_Subtract_Generated::Subtract" + ] + }, + "Events": { + "RootGet": { + "Type": "Api", + "Properties": { + "Path": "/SimpleCalculator/Subtract", + "Method": "GET" + } + } + } + } + }, + "SimpleCalculatorMultiply": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.SimpleCalculator_Multiply_Generated::Multiply" + ] + }, + "Events": { + "RootGet": { + "Type": "Api", + "Properties": { + "Path": "/SimpleCalculator/Multiply/{x}/{y}", + "Method": "GET" + } + } + } + } + }, + "SimpleCalculatorDivideAsync": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.SimpleCalculator_DivideAsync_Generated::DivideAsync" + ] + }, + "Events": { + "RootGet": { + "Type": "Api", + "Properties": { + "Path": "/SimpleCalculator/DivideAsync/{x}/{y}", + "Method": "GET" + } + } + } + } + }, + "PI": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations" + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.SimpleCalculator_Pi_Generated::Pi" + ] + } + } + }, + "Random": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations" + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.SimpleCalculator_Random_Generated::Random" + ] + } + } + }, + "Randoms": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations" + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.SimpleCalculator_Randoms_Generated::Randoms" + ] + } + } + }, + "SQSMessageHandler": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "TestQueueEvent" + ], + "SyncedEventProperties": { + "TestQueueEvent": [ + "Queue.Fn::GetAtt", + "BatchSize", + "FilterCriteria.Filters", + "FunctionResponseTypes", + "MaximumBatchingWindowInSeconds", + "ScalingConfig.MaximumConcurrency" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaSQSQueueExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.SqsMessageProcessing_HandleMessage_Generated::HandleMessage" + ] + }, + "Events": { + "TestQueueEvent": { + "Type": "SQS", + "Properties": { + "BatchSize": 50, + "FilterCriteria": { + "Filters": [ + { + "Pattern": "{ \"body\" : { \"RequestCode\" : [ \"BBBB\" ] } }" + } + ] + }, + "FunctionResponseTypes": [ + "ReportBatchItemFailures" + ], + "MaximumBatchingWindowInSeconds": 5, + "ScalingConfig": { + "MaximumConcurrency": 5 + }, + "Queue": { + "Fn::GetAtt": [ + "TestQueue", + "Arn" + ] + } + } + } + } + } + }, + "HttpApiV1AuthorizerTest": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method", + "PayloadFormatVersion" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.CustomAuthorizerHttpApiV1Example_HttpApiV1Authorizer_Generated::HttpApiV1Authorizer" + ] + }, + "Events": { + "RootGet": { + "Type": "HttpApi", + "Properties": { + "Path": "/api/authorizer-v1", + "Method": "GET", + "PayloadFormatVersion": "1.0" + } + } + } + } + }, + "TestServerlessAppNullableReferenceTypeExampleNullableHeaderHttpApiGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.NullableReferenceTypeExample_NullableHeaderHttpApi_Generated::NullableHeaderHttpApi" + ] + }, + "Events": { + "RootGet": { + "Type": "HttpApi", + "Properties": { + "Path": "/nullableheaderhttpapi", + "Method": "GET" + } + } + } + } + }, + "TestServerlessAppCustomAuthorizerWithIHttpResultsExampleAuthorizerWithIHttpResultsGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.CustomAuthorizerWithIHttpResultsExample_AuthorizerWithIHttpResults_Generated::AuthorizerWithIHttpResults" + ] + }, + "Events": { + "RootGet": { + "Type": "HttpApi", + "Properties": { + "Path": "/authorizerihttpresults", + "Method": "GET" + } + } + } + } + }, + "GreeterSayHello": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method", + "PayloadFormatVersion" + ] + } + }, + "Properties": { + "MemorySize": 1024, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.Greeter_SayHello_Generated::SayHello" + ] + }, + "Events": { + "RootGet": { + "Type": "HttpApi", + "Properties": { + "Path": "/Greeter/SayHello", + "Method": "GET", + "PayloadFormatVersion": "1.0" + } + } + } + } + }, + "GreeterSayHelloAsync": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method", + "PayloadFormatVersion" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 50, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.Greeter_SayHelloAsync_Generated::SayHelloAsync" + ] + }, + "Events": { + "RootGet": { + "Type": "HttpApi", + "Properties": { + "Path": "/Greeter/SayHelloAsync", + "Method": "GET", + "PayloadFormatVersion": "1.0" + } + } + } + } + }, + "TestServerlessAppCustomizeResponseExamplesOkResponseWithHeaderGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.CustomizeResponseExamples_OkResponseWithHeader_Generated::OkResponseWithHeader" + ] + }, + "Events": { + "RootGet": { + "Type": "Api", + "Properties": { + "Path": "/okresponsewithheader/{x}", + "Method": "GET" + } + } + } + } + }, + "TestServerlessAppCustomizeResponseExamplesOkResponseWithHeaderAsyncGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.CustomizeResponseExamples_OkResponseWithHeaderAsync_Generated::OkResponseWithHeaderAsync" + ] + }, + "Events": { + "RootGet": { + "Type": "Api", + "Properties": { + "Path": "/okresponsewithheaderasync/{x}", + "Method": "GET" + } + } + } + } + }, + "TestServerlessAppCustomizeResponseExamplesNotFoundResponseWithHeaderV2Generated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.CustomizeResponseExamples_NotFoundResponseWithHeaderV2_Generated::NotFoundResponseWithHeaderV2" + ] + }, + "Events": { + "RootGet": { + "Type": "HttpApi", + "Properties": { + "Path": "/notfoundwithheaderv2/{x}", + "Method": "GET" + } + } + } + } + }, + "TestServerlessAppCustomizeResponseExamplesNotFoundResponseWithHeaderV2AsyncGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.CustomizeResponseExamples_NotFoundResponseWithHeaderV2Async_Generated::NotFoundResponseWithHeaderV2Async" + ] + }, + "Events": { + "RootGet": { + "Type": "HttpApi", + "Properties": { + "Path": "/notfoundwithheaderv2async/{x}", + "Method": "GET" + } + } + } + } + }, + "TestServerlessAppCustomizeResponseExamplesNotFoundResponseWithHeaderV1Generated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method", + "PayloadFormatVersion" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.CustomizeResponseExamples_NotFoundResponseWithHeaderV1_Generated::NotFoundResponseWithHeaderV1" + ] + }, + "Events": { + "RootGet": { + "Type": "HttpApi", + "Properties": { + "Path": "/notfoundwithheaderv1/{x}", + "Method": "GET", + "PayloadFormatVersion": "1.0" + } + } + } + } + }, + "TestServerlessAppCustomizeResponseExamplesNotFoundResponseWithHeaderV1AsyncGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method", + "PayloadFormatVersion" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.CustomizeResponseExamples_NotFoundResponseWithHeaderV1Async_Generated::NotFoundResponseWithHeaderV1Async" + ] + }, + "Events": { + "RootGet": { + "Type": "HttpApi", + "Properties": { + "Path": "/notfoundwithheaderv1async/{x}", + "Method": "GET", + "PayloadFormatVersion": "1.0" + } + } + } + } + }, + "TestServerlessAppCustomizeResponseExamplesOkResponseWithCustomSerializerGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method", + "PayloadFormatVersion" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.CustomizeResponseExamples_OkResponseWithCustomSerializer_Generated::OkResponseWithCustomSerializer" + ] + }, + "Events": { + "RootGet": { + "Type": "HttpApi", + "Properties": { + "Path": "/okresponsewithcustomserializerasync/{firstName}/{lastName}", + "Method": "GET", + "PayloadFormatVersion": "1.0" + } + } + } + } + }, + "TestServerlessAppFromScratchNoSerializerAttributeReferenceToUpperGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations" + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.FromScratch.NoSerializerAttributeReference_ToUpper_Generated::ToUpper" + ] + } + } + }, + "TestServerlessAppIntrinsicExampleHasIntrinsicGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations" + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.IntrinsicExample_HasIntrinsic_Generated::HasIntrinsic" + ] + } + } + }, + "HttpApiNonString": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.CustomAuthorizerNonStringExample_HttpApiWithNonString_Generated::HttpApiWithNonString" + ] + }, + "Events": { + "RootGet": { + "Type": "HttpApi", + "Properties": { + "Path": "/api/authorizer-non-string", + "Method": "GET" + } + } + } + } + }, + "AuthNameFallbackTest": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.AuthNameFallback_GetUserId_Generated::GetUserId" + ] + }, + "Events": { + "RootGet": { + "Type": "HttpApi", + "Properties": { + "Path": "/api/authorizer-fallback", + "Method": "GET" + } + } + } + } + }, + "TestServerlessAppVoidExampleVoidReturnGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations" + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.VoidExample_VoidReturn_Generated::VoidReturn" + ] + } + } + }, + "RestAuthorizerTest": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.CustomAuthorizerRestExample_RestAuthorizer_Generated::RestAuthorizer" + ] + }, + "Events": { + "RootGet": { + "Type": "Api", + "Properties": { + "Path": "/rest/authorizer", + "Method": "GET" + } + } + } + } + }, + "TestServerlessAppTaskExampleTaskReturnGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations" + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.TaskExample_TaskReturn_Generated::TaskReturn" + ] + } + } + }, + "ToUpper": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations" + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.Sub1.Functions_ToUpper_Generated::ToUpper" + ] + } + } + }, + "TestServerlessAppComplexCalculatorAddGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootPost" + ], + "SyncedEventProperties": { + "RootPost": [ + "Path", + "Method" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.ComplexCalculator_Add_Generated::Add" + ] + }, + "Events": { + "RootPost": { + "Type": "HttpApi", + "Properties": { + "Path": "/ComplexCalculator/Add", + "Method": "POST" + } + } + } + } + }, + "TestServerlessAppComplexCalculatorSubtractGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootPost" + ], + "SyncedEventProperties": { + "RootPost": [ + "Path", + "Method" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.ComplexCalculator_Subtract_Generated::Subtract" + ] + }, + "Events": { + "RootPost": { + "Type": "HttpApi", + "Properties": { + "Path": "/ComplexCalculator/Subtract", + "Method": "POST" + } + } + } + } } }, "Outputs": { From 556b7262472ba70f31c693c45ecf9f42200da582 Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Wed, 18 Feb 2026 23:41:19 -0800 Subject: [PATCH 10/20] Remove tests --- .../c27a62e6-91ca-4a59-9406-394866cdfa62.json | 11 +++++ .../ResponseStreamTests.cs | 41 ------------------- 2 files changed, 11 insertions(+), 41 deletions(-) create mode 100644 .autover/changes/c27a62e6-91ca-4a59-9406-394866cdfa62.json diff --git a/.autover/changes/c27a62e6-91ca-4a59-9406-394866cdfa62.json b/.autover/changes/c27a62e6-91ca-4a59-9406-394866cdfa62.json new file mode 100644 index 000000000..9ad5afe6e --- /dev/null +++ b/.autover/changes/c27a62e6-91ca-4a59-9406-394866cdfa62.json @@ -0,0 +1,11 @@ +{ + "Projects": [ + { + "Name": "Amazon.Lambda.RuntimeSupport", + "Type": "Minor", + "ChangelogMessages": [ + "Add response streaming support" + ] + } + ] +} diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs index 735fba482..a4d265228 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs @@ -216,47 +216,6 @@ public async Task ReportErrorAsync_ReleasesCompletionSignal() Assert.True(stream.HasError); } - // ---- Property 6: Size Limit Enforcement ---- - - /// - /// Property 6: Size Limit Enforcement — single write exceeding limit throws. - /// Validates: Requirements 3.6, 3.7 - /// - [Theory] - [InlineData(21 * 1024 * 1024)] - public async Task SizeLimit_SingleWriteExceedingLimit_Throws(int writeSize) - { - var (stream, _) = CreateWiredStream(); - var data = new byte[writeSize]; - - await Assert.ThrowsAsync(() => stream.WriteAsync(data)); - } - - /// - /// Property 6: Size Limit Enforcement — multiple writes exceeding limit throws. - /// Validates: Requirements 3.6, 3.7 - /// - [Fact] - public async Task SizeLimit_MultipleWritesExceedingLimit_Throws() - { - var (stream, _) = CreateWiredStream(); - - await stream.WriteAsync(new byte[10 * 1024 * 1024]); - await Assert.ThrowsAsync( - () => stream.WriteAsync(new byte[11 * 1024 * 1024])); - } - - [Fact] - public async Task SizeLimit_ExactlyAtLimit_Succeeds() - { - var (stream, _) = CreateWiredStream(); - var data = new byte[20 * 1024 * 1024]; - - await stream.WriteAsync(data); - - Assert.Equal(data.Length, stream.BytesWritten); - } - // ---- Property 19: Writes After Completion Rejected ---- /// From 4d5dee2fd25fa22cd55c41fa278c682ec381981f Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Wed, 4 Mar 2026 16:10:29 -0800 Subject: [PATCH 11/20] Clean up and rework Semaphore locks --- .../Bootstrap/LambdaBootstrap.cs | 27 +++---- .../Client/ILambdaResponseStream.cs | 13 ---- ...bdaResponseStream.ILambdaResponseStream.cs | 55 +++++++------- .../Client/RuntimeApiClient.cs | 2 - .../Client/StreamingHttpContent.cs | 3 + .../ResponseStreamTests.cs | 73 +++---------------- .../RuntimeApiClientTests.cs | 19 ----- ...grationTests.cs => StreamingE2EWithMoq.cs} | 28 +------ .../StreamingHttpContentTests.cs | 10 +-- 9 files changed, 62 insertions(+), 168 deletions(-) rename Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/{StreamingIntegrationTests.cs => StreamingE2EWithMoq.cs} (95%) diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/LambdaBootstrap.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/LambdaBootstrap.cs index 6241fb61f..ba44c05ed 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/LambdaBootstrap.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/LambdaBootstrap.cs @@ -385,15 +385,13 @@ internal async Task InvokeOnceAsync(CancellationToken cancellationToken = defaul { WriteUnhandledExceptionToLog(exception); - var streamIfCreated = LambdaResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency); - if (streamIfCreated != null && streamIfCreated.BytesWritten > 0) + var responseStream = LambdaResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency); + if (responseStream != null) { - // Midstream error — report via trailers on the already-open HTTP connection - await streamIfCreated.ReportErrorAsync(exception); + responseStream.ReportError(exception); } else { - // Error before streaming started — use standard error reporting await Client.ReportInvocationErrorAsync(invocation.LambdaContext.AwsRequestId, exception, cancellationToken); } } @@ -402,17 +400,20 @@ internal async Task InvokeOnceAsync(CancellationToken cancellationToken = defaul _logger.LogInformation("Finished invoking handler"); } - // If streaming was started, await the HTTP send task to ensure it completes - var sendTask = LambdaResponseStreamFactory.GetSendTask(isMultiConcurrency); - if (sendTask != null) + var streamIfCreated = LambdaResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency); + if (streamIfCreated != null) { - var stream = LambdaResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency); - if (stream != null && !stream.IsCompleted && !stream.HasError) + streamIfCreated.MarkCompleted(); + + // If streaming was started, await the HTTP send task to ensure it completes + var sendTask = LambdaResponseStreamFactory.GetSendTask(isMultiConcurrency); + if (sendTask != null) { - // Handler returned successfully — signal stream completion - stream.MarkCompleted(); + // Wait for the streaming response to finish sending before allowing the next invocation to be processed. This ensures that responses are sent in the order the invocations were received. + await sendTask; } - await sendTask; // Wait for HTTP request to finish + + streamIfCreated.ManualDispose(); } else if (invokeSucceeded) { diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ILambdaResponseStream.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ILambdaResponseStream.cs index d3565fdbc..af7c1a59f 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ILambdaResponseStream.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ILambdaResponseStream.cs @@ -45,25 +45,12 @@ public interface ILambdaResponseStream : IDisposable /// Thrown if the stream is already completed or an error has been reported. Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken = default); - /// - /// Reports an error that occurred during streaming. - /// This will send error information via HTTP trailing headers. - /// - /// The exception to report. - /// Optional cancellation token. - /// A task representing the asynchronous operation. - /// Thrown if the stream is already completed or an error has already been reported. - Task ReportErrorAsync(Exception exception, CancellationToken cancellationToken = default); /// /// Gets the total number of bytes written to the stream so far. /// long BytesWritten { get; } - /// - /// Gets whether the stream has been completed. - /// - bool IsCompleted { get; } /// /// Gets whether an error has been reported. diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStream.ILambdaResponseStream.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStream.ILambdaResponseStream.cs index 7830c81b4..9a5e6a651 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStream.ILambdaResponseStream.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStream.ILambdaResponseStream.cs @@ -45,11 +45,6 @@ public partial class LambdaResponseStream : Stream, ILambdaResponseStream /// public long BytesWritten => _bytesWritten; - /// - /// Gets a value indicating whether the operation has completed. - /// - public bool IsCompleted => _isCompleted; - /// /// Gets a value indicating whether an error has occurred. /// @@ -144,10 +139,8 @@ public override async Task WriteAsync(byte[] buffer, int offset, int count, Canc /// This will send error information via HTTP trailing headers. /// /// The exception to report. - /// Optional cancellation token. - /// A task representing the asynchronous operation. /// Thrown if the stream is already completed or an error has already been reported. - public Task ReportErrorAsync(Exception exception, CancellationToken cancellationToken = default) + internal void ReportError(Exception exception) { if (exception == null) throw new ArgumentNullException(nameof(exception)); @@ -161,22 +154,45 @@ public Task ReportErrorAsync(Exception exception, CancellationToken cancellation _hasError = true; _reportedError = exception; - } + + _isCompleted = true; + } // Signal completion so StreamingHttpContent can write error trailers and finish _completionSignal.Release(); - - return Task.CompletedTask; } internal void MarkCompleted() { + bool shouldReleaseLock = false; lock (_lock) { + // Release lock if not already completed, otherwise do nothing (idempotent) + if (!_isCompleted) + { + shouldReleaseLock = true; + } _isCompleted = true; } - // Signal completion so StreamingHttpContent can write the final chunk and finish - _completionSignal.Release(); + + if (shouldReleaseLock) + { + // Signal completion so StreamingHttpContent can write the final chunk and finish + _completionSignal.Release(); + } + } + + /// + /// The resouces like the SemaphoreSlims are manually disposed by LambdaBootstrap after each invocation instead of relying on the + /// Dipose pattern because we don't want the user's Lambda function to trigger Releasing and disposing the semaphores when + /// invocation of the user's code ends. + /// + internal void ManualDispose() + { + try { _httpStreamReady.Release(); } catch (SemaphoreFullException) { /* Ignore if already released */ } + _httpStreamReady.Dispose(); + try { _completionSignal.Release(); } catch (SemaphoreFullException) { /* Ignore if already released */ } + _completionSignal.Dispose(); } private void ThrowIfCompletedOrError() @@ -186,18 +202,5 @@ private void ThrowIfCompletedOrError() if (_hasError) throw new InvalidOperationException("Cannot write to a stream after an error has been reported."); } - - // ── Dispose ────────────────────────────────────────────────────────── - - /// - protected override void Dispose(bool disposing) - { - if (disposing) - { - try { _completionSignal.Release(); } catch (SemaphoreFullException) { } - } - - base.Dispose(disposing); - } } } diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs index f594d5e56..e142b3719 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs @@ -214,8 +214,6 @@ internal virtual async Task StartStreamingResponseAsync( request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); response.EnsureSuccessStatusCode(); } - - responseStream.MarkCompleted(); } /// diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingHttpContent.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingHttpContent.cs index c642873aa..d29e56470 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingHttpContent.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingHttpContent.cs @@ -19,6 +19,7 @@ using System.Net.Http; using System.Text; using System.Threading.Tasks; +using Amazon.Lambda.RuntimeSupport.Helpers; namespace Amazon.Lambda.RuntimeSupport { @@ -43,6 +44,7 @@ protected override async Task SerializeToStreamAsync(Stream stream, TransportCon // can write chunks directly to it. _responseStream.SetHttpOutputStream(stream); + InternalLogger.GetDefaultLogger().LogInformation("In SerializeToStreamAsync waiting for the undlying Lambda response stream in indicate it is complete."); // Wait for the handler to finish writing (MarkCompleted or ReportErrorAsync) await _responseStream.WaitForCompletionAsync(); @@ -52,6 +54,7 @@ protected override async Task SerializeToStreamAsync(Stream stream, TransportCon // Write error trailers if present if (_responseStream.HasError) { + InternalLogger.GetDefaultLogger().LogError(_responseStream.ReportedError, "An error occurred during Lambda execution. Writing error trailers to response."); await WriteErrorTrailersAsync(stream, _responseStream.ReportedError); } diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs index 735fba482..e9e2690d4 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs @@ -45,7 +45,6 @@ public void Constructor_InitializesStateCorrectly() var stream = new LambdaResponseStream(); Assert.Equal(0, stream.BytesWritten); - Assert.False(stream.IsCompleted); Assert.False(stream.HasError); Assert.Null(stream.ReportedError); } @@ -192,7 +191,6 @@ public async Task MarkCompleted_ReleasesCompletionSignal() // Should complete within a reasonable time var completed = await Task.WhenAny(waitTask, Task.Delay(TimeSpan.FromSeconds(2))); Assert.Same(waitTask, completed); - Assert.True(stream.IsCompleted); } // ---- Completion signaling: ReportErrorAsync releases _completionSignal ---- @@ -209,54 +207,13 @@ public async Task ReportErrorAsync_ReleasesCompletionSignal() var waitTask = stream.WaitForCompletionAsync(); Assert.False(waitTask.IsCompleted, "WaitForCompletionAsync should block before ReportErrorAsync"); - await stream.ReportErrorAsync(new Exception("test error")); + stream.ReportError(new Exception("test error")); var completed = await Task.WhenAny(waitTask, Task.Delay(TimeSpan.FromSeconds(2))); Assert.Same(waitTask, completed); Assert.True(stream.HasError); } - // ---- Property 6: Size Limit Enforcement ---- - - /// - /// Property 6: Size Limit Enforcement — single write exceeding limit throws. - /// Validates: Requirements 3.6, 3.7 - /// - [Theory] - [InlineData(21 * 1024 * 1024)] - public async Task SizeLimit_SingleWriteExceedingLimit_Throws(int writeSize) - { - var (stream, _) = CreateWiredStream(); - var data = new byte[writeSize]; - - await Assert.ThrowsAsync(() => stream.WriteAsync(data)); - } - - /// - /// Property 6: Size Limit Enforcement — multiple writes exceeding limit throws. - /// Validates: Requirements 3.6, 3.7 - /// - [Fact] - public async Task SizeLimit_MultipleWritesExceedingLimit_Throws() - { - var (stream, _) = CreateWiredStream(); - - await stream.WriteAsync(new byte[10 * 1024 * 1024]); - await Assert.ThrowsAsync( - () => stream.WriteAsync(new byte[11 * 1024 * 1024])); - } - - [Fact] - public async Task SizeLimit_ExactlyAtLimit_Succeeds() - { - var (stream, _) = CreateWiredStream(); - var data = new byte[20 * 1024 * 1024]; - - await stream.WriteAsync(data); - - Assert.Equal(data.Length, stream.BytesWritten); - } - // ---- Property 19: Writes After Completion Rejected ---- /// @@ -283,7 +240,7 @@ public async Task WriteAsync_AfterReportError_Throws() { var (stream, _) = CreateWiredStream(); await stream.WriteAsync(new byte[] { 1 }); - await stream.ReportErrorAsync(new Exception("test")); + stream.ReportError(new Exception("test")); await Assert.ThrowsAsync( () => stream.WriteAsync(new byte[] { 2 })); @@ -297,7 +254,7 @@ public async Task ReportErrorAsync_SetsErrorState() var stream = new LambdaResponseStream(); var exception = new InvalidOperationException("something broke"); - await stream.ReportErrorAsync(exception); + stream.ReportError(exception); Assert.True(stream.HasError); Assert.Same(exception, stream.ReportedError); @@ -309,28 +266,18 @@ public async Task ReportErrorAsync_AfterCompleted_Throws() var stream = new LambdaResponseStream(); stream.MarkCompleted(); - await Assert.ThrowsAsync( - () => stream.ReportErrorAsync(new Exception("test"))); + Assert.Throws( + () => stream.ReportError(new Exception("test"))); } [Fact] public async Task ReportErrorAsync_CalledTwice_Throws() { var stream = new LambdaResponseStream(); - await stream.ReportErrorAsync(new Exception("first")); - - await Assert.ThrowsAsync( - () => stream.ReportErrorAsync(new Exception("second"))); - } - - [Fact] - public void MarkCompleted_SetsCompletionState() - { - var stream = new LambdaResponseStream(); - - stream.MarkCompleted(); + stream.ReportError(new Exception("first")); - Assert.True(stream.IsCompleted); + Assert.Throws( + () => stream.ReportError(new Exception("second"))); } // ---- Argument validation ---- @@ -356,7 +303,7 @@ public async Task ReportErrorAsync_NullException_ThrowsArgumentNull() { var stream = new LambdaResponseStream(); - await Assert.ThrowsAsync(() => stream.ReportErrorAsync(null)); + Assert.Throws(() => stream.ReportError(null)); } // ---- Dispose signals completion ---- @@ -369,7 +316,7 @@ public async Task Dispose_ReleasesCompletionSignalIfNotAlreadyReleased() var waitTask = stream.WaitForCompletionAsync(); Assert.False(waitTask.IsCompleted); - stream.Dispose(); + stream.ManualDispose(); var completed = await Task.WhenAny(waitTask, Task.Delay(TimeSpan.FromSeconds(2))); Assert.Same(waitTask, completed); diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/RuntimeApiClientTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/RuntimeApiClientTests.cs index fbc4a8ae6..3a471ab1e 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/RuntimeApiClientTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/RuntimeApiClientTests.cs @@ -133,25 +133,6 @@ public async Task StartStreamingResponseAsync_DeclaresTrailerHeaderUpfront() Assert.Contains(StreamingConstants.ErrorBodyTrailer, trailerValue); } - // --- Property 18: Stream Finalization --- - - /// - /// Property 18: Stream Finalization - /// For any streaming response that completes successfully, the ResponseStream - /// should be marked as completed (IsCompleted = true) after the HTTP response succeeds. - /// **Validates: Requirements 8.3** - /// - [Fact] - public async Task StartStreamingResponseAsync_MarksStreamCompletedAfterSuccess() - { - var stream = new LambdaResponseStream(); - var client = CreateClientWithMockHandler(stream, out _); - - await client.StartStreamingResponseAsync("req-4", stream, CancellationToken.None); - - Assert.True(stream.IsCompleted); - } - // --- Property 10: Buffered Responses Exclude Streaming Headers --- /// diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingIntegrationTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingE2EWithMoq.cs similarity index 95% rename from Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingIntegrationTests.cs rename to Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingE2EWithMoq.cs index 0f15680f4..377aede2d 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingIntegrationTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingE2EWithMoq.cs @@ -34,7 +34,7 @@ public class ResponseStreamFactoryCollection { } /// ResponseStream → StreamingHttpContent → captured HTTP output stream. /// [Collection("ResponseStreamFactory")] - public class StreamingIntegrationTests : IDisposable + public class StreamingE2EWithMoq : IDisposable { public void Dispose() { @@ -238,7 +238,6 @@ public async Task Streaming_StreamFinalized_BytesWrittenMatchesPayload() Assert.NotNull(client.LastResponseStream); Assert.Equal(data.Length, client.LastResponseStream.BytesWritten); - Assert.True(client.LastResponseStream.IsCompleted); } // ─── 10.2 End-to-end buffered response ────────────────────────────────────── @@ -344,31 +343,6 @@ public async Task MidstreamError_ErrorTrailersIncludedAfterFinalChunk() Assert.False(client.ReportInvocationErrorCalled); } - /// - /// End-to-end: handler throws before writing any data — standard error reporting is used. - /// Requirements: 5.6, 5.7 - /// - [Fact] - public async Task PreStreamError_ExceptionBeforeAnyWrite_UsesStandardErrorReporting() - { - var client = CreateClient(); - - LambdaBootstrapHandler handler = async (invocation) => - { - var stream = LambdaResponseStreamFactory.CreateStream(); - // Throw before writing anything - throw new ArgumentException("pre-write failure"); - }; - - using var bootstrap = new LambdaBootstrap(handler, null); - bootstrap.Client = client; - await bootstrap.InvokeOnceAsync(); - - // BytesWritten == 0, so standard error reporting should be used - Assert.True(client.ReportInvocationErrorCalled, - "Standard error reporting should be used when no bytes were written"); - } - /// /// End-to-end: error body trailer contains JSON with exception details. /// Requirements: 5.2, 5.3 diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingHttpContentTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingHttpContentTests.cs index 1f85f47a8..4fed4b810 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingHttpContentTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingHttpContentTests.cs @@ -117,7 +117,7 @@ public async Task SerializeToStreamAsync_BlocksUntilReportErrorAsync() Assert.False(serializeTask.IsCompleted, "SerializeToStreamAsync should block until error is reported"); - await rs.ReportErrorAsync(new Exception("test error")); + rs.ReportError(new Exception("test error")); await serializeTask; Assert.True(serializeTask.IsCompleted); @@ -182,7 +182,7 @@ public async Task ErrorTrailers_AppearAfterFinalChunk() var output = await SerializeWithConcurrentHandler(rs, async stream => { await stream.WriteAsync(new byte[] { 1 }); - await stream.ReportErrorAsync(new Exception("fail")); + stream.ReportError(new Exception("fail")); }); var outputStr = Encoding.UTF8.GetString(output); @@ -216,7 +216,7 @@ public async Task ErrorTrailer_IncludesErrorType(Type exceptionType) { await stream.WriteAsync(new byte[] { 1 }); var exception = (Exception)Activator.CreateInstance(exceptionType, "test error"); - await stream.ReportErrorAsync(exception); + stream.ReportError(exception); }); var outputStr = Encoding.UTF8.GetString(output); @@ -237,7 +237,7 @@ public async Task ErrorTrailer_IncludesJsonErrorBody() var output = await SerializeWithConcurrentHandler(rs, async stream => { await stream.WriteAsync(new byte[] { 1 }); - await stream.ReportErrorAsync(new InvalidOperationException("something went wrong")); + stream.ReportError(new InvalidOperationException("something went wrong")); }); var outputStr = Encoding.UTF8.GetString(output); @@ -280,7 +280,7 @@ public async Task ErrorCompletion_EndsWithCrlf() var output = await SerializeWithConcurrentHandler(rs, async stream => { await stream.WriteAsync(new byte[] { 1 }); - await stream.ReportErrorAsync(new Exception("fail")); + stream.ReportError(new Exception("fail")); }); var outputStr = Encoding.UTF8.GetString(output); From 645771ea0d90f4eea8b03825effb81bb12039ef2 Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Thu, 5 Mar 2026 11:06:34 -0800 Subject: [PATCH 12/20] Start working on supporting having a prelude chuck in the response stream --- ...bdaResponseStream.ILambdaResponseStream.cs | 56 ++++++++++++++++++- .../Client/RuntimeApiClient.cs | 2 +- .../Client/StreamingHttpContent.cs | 19 ++++--- .../ResponseStreamTests.cs | 26 ++++----- .../StreamingE2EWithMoq.cs | 2 +- .../TestStreamingRuntimeApiClient.cs | 2 +- 6 files changed, 80 insertions(+), 27 deletions(-) diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStream.ILambdaResponseStream.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStream.ILambdaResponseStream.cs index 9a5e6a651..896e00cd8 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStream.ILambdaResponseStream.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStream.ILambdaResponseStream.cs @@ -18,6 +18,7 @@ using System.Text; using System.Threading; using System.Threading.Tasks; +using Amazon.Lambda.RuntimeSupport.Helpers; namespace Amazon.Lambda.RuntimeSupport { @@ -50,29 +51,78 @@ public partial class LambdaResponseStream : Stream, ILambdaResponseStream /// public bool HasError => _hasError; + private readonly byte[] _prelude; + internal Exception ReportedError => _reportedError; internal LambdaResponseStream() + : this(Array.Empty()) { } + internal LambdaResponseStream(byte[] prelude) + { + _prelude = prelude; + } + /// /// Called by StreamingHttpContent.SerializeToStreamAsync to provide the HTTP output stream. /// - internal void SetHttpOutputStream(Stream httpOutputStream) + internal async Task SetHttpOutputStreamAsync(Stream httpOutputStream, CancellationToken cancellationToken = default) { _httpOutputStream = httpOutputStream; _httpStreamReady.Release(); + + InternalLogger.GetDefaultLogger().LogDebug($"Writing prelude of {_prelude.Length} bytes to HTTP stream."); + await WritePreludeAsync(cancellationToken); + } + + private async Task WritePreludeAsync(CancellationToken cancellationToken = default) + { + if (_prelude?.Length > 0) + { + await _httpStreamReady.WaitAsync(cancellationToken); + try + { + lock (_lock) + { + ThrowIfCompletedOrError(); + } + + // Write prelude JSON chunk + var chunkSizeHex = _prelude.Length.ToString("X"); + var chunkSizeBytes = Encoding.ASCII.GetBytes(chunkSizeHex); + await _httpOutputStream.WriteAsync(chunkSizeBytes, 0, chunkSizeBytes.Length, cancellationToken); + await _httpOutputStream.WriteAsync(CrlfBytes, 0, CrlfBytes.Length, cancellationToken); + await _httpOutputStream.WriteAsync(_prelude, 0, _prelude.Length, cancellationToken); + await _httpOutputStream.WriteAsync(CrlfBytes, 0, CrlfBytes.Length, cancellationToken); + + // Write 8 null bytes delimiter chunk + var delimiterBytes = new byte[8]; + chunkSizeHex = "8"; + chunkSizeBytes = Encoding.ASCII.GetBytes(chunkSizeHex); + await _httpOutputStream.WriteAsync(chunkSizeBytes, 0, chunkSizeBytes.Length, cancellationToken); + await _httpOutputStream.WriteAsync(CrlfBytes, 0, CrlfBytes.Length, cancellationToken); + await _httpOutputStream.WriteAsync(delimiterBytes, 0, delimiterBytes.Length, cancellationToken); + await _httpOutputStream.WriteAsync(CrlfBytes, 0, CrlfBytes.Length, cancellationToken); + + await _httpOutputStream.FlushAsync(cancellationToken); + } + finally + { + _httpStreamReady.Release(); + } + } } /// /// Called by StreamingHttpContent.SerializeToStreamAsync to wait until the handler /// finishes writing (MarkCompleted or ReportErrorAsync). /// - internal async Task WaitForCompletionAsync() + internal async Task WaitForCompletionAsync(CancellationToken cancellationToken = default) { - await _completionSignal.WaitAsync(); + await _completionSignal.WaitAsync(cancellationToken); } /// diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs index e142b3719..c160fc9f1 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs @@ -206,7 +206,7 @@ internal virtual async Task StartStreamingResponseAsync( request.Headers.Add("Trailer", $"{StreamingConstants.ErrorTypeTrailer}, {StreamingConstants.ErrorBodyTrailer}"); - request.Content = new StreamingHttpContent(responseStream); + request.Content = new StreamingHttpContent(responseStream, cancellationToken); // SendAsync calls SerializeToStreamAsync, which blocks until the handler // finishes writing. This is why this method runs concurrently with the handler. diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingHttpContent.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingHttpContent.cs index d29e56470..25541edb9 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingHttpContent.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingHttpContent.cs @@ -18,6 +18,7 @@ using System.Net; using System.Net.Http; using System.Text; +using System.Threading; using System.Threading.Tasks; using Amazon.Lambda.RuntimeSupport.Helpers; @@ -32,24 +33,26 @@ internal class StreamingHttpContent : HttpContent private static readonly byte[] FinalChunkBytes = Encoding.ASCII.GetBytes("0\r\n"); private readonly LambdaResponseStream _responseStream; + private readonly CancellationToken _cancellationToken; - public StreamingHttpContent(LambdaResponseStream responseStream) + public StreamingHttpContent(LambdaResponseStream responseStream, CancellationToken cancellationToken = default) { _responseStream = responseStream ?? throw new ArgumentNullException(nameof(responseStream)); + _cancellationToken = cancellationToken; } protected override async Task SerializeToStreamAsync(Stream stream, TransportContext context) { // Hand the HTTP output stream to ResponseStream so WriteAsync calls // can write chunks directly to it. - _responseStream.SetHttpOutputStream(stream); + await _responseStream.SetHttpOutputStreamAsync(stream, _cancellationToken); InternalLogger.GetDefaultLogger().LogInformation("In SerializeToStreamAsync waiting for the undlying Lambda response stream in indicate it is complete."); // Wait for the handler to finish writing (MarkCompleted or ReportErrorAsync) - await _responseStream.WaitForCompletionAsync(); + await _responseStream.WaitForCompletionAsync(_cancellationToken); // Write final chunk - await stream.WriteAsync(FinalChunkBytes, 0, FinalChunkBytes.Length); + await stream.WriteAsync(FinalChunkBytes, 0, FinalChunkBytes.Length, _cancellationToken); // Write error trailers if present if (_responseStream.HasError) @@ -59,8 +62,8 @@ protected override async Task SerializeToStreamAsync(Stream stream, TransportCon } // Write final CRLF to end the chunked message - await stream.WriteAsync(CrlfBytes, 0, CrlfBytes.Length); - await stream.FlushAsync(); + await stream.WriteAsync(CrlfBytes, 0, CrlfBytes.Length, _cancellationToken); + await stream.FlushAsync(_cancellationToken); } protected override bool TryComputeLength(out long length) @@ -75,12 +78,12 @@ private async Task WriteErrorTrailersAsync(Stream stream, Exception exception) var errorTypeHeader = $"{StreamingConstants.ErrorTypeTrailer}: {exceptionInfo.ErrorType}\r\n"; var errorTypeBytes = Encoding.UTF8.GetBytes(errorTypeHeader); - await stream.WriteAsync(errorTypeBytes, 0, errorTypeBytes.Length); + await stream.WriteAsync(errorTypeBytes, 0, errorTypeBytes.Length, _cancellationToken); var errorBodyJson = LambdaJsonExceptionWriter.WriteJson(exceptionInfo); var errorBodyHeader = $"{StreamingConstants.ErrorBodyTrailer}: {errorBodyJson}\r\n"; var errorBodyBytes = Encoding.UTF8.GetBytes(errorBodyHeader); - await stream.WriteAsync(errorBodyBytes, 0, errorBodyBytes.Length); + await stream.WriteAsync(errorBodyBytes, 0, errorBodyBytes.Length, _cancellationToken); } } } diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs index e9e2690d4..5937e1cf9 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs @@ -29,11 +29,11 @@ public class ResponseStreamTests /// Helper: creates a ResponseStream and wires up a MemoryStream as the HTTP output stream. /// Returns both so tests can inspect what was written. /// - private static (LambdaResponseStream stream, MemoryStream httpOutput) CreateWiredStream() + private static async Task<(LambdaResponseStream stream, MemoryStream httpOutput)> CreateWiredStreamAsync() { var rs = new LambdaResponseStream(); var output = new MemoryStream(); - rs.SetHttpOutputStream(output); + await rs.SetHttpOutputStreamAsync(output); return (rs, output); } @@ -62,7 +62,7 @@ public void Constructor_InitializesStateCorrectly() [InlineData(new byte[0], "0")] // 0 bytes → "0" public async Task WriteAsync_WritesChunkedEncodingFormat(byte[] data, string expectedHexSize) { - var (stream, httpOutput) = CreateWiredStream(); + var (stream, httpOutput) = await CreateWiredStreamAsync(); await stream.WriteAsync(data); @@ -82,7 +82,7 @@ public async Task WriteAsync_WritesChunkedEncodingFormat(byte[] data, string exp [Fact] public async Task WriteAsync_WithOffset_WritesCorrectSliceAsChunk() { - var (stream, httpOutput) = CreateWiredStream(); + var (stream, httpOutput) = await CreateWiredStreamAsync(); var data = new byte[] { 0, 1, 2, 3, 0 }; await stream.WriteAsync(data, 1, 3); @@ -107,7 +107,7 @@ public async Task WriteAsync_WithOffset_WritesCorrectSliceAsChunk() [Fact] public async Task WriteAsync_MultipleWrites_EachAppearsImmediately() { - var (stream, httpOutput) = CreateWiredStream(); + var (stream, httpOutput) = await CreateWiredStreamAsync(); await stream.WriteAsync(new byte[] { 0xAA }); var afterFirst = httpOutput.ToArray().Length; @@ -128,7 +128,7 @@ public async Task WriteAsync_MultipleWrites_EachAppearsImmediately() [Fact] public async Task WriteAsync_LargerPayload_HexSizeIsCorrect() { - var (stream, httpOutput) = CreateWiredStream(); + var (stream, httpOutput) = await CreateWiredStreamAsync(); var data = new byte[256]; // 0x100 await stream.WriteAsync(data); @@ -165,7 +165,7 @@ public async Task WriteAsync_BlocksUntilSetHttpOutputStream() Assert.False(writeCompleted.IsSet, "WriteAsync should block until SetHttpOutputStream is called"); // Now provide the HTTP stream — the write should complete - rs.SetHttpOutputStream(httpOutput); + await rs.SetHttpOutputStreamAsync(httpOutput); await writeTask; Assert.True(writeCompleted.IsSet); @@ -181,7 +181,7 @@ public async Task WriteAsync_BlocksUntilSetHttpOutputStream() [Fact] public async Task MarkCompleted_ReleasesCompletionSignal() { - var (stream, _) = CreateWiredStream(); + var (stream, _) = await CreateWiredStreamAsync(); var waitTask = stream.WaitForCompletionAsync(); Assert.False(waitTask.IsCompleted, "WaitForCompletionAsync should block before MarkCompleted"); @@ -202,7 +202,7 @@ public async Task MarkCompleted_ReleasesCompletionSignal() [Fact] public async Task ReportErrorAsync_ReleasesCompletionSignal() { - var (stream, _) = CreateWiredStream(); + var (stream, _) = await CreateWiredStreamAsync(); var waitTask = stream.WaitForCompletionAsync(); Assert.False(waitTask.IsCompleted, "WaitForCompletionAsync should block before ReportErrorAsync"); @@ -223,7 +223,7 @@ public async Task ReportErrorAsync_ReleasesCompletionSignal() [Fact] public async Task WriteAsync_AfterMarkCompleted_Throws() { - var (stream, _) = CreateWiredStream(); + var (stream, _) = await CreateWiredStreamAsync(); await stream.WriteAsync(new byte[] { 1 }); stream.MarkCompleted(); @@ -238,7 +238,7 @@ await Assert.ThrowsAsync( [Fact] public async Task WriteAsync_AfterReportError_Throws() { - var (stream, _) = CreateWiredStream(); + var (stream, _) = await CreateWiredStreamAsync(); await stream.WriteAsync(new byte[] { 1 }); stream.ReportError(new Exception("test")); @@ -285,7 +285,7 @@ public async Task ReportErrorAsync_CalledTwice_Throws() [Fact] public async Task WriteAsync_NullBuffer_ThrowsArgumentNull() { - var (stream, _) = CreateWiredStream(); + var (stream, _) = await CreateWiredStreamAsync(); await Assert.ThrowsAsync(() => stream.WriteAsync((byte[])null)); } @@ -293,7 +293,7 @@ public async Task WriteAsync_NullBuffer_ThrowsArgumentNull() [Fact] public async Task WriteAsync_NullBufferWithOffset_ThrowsArgumentNull() { - var (stream, _) = CreateWiredStream(); + var (stream, _) = await CreateWiredStreamAsync(); await Assert.ThrowsAsync(() => stream.WriteAsync(null, 0, 0)); } diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingE2EWithMoq.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingE2EWithMoq.cs index 377aede2d..6f6b9aab8 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingE2EWithMoq.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingE2EWithMoq.cs @@ -514,7 +514,7 @@ internal override async Task StartStreamingResponseAsync( string awsRequestId, LambdaResponseStream responseStream, CancellationToken cancellationToken = default) { // Provide the HTTP output stream so writes don't block - responseStream.SetHttpOutputStream(new MemoryStream()); + await responseStream.SetHttpOutputStreamAsync(new MemoryStream()); await responseStream.WaitForCompletionAsync(); } } diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/TestHelpers/TestStreamingRuntimeApiClient.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/TestHelpers/TestStreamingRuntimeApiClient.cs index da68d2940..4d5166fb5 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/TestHelpers/TestStreamingRuntimeApiClient.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/TestHelpers/TestStreamingRuntimeApiClient.cs @@ -114,7 +114,7 @@ internal override async Task StartStreamingResponseAsync( LastStreamingResponseStream = responseStream; // Simulate the HTTP stream being available - responseStream.SetHttpOutputStream(new MemoryStream()); + await responseStream.SetHttpOutputStreamAsync(new MemoryStream(), cancellationToken); // Wait for the handler to finish writing (mirrors real SerializeToStreamAsync behavior) await responseStream.WaitForCompletionAsync(); From 1f17a586fd36e973cd36d6772ce2bb9097812dc7 Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Thu, 5 Mar 2026 14:28:00 -0800 Subject: [PATCH 13/20] Rework to support class library programming model --- .../LambdaResponseStreamFactory.cs | 184 ++++++++++++++++++ .../Bootstrap/LambdaBootstrap.cs | 26 ++- .../ResponseStreaming/ResponseStream.cs} | 70 +++---- .../ResponseStreamContext.cs} | 6 +- .../ResponseStreamFactory.cs} | 20 +- ...onseStreamLambdaCoreInitializerIsolated.cs | 61 ++++++ .../ResponseStreaming}/StreamingConstants.cs | 2 +- .../StreamingHttpContent.cs | 6 +- .../Client/ILambdaResponseStream.cs | 60 ------ .../Client/LambdaResponseStream.Stream.cs | 97 --------- .../Client/RuntimeApiClient.cs | 3 +- .../LambdaBootstrapTests.cs | 12 +- .../ResponseStreamFactoryTests.cs | 73 +++---- .../ResponseStreamTests.cs | 62 +++--- .../RuntimeApiClientTests.cs | 17 +- .../StreamingE2EWithMoq.cs | 43 ++-- .../StreamingHttpContentTests.cs | 32 +-- .../TestStreamingRuntimeApiClient.cs | 5 +- 18 files changed, 445 insertions(+), 334 deletions(-) create mode 100644 Libraries/src/Amazon.Lambda.Core/LambdaResponseStreamFactory.cs rename Libraries/src/Amazon.Lambda.RuntimeSupport/{Client/LambdaResponseStream.ILambdaResponseStream.cs => Bootstrap/ResponseStreaming/ResponseStream.cs} (84%) rename Libraries/src/Amazon.Lambda.RuntimeSupport/{Client/LambdaResponseStreamContext.cs => Bootstrap/ResponseStreaming/ResponseStreamContext.cs} (92%) rename Libraries/src/Amazon.Lambda.RuntimeSupport/{Client/LambdaResponseStreamFactory.cs => Bootstrap/ResponseStreaming/ResponseStreamFactory.cs} (83%) create mode 100644 Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStreamLambdaCoreInitializerIsolated.cs rename Libraries/src/Amazon.Lambda.RuntimeSupport/{Client => Bootstrap/ResponseStreaming}/StreamingConstants.cs (95%) rename Libraries/src/Amazon.Lambda.RuntimeSupport/{Client => Bootstrap/ResponseStreaming}/StreamingHttpContent.cs (94%) delete mode 100644 Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ILambdaResponseStream.cs delete mode 100644 Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStream.Stream.cs diff --git a/Libraries/src/Amazon.Lambda.Core/LambdaResponseStreamFactory.cs b/Libraries/src/Amazon.Lambda.Core/LambdaResponseStreamFactory.cs new file mode 100644 index 000000000..46ff77d18 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Core/LambdaResponseStreamFactory.cs @@ -0,0 +1,184 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +#if NET8_0_OR_GREATER +using System; +using System.IO; +using System.Runtime.Versioning; +using System.Threading; +using System.Threading.Tasks; + +namespace Amazon.Lambda.Core +{ + /// + /// Factory to create Lambda response streams for writing streaming responses in AWS Lambda functions. The created streams are write-only and non-seekable. + /// + [RequiresPreviewFeatures(LambdaResponseStreamFactory.ParameterizedPreviewMessage)] + public class LambdaResponseStreamFactory + { + internal const string ParameterizedPreviewMessage = + "Response streaming is in preview till a new version of .NET Lambda runtime client that supports response streaming " + + "has been deployed to the .NET Lambda managed runtime. Till deployment has been made the feature can be used by deploying as an " + + "executable including the latest version of Amazon.Lambda.RuntimeSupport and setting the \"EnablePreviewFeatures\" in the Lambda " + + "project file to \"true\""; + + private static Func _streamFactory; + + internal static void SetLambdaResponseStream(Func streamFactory) + { + _streamFactory = streamFactory ?? throw new ArgumentNullException(nameof(streamFactory)); + } + + /// + /// Creates a that can be used to write streaming responses back to callers of the Lambda function. Once + /// A Lambda function creates a response stream all output must be returned by writing to the stream; the Lambda function's handler + /// return value will be ignored. The stream is write-only and non-seekable. + /// + /// + public static Stream CreateStream() + { + var runtimeResponseStream = _streamFactory(Array.Empty()); + return new LambdaResponseStream(runtimeResponseStream); + } + } + + /// + /// Interface for writing streaming responses in AWS Lambda functions. + /// Obtained by calling within a handler. + /// + internal interface ILambdaResponseStream : IDisposable + { + /// + /// Asynchronously writes a portion of a byte array to the response stream. + /// + /// The byte array containing data to write. + /// The zero-based byte offset in buffer at which to begin copying bytes. + /// The number of bytes to write. + /// Optional cancellation token. + /// A task representing the asynchronous operation. + /// Thrown if the stream is already completed or an error has been reported. + Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken = default); + + + /// + /// Gets the total number of bytes written to the stream so far. + /// + long BytesWritten { get; } + + + /// + /// Gets whether an error has been reported. + /// + bool HasError { get; } + } + + /// + /// A write-only, non-seekable subclass that streams response data + /// to the Lambda Runtime API. Returned by . + /// Integrates with standard .NET stream consumers such as . + /// + [RequiresPreviewFeatures(LambdaResponseStreamFactory.ParameterizedPreviewMessage)] + public class LambdaResponseStream : Stream + { + private readonly ILambdaResponseStream _responseStream; + + internal LambdaResponseStream(ILambdaResponseStream responseStream) + { + _responseStream = responseStream; + } + + /// + /// The number of bytes written to the Lambda response stream so far. + /// + public long BytesWritten => _responseStream.BytesWritten; + + /// + /// Asynchronously writes a byte array to the response stream. + /// + /// The byte array to write. + /// Optional cancellation token. + /// A task representing the asynchronous operation. + /// Thrown if the stream is already completed or an error has been reported. + public async Task WriteAsync(byte[] buffer, CancellationToken cancellationToken = default) + { + if (buffer == null) + throw new ArgumentNullException(nameof(buffer)); + + await WriteAsync(buffer, 0, buffer.Length, cancellationToken); + } + + /// + /// Asynchronously writes a portion of a byte array to the response stream. + /// + /// The byte array containing data to write. + /// The zero-based byte offset in buffer at which to begin copying bytes. + /// The number of bytes to write. + /// Optional cancellation token. + /// A task representing the asynchronous operation. + /// Thrown if the stream is already completed or an error has been reported. + public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken = default) + { + await _responseStream.WriteAsync(buffer, offset, count, cancellationToken); + } + + #region Noop Overrides + + /// Gets a value indicating whether the stream supports reading. Always false. + public override bool CanRead => false; + + /// Gets a value indicating whether the stream supports seeking. Always false. + public override bool CanSeek => false; + + /// Gets a value indicating whether the stream supports writing. Always true. + public override bool CanWrite => true; + + /// + /// Gets the total number of bytes written to the stream so far. + /// Equivalent to . + /// + public override long Length => BytesWritten; + + /// + /// Getting or setting the position is not supported. + /// + /// Always thrown. + public override long Position + { + get => throw new NotSupportedException("LambdaResponseStream does not support seeking."); + set => throw new NotSupportedException("LambdaResponseStream does not support seeking."); + } + + /// Not supported. + /// Always thrown. + public override long Seek(long offset, SeekOrigin origin) + => throw new NotImplementedException("LambdaResponseStream does not support seeking."); + + /// Not supported. + /// Always thrown. + public override int Read(byte[] buffer, int offset, int count) + => throw new NotImplementedException("LambdaResponseStream does not support reading."); + + /// Not supported. + /// Always thrown. + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => throw new NotImplementedException("LambdaResponseStream does not support reading."); + + /// + /// Writes a sequence of bytes to the stream. Delegates to the async path synchronously. + /// Prefer to avoid blocking. + /// + public override void Write(byte[] buffer, int offset, int count) + => WriteAsync(buffer, offset, count, CancellationToken.None).GetAwaiter().GetResult(); + + /// + /// Flush is a no-op; data is sent to the Runtime API immediately on each write. + /// + public override void Flush() { } + + /// Not supported. + /// Always thrown. + public override void SetLength(long value) + => throw new NotSupportedException("LambdaResponseStream does not support SetLength."); + #endregion + } +} +#endif diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/LambdaBootstrap.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/LambdaBootstrap.cs index ba44c05ed..a804b0b10 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/LambdaBootstrap.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/LambdaBootstrap.cs @@ -20,6 +20,7 @@ using System.Threading; using System.Threading.Tasks; using Amazon.Lambda.RuntimeSupport.Bootstrap; +using Amazon.Lambda.RuntimeSupport.Client.ResponseStreaming; using Amazon.Lambda.RuntimeSupport.Helpers; namespace Amazon.Lambda.RuntimeSupport @@ -225,6 +226,19 @@ internal LambdaBootstrap(HttpClient httpClient, LambdaBootstrapHandler handler, return; } #if NET8_0_OR_GREATER + + try + { + // Initalize in Amazon.Lambda.Core the factory for creating the response stream and related logic for supporting response streaming. + ResponseStreamLambdaCoreInitializerIsolated.InitializeCore(); + } + catch (TypeLoadException) + { + _logger.LogDebug("Failed to configure Amazon.Lambda.Core with factory to create response stream. This happens when the version of Amazon.Lambda.Core referenced by the Lambda function is out of date."); + } + + + // Check if Initialization type is SnapStart, and invoke the snapshot restore logic. if (_configuration.IsInitTypeSnapstart) { @@ -363,7 +377,7 @@ internal async Task InvokeOnceAsync(CancellationToken cancellationToken = defaul var runtimeApiClient = Client as RuntimeApiClient; if (runtimeApiClient != null) { - LambdaResponseStreamFactory.InitializeInvocation( + ResponseStreamFactory.InitializeInvocation( invocation.LambdaContext.AwsRequestId, isMultiConcurrency, runtimeApiClient, @@ -385,7 +399,7 @@ internal async Task InvokeOnceAsync(CancellationToken cancellationToken = defaul { WriteUnhandledExceptionToLog(exception); - var responseStream = LambdaResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency); + var responseStream = ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency); if (responseStream != null) { responseStream.ReportError(exception); @@ -400,20 +414,20 @@ internal async Task InvokeOnceAsync(CancellationToken cancellationToken = defaul _logger.LogInformation("Finished invoking handler"); } - var streamIfCreated = LambdaResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency); + var streamIfCreated = ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency); if (streamIfCreated != null) { streamIfCreated.MarkCompleted(); // If streaming was started, await the HTTP send task to ensure it completes - var sendTask = LambdaResponseStreamFactory.GetSendTask(isMultiConcurrency); + var sendTask = ResponseStreamFactory.GetSendTask(isMultiConcurrency); if (sendTask != null) { // Wait for the streaming response to finish sending before allowing the next invocation to be processed. This ensures that responses are sent in the order the invocations were received. await sendTask; } - streamIfCreated.ManualDispose(); + streamIfCreated.Dispose(); } else if (invokeSucceeded) { @@ -454,7 +468,7 @@ internal async Task InvokeOnceAsync(CancellationToken cancellationToken = defaul { if (runtimeApiClient != null) { - LambdaResponseStreamFactory.CleanupInvocation(isMultiConcurrency); + ResponseStreamFactory.CleanupInvocation(isMultiConcurrency); } invocation.Dispose(); } diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStream.ILambdaResponseStream.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStream.cs similarity index 84% rename from Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStream.ILambdaResponseStream.cs rename to Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStream.cs index 896e00cd8..37db44c76 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStream.ILambdaResponseStream.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStream.cs @@ -20,13 +20,12 @@ using System.Threading.Tasks; using Amazon.Lambda.RuntimeSupport.Helpers; -namespace Amazon.Lambda.RuntimeSupport +namespace Amazon.Lambda.RuntimeSupport.Client.ResponseStreaming { /// - /// A write-only, non-seekable subclass that streams response data - /// to the Lambda Runtime API. Returned by . + /// Represents the writable stream used by Lambda handlers to write response data for streaming invocations. /// - public partial class LambdaResponseStream : Stream, ILambdaResponseStream + internal class ResponseStream { private static readonly byte[] CrlfBytes = Encoding.ASCII.GetBytes("\r\n"); @@ -38,6 +37,7 @@ public partial class LambdaResponseStream : Stream, ILambdaResponseStream // The live HTTP output stream, set by StreamingHttpContent when SerializeToStreamAsync is called. private Stream _httpOutputStream; + private bool _disposedValue; private readonly SemaphoreSlim _httpStreamReady = new SemaphoreSlim(0, 1); private readonly SemaphoreSlim _completionSignal = new SemaphoreSlim(0, 1); @@ -56,12 +56,7 @@ public partial class LambdaResponseStream : Stream, ILambdaResponseStream internal Exception ReportedError => _reportedError; - internal LambdaResponseStream() - : this(Array.Empty()) - { - } - - internal LambdaResponseStream(byte[] prelude) + internal ResponseStream(byte[] prelude) { _prelude = prelude; } @@ -125,18 +120,10 @@ internal async Task WaitForCompletionAsync(CancellationToken cancellationToken = await _completionSignal.WaitAsync(cancellationToken); } - /// - /// Asynchronously writes a byte array to the response stream. - /// - /// The byte array to write. - /// Optional cancellation token. - /// A task representing the asynchronous operation. - /// Thrown if the stream is already completed or an error has been reported. - public async Task WriteAsync(byte[] buffer, CancellationToken cancellationToken = default) + internal async Task WriteAsync(byte[] buffer, CancellationToken cancellationToken = default) { if (buffer == null) throw new ArgumentNullException(nameof(buffer)); - await WriteAsync(buffer, 0, buffer.Length, cancellationToken); } @@ -149,7 +136,7 @@ public async Task WriteAsync(byte[] buffer, CancellationToken cancellationToken /// Optional cancellation token. /// A task representing the asynchronous operation. /// Thrown if the stream is already completed or an error has been reported. - public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken = default) + public async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken = default) { if (buffer == null) throw new ArgumentNullException(nameof(buffer)); @@ -232,19 +219,6 @@ internal void MarkCompleted() } } - /// - /// The resouces like the SemaphoreSlims are manually disposed by LambdaBootstrap after each invocation instead of relying on the - /// Dipose pattern because we don't want the user's Lambda function to trigger Releasing and disposing the semaphores when - /// invocation of the user's code ends. - /// - internal void ManualDispose() - { - try { _httpStreamReady.Release(); } catch (SemaphoreFullException) { /* Ignore if already released */ } - _httpStreamReady.Dispose(); - try { _completionSignal.Release(); } catch (SemaphoreFullException) { /* Ignore if already released */ } - _completionSignal.Dispose(); - } - private void ThrowIfCompletedOrError() { if (_isCompleted) @@ -252,5 +226,35 @@ private void ThrowIfCompletedOrError() if (_hasError) throw new InvalidOperationException("Cannot write to a stream after an error has been reported."); } + + /// + /// Disposes the stream. After calling Dispose, no further writes or error reports should be made. + /// + /// + protected virtual void Dispose(bool disposing) + { + if (!_disposedValue) + { + if (disposing) + { + try { _httpStreamReady.Release(); } catch (SemaphoreFullException) { /* Ignore if already released */ } + _httpStreamReady.Dispose(); + try { _completionSignal.Release(); } catch (SemaphoreFullException) { /* Ignore if already released */ } + _completionSignal.Dispose(); + } + + _disposedValue = true; + } + } + + /// + /// Dispose of the stream. After calling Dispose, no further writes or error reports should be made. + /// + public void Dispose() + { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + Dispose(disposing: true); + GC.SuppressFinalize(this); + } } } diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStreamContext.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStreamContext.cs similarity index 92% rename from Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStreamContext.cs rename to Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStreamContext.cs index c6a58c81d..3fb92e51d 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStreamContext.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStreamContext.cs @@ -16,12 +16,12 @@ using System.Threading; using System.Threading.Tasks; -namespace Amazon.Lambda.RuntimeSupport +namespace Amazon.Lambda.RuntimeSupport.Client.ResponseStreaming { /// /// Internal context class used by ResponseStreamFactory to track per-invocation streaming state. /// - internal class LambdaResponseStreamContext + internal class ResponseStreamContext { /// /// The AWS request ID for the current invocation. @@ -36,7 +36,7 @@ internal class LambdaResponseStreamContext /// /// The ResponseStream instance if created. /// - public LambdaResponseStream Stream { get; set; } + public ResponseStream Stream { get; set; } /// /// The RuntimeApiClient used to start the streaming HTTP POST. diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStreamFactory.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStreamFactory.cs similarity index 83% rename from Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStreamFactory.cs rename to Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStreamFactory.cs index 84d8c0ebd..dcbdf4c92 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStreamFactory.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStreamFactory.cs @@ -17,31 +17,29 @@ using System.Threading; using System.Threading.Tasks; -namespace Amazon.Lambda.RuntimeSupport +namespace Amazon.Lambda.RuntimeSupport.Client.ResponseStreaming { /// /// Factory for creating streaming responses in AWS Lambda functions. /// Call CreateStream() within your handler to opt into response streaming for that invocation. /// - public static class LambdaResponseStreamFactory + internal static class ResponseStreamFactory { // For on-demand mode (single invocation at a time) - private static LambdaResponseStreamContext _onDemandContext; + private static ResponseStreamContext _onDemandContext; // For multi-concurrency mode (multiple concurrent invocations) - private static readonly AsyncLocal _asyncLocalContext = new AsyncLocal(); + private static readonly AsyncLocal _asyncLocalContext = new AsyncLocal(); /// /// Creates a streaming response for the current invocation. /// Can only be called once per invocation. /// /// - /// A — a subclass — for writing - /// response data. The returned stream also implements . /// /// Thrown if called outside an invocation context. /// Thrown if called more than once per invocation. - public static LambdaResponseStream CreateStream() + public static ResponseStream CreateStream(byte[] prelude) { var context = GetCurrentContext(); @@ -57,7 +55,7 @@ public static LambdaResponseStream CreateStream() "ResponseStreamFactory.CreateStream() can only be called once per invocation."); } - var lambdaStream = new LambdaResponseStream(); + var lambdaStream = new ResponseStream(prelude); context.Stream = lambdaStream; context.StreamCreated = true; @@ -76,7 +74,7 @@ internal static void InitializeInvocation( string awsRequestId, bool isMultiConcurrency, RuntimeApiClient runtimeApiClient, CancellationToken cancellationToken) { - var context = new LambdaResponseStreamContext + var context = new ResponseStreamContext { AwsRequestId = awsRequestId, StreamCreated = false, @@ -95,7 +93,7 @@ internal static void InitializeInvocation( } } - internal static LambdaResponseStream GetStreamIfCreated(bool isMultiConcurrency) + internal static ResponseStream GetStreamIfCreated(bool isMultiConcurrency) { var context = isMultiConcurrency ? _asyncLocalContext.Value : _onDemandContext; return context?.Stream; @@ -123,7 +121,7 @@ internal static void CleanupInvocation(bool isMultiConcurrency) } } - private static LambdaResponseStreamContext GetCurrentContext() + private static ResponseStreamContext GetCurrentContext() { // Check multi-concurrency first (AsyncLocal), then on-demand return _asyncLocalContext.Value ?? _onDemandContext; diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStreamLambdaCoreInitializerIsolated.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStreamLambdaCoreInitializerIsolated.cs new file mode 100644 index 000000000..15791d0b3 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStreamLambdaCoreInitializerIsolated.cs @@ -0,0 +1,61 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +#if NET8_0_OR_GREATER + +using System; +using System.Threading; +using System.Threading.Tasks; +using Amazon.Lambda.Core; +using Amazon.Lambda.RuntimeSupport.Client.ResponseStreaming; +#pragma warning disable CA2252 +namespace Amazon.Lambda.RuntimeSupport +{ + /// + /// This class is used to connect the created by to Amazon.Lambda.Core with it's public interfaces. + /// The deployed Lambda function might be referencing an older version of Amazon.Lambda.Core that does not have the public interfaces for response streaming, + /// so this class is used to avoid a direct dependency on Amazon.Lambda.Core in the rest of the response streaming implementation. + /// + /// Any code referencing this class must wrap the code around a try/catch for to allow for the case where the Lambda function + /// is deployed with an older version of Amazon.Lambda.Core that does not have the response streaming interfaces. + /// + /// + internal class ResponseStreamLambdaCoreInitializerIsolated + { + /// + /// Initalize Amazon.Lambda.Core with a factory method for creating that wraps the internal implementation. + /// + internal static void InitializeCore() + { +#if !ANALYZER_UNIT_TESTS // This precompiler directive is used to avoid the unit tests from needing a dependency on Amazon.Lambda.Core. + Func factory = (byte[] prelude) => new ImplLambdaResponseStream(ResponseStreamFactory.CreateStream(prelude)); + LambdaResponseStreamFactory.SetLambdaResponseStream(factory); +#endif + } + + /// + /// Implements the interface by wrapping a . This is used to connect the internal response streaming implementation to the public interfaces in Amazon.Lambda.Core. + /// + internal class ImplLambdaResponseStream : ILambdaResponseStream + { + private readonly ResponseStream _innerStream; + + internal ImplLambdaResponseStream(ResponseStream innerStream) + { + _innerStream = innerStream; + } + + /// + public long BytesWritten => _innerStream.BytesWritten; + + /// + public bool HasError => _innerStream.HasError; + + /// + public void Dispose() => _innerStream.Dispose(); + + /// + public Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken = default) => _innerStream.WriteAsync(buffer, offset, count); + } + } +} +#endif diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingConstants.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/StreamingConstants.cs similarity index 95% rename from Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingConstants.cs rename to Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/StreamingConstants.cs index c1e99ed17..43ac607b7 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingConstants.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/StreamingConstants.cs @@ -13,7 +13,7 @@ * permissions and limitations under the License. */ -namespace Amazon.Lambda.RuntimeSupport +namespace Amazon.Lambda.RuntimeSupport.Client.ResponseStreaming { /// /// Constants used for Lambda response streaming. diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingHttpContent.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/StreamingHttpContent.cs similarity index 94% rename from Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingHttpContent.cs rename to Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/StreamingHttpContent.cs index 25541edb9..797c92758 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingHttpContent.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/StreamingHttpContent.cs @@ -22,7 +22,7 @@ using System.Threading.Tasks; using Amazon.Lambda.RuntimeSupport.Helpers; -namespace Amazon.Lambda.RuntimeSupport +namespace Amazon.Lambda.RuntimeSupport.Client.ResponseStreaming { /// /// HttpContent implementation for streaming responses with chunked transfer encoding. @@ -32,10 +32,10 @@ internal class StreamingHttpContent : HttpContent private static readonly byte[] CrlfBytes = Encoding.ASCII.GetBytes("\r\n"); private static readonly byte[] FinalChunkBytes = Encoding.ASCII.GetBytes("0\r\n"); - private readonly LambdaResponseStream _responseStream; + private readonly ResponseStream _responseStream; private readonly CancellationToken _cancellationToken; - public StreamingHttpContent(LambdaResponseStream responseStream, CancellationToken cancellationToken = default) + public StreamingHttpContent(ResponseStream responseStream, CancellationToken cancellationToken = default) { _responseStream = responseStream ?? throw new ArgumentNullException(nameof(responseStream)); _cancellationToken = cancellationToken; diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ILambdaResponseStream.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ILambdaResponseStream.cs deleted file mode 100644 index af7c1a59f..000000000 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ILambdaResponseStream.cs +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace Amazon.Lambda.RuntimeSupport -{ - /// - /// Interface for writing streaming responses in AWS Lambda functions. - /// Obtained by calling within a handler. - /// - public interface ILambdaResponseStream : IDisposable - { - /// - /// Asynchronously writes a byte array to the response stream. - /// - /// The byte array to write. - /// Optional cancellation token. - /// A task representing the asynchronous operation. - /// Thrown if the stream is already completed or an error has been reported. - Task WriteAsync(byte[] buffer, CancellationToken cancellationToken = default); - - /// - /// Asynchronously writes a portion of a byte array to the response stream. - /// - /// The byte array containing data to write. - /// The zero-based byte offset in buffer at which to begin copying bytes. - /// The number of bytes to write. - /// Optional cancellation token. - /// A task representing the asynchronous operation. - /// Thrown if the stream is already completed or an error has been reported. - Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken = default); - - - /// - /// Gets the total number of bytes written to the stream so far. - /// - long BytesWritten { get; } - - - /// - /// Gets whether an error has been reported. - /// - bool HasError { get; } - } -} diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStream.Stream.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStream.Stream.cs deleted file mode 100644 index 5453333e7..000000000 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStream.Stream.cs +++ /dev/null @@ -1,97 +0,0 @@ -/* - * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -using System; -using System.IO; -using System.Threading; -using System.Threading.Tasks; - -namespace Amazon.Lambda.RuntimeSupport -{ - /// - /// A write-only, non-seekable subclass that streams response data - /// to the Lambda Runtime API. Returned by . - /// Integrates with standard .NET stream consumers such as . - /// - public partial class LambdaResponseStream : Stream, ILambdaResponseStream - { - // ── System.IO.Stream — capabilities ───────────────────────────────── - - /// Gets a value indicating whether the stream supports reading. Always false. - public override bool CanRead => false; - - /// Gets a value indicating whether the stream supports seeking. Always false. - public override bool CanSeek => false; - - /// Gets a value indicating whether the stream supports writing. Always true. - public override bool CanWrite => true; - - // ── System.IO.Stream — Length / Position ──────────────────────────── - - /// - /// Gets the total number of bytes written to the stream so far. - /// Equivalent to . - /// - public override long Length => BytesWritten; - - /// - /// Getting or setting the position is not supported. - /// - /// Always thrown. - public override long Position - { - get => throw new NotSupportedException("LambdaResponseStream does not support seeking."); - set => throw new NotSupportedException("LambdaResponseStream does not support seeking."); - } - - // ── System.IO.Stream — seek / read (not supported) ────────────────── - - /// Not supported. - /// Always thrown. - public override long Seek(long offset, SeekOrigin origin) - => throw new NotImplementedException("LambdaResponseStream does not support seeking."); - - /// Not supported. - /// Always thrown. - public override int Read(byte[] buffer, int offset, int count) - => throw new NotImplementedException("LambdaResponseStream does not support reading."); - - /// Not supported. - /// Always thrown. - public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) - => throw new NotImplementedException("LambdaResponseStream does not support reading."); - - // ── System.IO.Stream — write ───────────────────────────────────────── - - /// - /// Writes a sequence of bytes to the stream. Delegates to the async path synchronously. - /// Prefer to avoid blocking. - /// - public override void Write(byte[] buffer, int offset, int count) - => WriteAsync(buffer, offset, count, CancellationToken.None).GetAwaiter().GetResult(); - - // ── System.IO.Stream — flush / set length ──────────────────────────── - - /// - /// Flush is a no-op; data is sent to the Runtime API immediately on each write. - /// - public override void Flush() { } - - /// Not supported. - /// Always thrown. - public override void SetLength(long value) - => throw new NotSupportedException("LambdaResponseStream does not support SetLength."); - } -} diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs index c160fc9f1..041097057 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs @@ -20,6 +20,7 @@ using System.Threading; using System.Threading.Tasks; using Amazon.Lambda.RuntimeSupport.Bootstrap; +using Amazon.Lambda.RuntimeSupport.Client.ResponseStreaming; namespace Amazon.Lambda.RuntimeSupport { @@ -189,7 +190,7 @@ public Task ReportRestoreErrorAsync(Exception exception, String errorType = null /// The optional cancellation token to use. /// A Task representing the in-flight HTTP POST. internal virtual async Task StartStreamingResponseAsync( - string awsRequestId, LambdaResponseStream responseStream, CancellationToken cancellationToken = default) + string awsRequestId, ResponseStream responseStream, CancellationToken cancellationToken = default) { if (awsRequestId == null) throw new ArgumentNullException(nameof(awsRequestId)); if (responseStream == null) throw new ArgumentNullException(nameof(responseStream)); diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/LambdaBootstrapTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/LambdaBootstrapTests.cs index ae40b7e2e..e7f36a377 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/LambdaBootstrapTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/LambdaBootstrapTests.cs @@ -18,10 +18,10 @@ using System.Linq; using System.Net.Http; using System.Text; -using System.Threading; using System.Threading.Tasks; using Xunit; +using Amazon.Lambda.RuntimeSupport.Client.ResponseStreaming; using Amazon.Lambda.RuntimeSupport.Bootstrap; using static Amazon.Lambda.RuntimeSupport.Bootstrap.Constants; @@ -314,7 +314,7 @@ public async Task StreamingMode_HandlerCallsCreateStream_SendTaskAwaited() LambdaBootstrapHandler handler = async (invocation) => { - var stream = LambdaResponseStreamFactory.CreateStream(); + var stream = ResponseStreamFactory.CreateStream(Array.Empty()); await stream.WriteAsync(Encoding.UTF8.GetBytes("hello")); return new InvocationResponse(Stream.Null, false); }; @@ -369,7 +369,7 @@ public async Task MidstreamError_ExceptionAfterWrites_ReportsViaTrailers() LambdaBootstrapHandler handler = async (invocation) => { - var stream = LambdaResponseStreamFactory.CreateStream(); + var stream = ResponseStreamFactory.CreateStream(Array.Empty()); await stream.WriteAsync(Encoding.UTF8.GetBytes("partial data")); throw new InvalidOperationException("midstream failure"); }; @@ -425,7 +425,7 @@ public async Task Cleanup_ResponseStreamFactoryStateCleared_AfterInvocation() LambdaBootstrapHandler handler = async (invocation) => { - var stream = LambdaResponseStreamFactory.CreateStream(); + var stream = ResponseStreamFactory.CreateStream(Array.Empty()); await stream.WriteAsync(Encoding.UTF8.GetBytes("data")); return new InvocationResponse(Stream.Null, false); }; @@ -437,8 +437,8 @@ public async Task Cleanup_ResponseStreamFactoryStateCleared_AfterInvocation() } // After invocation, factory state should be cleaned up - Assert.Null(LambdaResponseStreamFactory.GetStreamIfCreated(false)); - Assert.Null(LambdaResponseStreamFactory.GetSendTask(false)); + Assert.Null(ResponseStreamFactory.GetStreamIfCreated(false)); + Assert.Null(ResponseStreamFactory.GetSendTask(false)); } } } diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamFactoryTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamFactoryTests.cs index 9fce99ad5..b7879e6e3 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamFactoryTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamFactoryTests.cs @@ -16,6 +16,7 @@ using System; using System.Threading; using System.Threading.Tasks; +using Amazon.Lambda.RuntimeSupport.Client.ResponseStreaming; using Xunit; namespace Amazon.Lambda.RuntimeSupport.UnitTests @@ -28,8 +29,8 @@ public class ResponseStreamFactoryTests : IDisposable public void Dispose() { // Clean up both modes to avoid test pollution - LambdaResponseStreamFactory.CleanupInvocation(isMultiConcurrency: false); - LambdaResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); + ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: false); + ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); } /// @@ -40,7 +41,7 @@ private class MockStreamingRuntimeApiClient : RuntimeApiClient { public bool StartStreamingCalled { get; private set; } public string LastAwsRequestId { get; private set; } - public LambdaResponseStream LastResponseStream { get; private set; } + public ResponseStream LastResponseStream { get; private set; } public TaskCompletionSource SendTaskCompletion { get; } = new TaskCompletionSource(); public MockStreamingRuntimeApiClient() @@ -49,7 +50,7 @@ public MockStreamingRuntimeApiClient() } internal override async Task StartStreamingResponseAsync( - string awsRequestId, LambdaResponseStream responseStream, CancellationToken cancellationToken = default) + string awsRequestId, ResponseStream responseStream, CancellationToken cancellationToken = default) { StartStreamingCalled = true; LastAwsRequestId = awsRequestId; @@ -60,7 +61,7 @@ internal override async Task StartStreamingResponseAsync( private void InitializeWithMock(string requestId, bool isMultiConcurrency, MockStreamingRuntimeApiClient mockClient) { - LambdaResponseStreamFactory.InitializeInvocation( + ResponseStreamFactory.InitializeInvocation( requestId, isMultiConcurrency, mockClient, CancellationToken.None); } @@ -77,10 +78,10 @@ public void CreateStream_OnDemandMode_ReturnsValidStream() var mock = new MockStreamingRuntimeApiClient(); InitializeWithMock("req-1", isMultiConcurrency: false, mock); - var stream = LambdaResponseStreamFactory.CreateStream(); + var stream = ResponseStreamFactory.CreateStream(Array.Empty()); Assert.NotNull(stream); - Assert.IsAssignableFrom(stream); + Assert.IsAssignableFrom(stream); } /// @@ -93,10 +94,10 @@ public void CreateStream_MultiConcurrencyMode_ReturnsValidStream() var mock = new MockStreamingRuntimeApiClient(); InitializeWithMock("req-2", isMultiConcurrency: true, mock); - var stream = LambdaResponseStreamFactory.CreateStream(); + var stream = ResponseStreamFactory.CreateStream(Array.Empty()); Assert.NotNull(stream); - Assert.IsAssignableFrom(stream); + Assert.IsAssignableFrom(stream); } // --- Property 4: Single Stream Per Invocation --- @@ -110,16 +111,16 @@ public void CreateStream_CalledTwice_ThrowsInvalidOperationException() { var mock = new MockStreamingRuntimeApiClient(); InitializeWithMock("req-3", isMultiConcurrency: false, mock); - LambdaResponseStreamFactory.CreateStream(); + ResponseStreamFactory.CreateStream(Array.Empty()); - Assert.Throws(() => LambdaResponseStreamFactory.CreateStream()); + Assert.Throws(() => ResponseStreamFactory.CreateStream(Array.Empty())); } [Fact] public void CreateStream_OutsideInvocationContext_ThrowsInvalidOperationException() { // No InitializeInvocation called - Assert.Throws(() => LambdaResponseStreamFactory.CreateStream()); + Assert.Throws(() => ResponseStreamFactory.CreateStream(Array.Empty())); } // --- CreateStream starts HTTP POST --- @@ -134,7 +135,7 @@ public void CreateStream_CallsStartStreamingResponseAsync() var mock = new MockStreamingRuntimeApiClient(); InitializeWithMock("req-start", isMultiConcurrency: false, mock); - LambdaResponseStreamFactory.CreateStream(); + ResponseStreamFactory.CreateStream(Array.Empty()); Assert.True(mock.StartStreamingCalled); Assert.Equal("req-start", mock.LastAwsRequestId); @@ -153,9 +154,9 @@ public void GetSendTask_AfterCreateStream_ReturnsNonNullTask() var mock = new MockStreamingRuntimeApiClient(); InitializeWithMock("req-send", isMultiConcurrency: false, mock); - LambdaResponseStreamFactory.CreateStream(); + ResponseStreamFactory.CreateStream(Array.Empty()); - var sendTask = LambdaResponseStreamFactory.GetSendTask(isMultiConcurrency: false); + var sendTask = ResponseStreamFactory.GetSendTask(isMultiConcurrency: false); Assert.NotNull(sendTask); } @@ -165,14 +166,14 @@ public void GetSendTask_BeforeCreateStream_ReturnsNull() var mock = new MockStreamingRuntimeApiClient(); InitializeWithMock("req-nosend", isMultiConcurrency: false, mock); - var sendTask = LambdaResponseStreamFactory.GetSendTask(isMultiConcurrency: false); + var sendTask = ResponseStreamFactory.GetSendTask(isMultiConcurrency: false); Assert.Null(sendTask); } [Fact] public void GetSendTask_NoContext_ReturnsNull() { - Assert.Null(LambdaResponseStreamFactory.GetSendTask(isMultiConcurrency: false)); + Assert.Null(ResponseStreamFactory.GetSendTask(isMultiConcurrency: false)); } // --- Internal methods --- @@ -183,9 +184,9 @@ public void InitializeInvocation_OnDemand_SetsUpContext() var mock = new MockStreamingRuntimeApiClient(); InitializeWithMock("req-4", isMultiConcurrency: false, mock); - Assert.Null(LambdaResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: false)); + Assert.Null(ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: false)); - var stream = LambdaResponseStreamFactory.CreateStream(); + var stream = ResponseStreamFactory.CreateStream(Array.Empty()); Assert.NotNull(stream); } @@ -195,9 +196,9 @@ public void InitializeInvocation_MultiConcurrency_SetsUpContext() var mock = new MockStreamingRuntimeApiClient(); InitializeWithMock("req-5", isMultiConcurrency: true, mock); - Assert.Null(LambdaResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: true)); + Assert.Null(ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: true)); - var stream = LambdaResponseStreamFactory.CreateStream(); + var stream = ResponseStreamFactory.CreateStream(Array.Empty()); Assert.NotNull(stream); } @@ -206,16 +207,16 @@ public void GetStreamIfCreated_AfterCreateStream_ReturnsStream() { var mock = new MockStreamingRuntimeApiClient(); InitializeWithMock("req-6", isMultiConcurrency: false, mock); - LambdaResponseStreamFactory.CreateStream(); + ResponseStreamFactory.CreateStream(Array.Empty()); - var retrieved = LambdaResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: false); + var retrieved = ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: false); Assert.NotNull(retrieved); } [Fact] public void GetStreamIfCreated_NoContext_ReturnsNull() { - Assert.Null(LambdaResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: false)); + Assert.Null(ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: false)); } [Fact] @@ -223,12 +224,12 @@ public void CleanupInvocation_ClearsState() { var mock = new MockStreamingRuntimeApiClient(); InitializeWithMock("req-7", isMultiConcurrency: false, mock); - LambdaResponseStreamFactory.CreateStream(); + ResponseStreamFactory.CreateStream(Array.Empty()); - LambdaResponseStreamFactory.CleanupInvocation(isMultiConcurrency: false); + ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: false); - Assert.Null(LambdaResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: false)); - Assert.Throws(() => LambdaResponseStreamFactory.CreateStream()); + Assert.Null(ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: false)); + Assert.Throws(() => ResponseStreamFactory.CreateStream(Array.Empty())); } // --- Property 16: State Isolation Between Invocations --- @@ -244,17 +245,17 @@ public void StateIsolation_SequentialInvocations_NoLeakage() // First invocation - streaming InitializeWithMock("req-8a", isMultiConcurrency: false, mock); - var stream1 = LambdaResponseStreamFactory.CreateStream(); + var stream1 = ResponseStreamFactory.CreateStream(Array.Empty()); Assert.NotNull(stream1); - LambdaResponseStreamFactory.CleanupInvocation(isMultiConcurrency: false); + ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: false); // Second invocation - should start fresh InitializeWithMock("req-8b", isMultiConcurrency: false, mock); - Assert.Null(LambdaResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: false)); + Assert.Null(ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: false)); - var stream2 = LambdaResponseStreamFactory.CreateStream(); + var stream2 = ResponseStreamFactory.CreateStream(Array.Empty()); Assert.NotNull(stream2); - LambdaResponseStreamFactory.CleanupInvocation(isMultiConcurrency: false); + ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: false); } /// @@ -266,14 +267,14 @@ public async Task StateIsolation_MultiConcurrency_UsesAsyncLocal() { var mock = new MockStreamingRuntimeApiClient(); InitializeWithMock("req-9", isMultiConcurrency: true, mock); - var stream = LambdaResponseStreamFactory.CreateStream(); + var stream = ResponseStreamFactory.CreateStream(Array.Empty()); Assert.NotNull(stream); bool childSawNull = false; await Task.Run(() => { - LambdaResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); - childSawNull = LambdaResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: true) == null; + ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); + childSawNull = ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: true) == null; }); Assert.True(childSawNull); diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs index 5937e1cf9..ac4fc60eb 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs @@ -19,6 +19,7 @@ using System.Text; using System.Threading; using System.Threading.Tasks; +using Amazon.Lambda.RuntimeSupport.Client.ResponseStreaming; using Xunit; namespace Amazon.Lambda.RuntimeSupport.UnitTests @@ -29,9 +30,9 @@ public class ResponseStreamTests /// Helper: creates a ResponseStream and wires up a MemoryStream as the HTTP output stream. /// Returns both so tests can inspect what was written. /// - private static async Task<(LambdaResponseStream stream, MemoryStream httpOutput)> CreateWiredStreamAsync() + private static async Task<(ResponseStream stream, MemoryStream httpOutput)> CreateWiredStream() { - var rs = new LambdaResponseStream(); + var rs = new ResponseStream(Array.Empty()); var output = new MemoryStream(); await rs.SetHttpOutputStreamAsync(output); return (rs, output); @@ -42,7 +43,7 @@ public class ResponseStreamTests [Fact] public void Constructor_InitializesStateCorrectly() { - var stream = new LambdaResponseStream(); + var stream = new ResponseStream(Array.Empty()); Assert.Equal(0, stream.BytesWritten); Assert.False(stream.HasError); @@ -62,9 +63,9 @@ public void Constructor_InitializesStateCorrectly() [InlineData(new byte[0], "0")] // 0 bytes → "0" public async Task WriteAsync_WritesChunkedEncodingFormat(byte[] data, string expectedHexSize) { - var (stream, httpOutput) = await CreateWiredStreamAsync(); + var (stream, httpOutput) = await CreateWiredStream(); - await stream.WriteAsync(data); + await stream.WriteAsync(data, 0, data.Length); var written = httpOutput.ToArray(); var expected = Encoding.ASCII.GetBytes(expectedHexSize + "\r\n") @@ -82,7 +83,7 @@ public async Task WriteAsync_WritesChunkedEncodingFormat(byte[] data, string exp [Fact] public async Task WriteAsync_WithOffset_WritesCorrectSliceAsChunk() { - var (stream, httpOutput) = await CreateWiredStreamAsync(); + var (stream, httpOutput) = await CreateWiredStream(); var data = new byte[] { 0, 1, 2, 3, 0 }; await stream.WriteAsync(data, 1, 3); @@ -107,13 +108,14 @@ public async Task WriteAsync_WithOffset_WritesCorrectSliceAsChunk() [Fact] public async Task WriteAsync_MultipleWrites_EachAppearsImmediately() { - var (stream, httpOutput) = await CreateWiredStreamAsync(); + var (stream, httpOutput) = await CreateWiredStream(); - await stream.WriteAsync(new byte[] { 0xAA }); + var data = new byte[] { 0xAA }; + await stream.WriteAsync(data, 0, data.Length); var afterFirst = httpOutput.ToArray().Length; Assert.True(afterFirst > 0, "First chunk should be on the HTTP stream immediately after WriteAsync returns"); - await stream.WriteAsync(new byte[] { 0xBB, 0xCC }); + await stream.WriteAsync(new byte[] { 0xBB, 0xCC }, 0, 2); var afterSecond = httpOutput.ToArray().Length; Assert.True(afterSecond > afterFirst, "Second chunk should appear on the HTTP stream immediately"); @@ -128,10 +130,10 @@ public async Task WriteAsync_MultipleWrites_EachAppearsImmediately() [Fact] public async Task WriteAsync_LargerPayload_HexSizeIsCorrect() { - var (stream, httpOutput) = await CreateWiredStreamAsync(); + var (stream, httpOutput) = await CreateWiredStream(); var data = new byte[256]; // 0x100 - await stream.WriteAsync(data); + await stream.WriteAsync(data, 0, data.Length); var written = Encoding.ASCII.GetString(httpOutput.ToArray()); Assert.StartsWith("100\r\n", written); @@ -146,7 +148,7 @@ public async Task WriteAsync_LargerPayload_HexSizeIsCorrect() [Fact] public async Task WriteAsync_BlocksUntilSetHttpOutputStream() { - var rs = new LambdaResponseStream(); + var rs = new ResponseStream(Array.Empty()); var httpOutput = new MemoryStream(); var writeStarted = new ManualResetEventSlim(false); var writeCompleted = new ManualResetEventSlim(false); @@ -155,7 +157,7 @@ public async Task WriteAsync_BlocksUntilSetHttpOutputStream() var writeTask = Task.Run(async () => { writeStarted.Set(); - await rs.WriteAsync(new byte[] { 1, 2, 3 }); + await rs.WriteAsync(new byte[] { 1, 2, 3 }, 0, 3); writeCompleted.Set(); }); @@ -181,7 +183,7 @@ public async Task WriteAsync_BlocksUntilSetHttpOutputStream() [Fact] public async Task MarkCompleted_ReleasesCompletionSignal() { - var (stream, _) = await CreateWiredStreamAsync(); + var (stream, _) = await CreateWiredStream(); var waitTask = stream.WaitForCompletionAsync(); Assert.False(waitTask.IsCompleted, "WaitForCompletionAsync should block before MarkCompleted"); @@ -202,7 +204,7 @@ public async Task MarkCompleted_ReleasesCompletionSignal() [Fact] public async Task ReportErrorAsync_ReleasesCompletionSignal() { - var (stream, _) = await CreateWiredStreamAsync(); + var (stream, _) = await CreateWiredStream(); var waitTask = stream.WaitForCompletionAsync(); Assert.False(waitTask.IsCompleted, "WaitForCompletionAsync should block before ReportErrorAsync"); @@ -223,12 +225,12 @@ public async Task ReportErrorAsync_ReleasesCompletionSignal() [Fact] public async Task WriteAsync_AfterMarkCompleted_Throws() { - var (stream, _) = await CreateWiredStreamAsync(); - await stream.WriteAsync(new byte[] { 1 }); + var (stream, _) = await CreateWiredStream(); + await stream.WriteAsync(new byte[] { 1 }, 0, 1); stream.MarkCompleted(); await Assert.ThrowsAsync( - () => stream.WriteAsync(new byte[] { 2 })); + () => stream.WriteAsync(new byte[] { 2 }, 0, 1)); } /// @@ -238,12 +240,12 @@ await Assert.ThrowsAsync( [Fact] public async Task WriteAsync_AfterReportError_Throws() { - var (stream, _) = await CreateWiredStreamAsync(); - await stream.WriteAsync(new byte[] { 1 }); + var (stream, _) = await CreateWiredStream(); + await stream.WriteAsync(new byte[] { 1 }, 0, 1); stream.ReportError(new Exception("test")); await Assert.ThrowsAsync( - () => stream.WriteAsync(new byte[] { 2 })); + () => stream.WriteAsync(new byte[] { 2 }, 0, 1)); } // ---- Error handling tests ---- @@ -251,7 +253,7 @@ await Assert.ThrowsAsync( [Fact] public async Task ReportErrorAsync_SetsErrorState() { - var stream = new LambdaResponseStream(); + var stream = new ResponseStream(Array.Empty()); var exception = new InvalidOperationException("something broke"); stream.ReportError(exception); @@ -263,7 +265,7 @@ public async Task ReportErrorAsync_SetsErrorState() [Fact] public async Task ReportErrorAsync_AfterCompleted_Throws() { - var stream = new LambdaResponseStream(); + var stream = new ResponseStream(Array.Empty()); stream.MarkCompleted(); Assert.Throws( @@ -273,7 +275,7 @@ public async Task ReportErrorAsync_AfterCompleted_Throws() [Fact] public async Task ReportErrorAsync_CalledTwice_Throws() { - var stream = new LambdaResponseStream(); + var stream = new ResponseStream(Array.Empty()); stream.ReportError(new Exception("first")); Assert.Throws( @@ -285,15 +287,15 @@ public async Task ReportErrorAsync_CalledTwice_Throws() [Fact] public async Task WriteAsync_NullBuffer_ThrowsArgumentNull() { - var (stream, _) = await CreateWiredStreamAsync(); + var (stream, _) = await CreateWiredStream(); - await Assert.ThrowsAsync(() => stream.WriteAsync((byte[])null)); + await Assert.ThrowsAsync(() => stream.WriteAsync((byte[])null, 0, 0)); } [Fact] public async Task WriteAsync_NullBufferWithOffset_ThrowsArgumentNull() { - var (stream, _) = await CreateWiredStreamAsync(); + var (stream, _) = await CreateWiredStream(); await Assert.ThrowsAsync(() => stream.WriteAsync(null, 0, 0)); } @@ -301,7 +303,7 @@ public async Task WriteAsync_NullBufferWithOffset_ThrowsArgumentNull() [Fact] public async Task ReportErrorAsync_NullException_ThrowsArgumentNull() { - var stream = new LambdaResponseStream(); + var stream = new ResponseStream(Array.Empty()); Assert.Throws(() => stream.ReportError(null)); } @@ -311,12 +313,12 @@ public async Task ReportErrorAsync_NullException_ThrowsArgumentNull() [Fact] public async Task Dispose_ReleasesCompletionSignalIfNotAlreadyReleased() { - var stream = new LambdaResponseStream(); + var stream = new ResponseStream(Array.Empty()); var waitTask = stream.WaitForCompletionAsync(); Assert.False(waitTask.IsCompleted); - stream.ManualDispose(); + stream.Dispose(); var completed = await Task.WhenAny(waitTask, Task.Delay(TimeSpan.FromSeconds(2))); Assert.Same(waitTask, completed); diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/RuntimeApiClientTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/RuntimeApiClientTests.cs index 3a471ab1e..08275feb7 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/RuntimeApiClientTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/RuntimeApiClientTests.cs @@ -20,6 +20,7 @@ using System.Net.Http; using System.Threading; using System.Threading.Tasks; +using Amazon.Lambda.RuntimeSupport.Client.ResponseStreaming; using Xunit; namespace Amazon.Lambda.RuntimeSupport.UnitTests @@ -40,9 +41,9 @@ public class RuntimeApiClientTests private class MockHttpMessageHandler : HttpMessageHandler { public HttpRequestMessage CapturedRequest { get; private set; } - private readonly LambdaResponseStream _responseStream; + private readonly ResponseStream _responseStream; - public MockHttpMessageHandler(LambdaResponseStream responseStream) + public MockHttpMessageHandler(ResponseStream responseStream) { _responseStream = responseStream; } @@ -57,7 +58,7 @@ protected override Task SendAsync( } private static RuntimeApiClient CreateClientWithMockHandler( - LambdaResponseStream stream, out MockHttpMessageHandler handler) + ResponseStream stream, out MockHttpMessageHandler handler) { handler = new MockHttpMessageHandler(stream); var httpClient = new HttpClient(handler); @@ -77,7 +78,7 @@ private static RuntimeApiClient CreateClientWithMockHandler( [Fact] public async Task StartStreamingResponseAsync_IncludesStreamingResponseModeHeader() { - var stream = new LambdaResponseStream(); + var stream = new ResponseStream(Array.Empty()); var client = CreateClientWithMockHandler(stream, out var handler); await client.StartStreamingResponseAsync("req-1", stream, CancellationToken.None); @@ -100,7 +101,7 @@ public async Task StartStreamingResponseAsync_IncludesStreamingResponseModeHeade [Fact] public async Task StartStreamingResponseAsync_IncludesChunkedTransferEncodingHeader() { - var stream = new LambdaResponseStream(); + var stream = new ResponseStream(Array.Empty()); var client = CreateClientWithMockHandler(stream, out var handler); await client.StartStreamingResponseAsync("req-2", stream, CancellationToken.None); @@ -121,7 +122,7 @@ public async Task StartStreamingResponseAsync_IncludesChunkedTransferEncodingHea [Fact] public async Task StartStreamingResponseAsync_DeclaresTrailerHeaderUpfront() { - var stream = new LambdaResponseStream(); + var stream = new ResponseStream(Array.Empty()); var client = CreateClientWithMockHandler(stream, out var handler); await client.StartStreamingResponseAsync("req-3", stream, CancellationToken.None); @@ -184,7 +185,7 @@ public async Task SendResponseAsync_BufferedResponse_ExcludesStreamingHeaders() [Fact] public async Task StartStreamingResponseAsync_NullRequestId_ThrowsArgumentNullException() { - var stream = new LambdaResponseStream(); + var stream = new ResponseStream(Array.Empty()); var client = CreateClientWithMockHandler(stream, out _); await Assert.ThrowsAsync( @@ -194,7 +195,7 @@ await Assert.ThrowsAsync( [Fact] public async Task StartStreamingResponseAsync_NullResponseStream_ThrowsArgumentNullException() { - var stream = new LambdaResponseStream(); + var stream = new ResponseStream(Array.Empty()); var client = CreateClientWithMockHandler(stream, out _); await Assert.ThrowsAsync( diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingE2EWithMoq.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingE2EWithMoq.cs index 6f6b9aab8..2417b3ccb 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingE2EWithMoq.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingE2EWithMoq.cs @@ -20,6 +20,7 @@ using System.Text; using System.Threading; using System.Threading.Tasks; +using Amazon.Lambda.RuntimeSupport.Client.ResponseStreaming; using Amazon.Lambda.RuntimeSupport.UnitTests.TestHelpers; using Xunit; @@ -38,8 +39,8 @@ public class StreamingE2EWithMoq : IDisposable { public void Dispose() { - LambdaResponseStreamFactory.CleanupInvocation(isMultiConcurrency: false); - LambdaResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); + ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: false); + ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); } // ─── Helpers ──────────────────────────────────────────────────────────────── @@ -67,7 +68,7 @@ private class CapturingStreamingRuntimeApiClient : RuntimeApiClient, IRuntimeApi public bool SendResponseCalled { get; private set; } public bool ReportInvocationErrorCalled { get; private set; } public byte[] CapturedHttpBytes { get; private set; } - public LambdaResponseStream LastResponseStream { get; private set; } + public ResponseStream LastResponseStream { get; private set; } public Stream LastBufferedOutputStream { get; private set; } public new Amazon.Lambda.RuntimeSupport.Helpers.IConsoleLoggerWriter ConsoleLogger { get; } = new Helpers.LogLevelLoggerWriter(new SystemEnvironmentVariables()); @@ -97,7 +98,7 @@ public CapturingStreamingRuntimeApiClient( } internal override async Task StartStreamingResponseAsync( - string awsRequestId, LambdaResponseStream responseStream, CancellationToken cancellationToken = default) + string awsRequestId, ResponseStream responseStream, CancellationToken cancellationToken = default) { StartStreamingCalled = true; LastResponseStream = responseStream; @@ -159,7 +160,7 @@ public async Task Streaming_MultipleChunks_FlowThroughWithChunkedEncoding() LambdaBootstrapHandler handler = async (invocation) => { - var stream = LambdaResponseStreamFactory.CreateStream(); + var stream = ResponseStreamFactory.CreateStream(Array.Empty()); foreach (var chunk in chunks) await stream.WriteAsync(Encoding.UTF8.GetBytes(chunk)); return new InvocationResponse(Stream.Null, false); @@ -196,7 +197,7 @@ public async Task Streaming_AllDataTransmitted_ContentRoundTrip() LambdaBootstrapHandler handler = async (invocation) => { - var stream = LambdaResponseStreamFactory.CreateStream(); + var stream = ResponseStreamFactory.CreateStream(Array.Empty()); await stream.WriteAsync(payload); return new InvocationResponse(Stream.Null, false); }; @@ -227,7 +228,7 @@ public async Task Streaming_StreamFinalized_BytesWrittenMatchesPayload() LambdaBootstrapHandler handler = async (invocation) => { - var stream = LambdaResponseStreamFactory.CreateStream(); + var stream = ResponseStreamFactory.CreateStream(Array.Empty()); await stream.WriteAsync(data); return new InvocationResponse(Stream.Null, false); }; @@ -308,7 +309,7 @@ public async Task MidstreamError_ErrorTrailersIncludedAfterFinalChunk() LambdaBootstrapHandler handler = async (invocation) => { - var stream = LambdaResponseStreamFactory.CreateStream(); + var stream = ResponseStreamFactory.CreateStream(Array.Empty()); await stream.WriteAsync(Encoding.UTF8.GetBytes("partial data")); throw new InvalidOperationException("midstream failure"); }; @@ -355,7 +356,7 @@ public async Task MidstreamError_ErrorBodyTrailerContainsJsonDetails() LambdaBootstrapHandler handler = async (invocation) => { - var stream = LambdaResponseStreamFactory.CreateStream(); + var stream = ResponseStreamFactory.CreateStream(Array.Empty()); await stream.WriteAsync(Encoding.UTF8.GetBytes("some data")); throw new InvalidOperationException(errorMessage); }; @@ -393,13 +394,13 @@ public async Task MultiConcurrency_ConcurrentInvocations_StateIsolated() tasks.Add(Task.Run(async () => { var mockClient = new MockMultiConcurrencyStreamingClient(); - LambdaResponseStreamFactory.InitializeInvocation( + ResponseStreamFactory.InitializeInvocation( requestId, isMultiConcurrency: true, mockClient, CancellationToken.None); - var stream = LambdaResponseStreamFactory.CreateStream(); + var stream = ResponseStreamFactory.CreateStream(Array.Empty()); allStarted.Release(); // Wait until all tasks have started (to ensure true concurrency) @@ -409,10 +410,10 @@ public async Task MultiConcurrency_ConcurrentInvocations_StateIsolated() stream.MarkCompleted(); // Verify this invocation's stream is still accessible - var retrieved = LambdaResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: true); + var retrieved = ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: true); results[requestId] = retrieved != null ? payload : "MISSING"; - LambdaResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); + ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); })); } @@ -450,20 +451,20 @@ public async Task MultiConcurrency_StreamingAndBufferedMixedConcurrently_NoInter tasks.Add(Task.Run(async () => { var mockClient = new MockMultiConcurrencyStreamingClient(); - LambdaResponseStreamFactory.InitializeInvocation( + ResponseStreamFactory.InitializeInvocation( requestId, isMultiConcurrency: true, mockClient, CancellationToken.None); - var stream = LambdaResponseStreamFactory.CreateStream(); + var stream = ResponseStreamFactory.CreateStream(Array.Empty()); allStarted.Release(); await barrier.WaitAsync(); await stream.WriteAsync(Encoding.UTF8.GetBytes("streaming data")); stream.MarkCompleted(); - var retrieved = LambdaResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: true); + var retrieved = ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: true); streamingResults.Add(retrieved != null); - LambdaResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); + ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); })); } @@ -474,7 +475,7 @@ public async Task MultiConcurrency_StreamingAndBufferedMixedConcurrently_NoInter tasks.Add(Task.Run(async () => { var mockClient = new MockMultiConcurrencyStreamingClient(); - LambdaResponseStreamFactory.InitializeInvocation( + ResponseStreamFactory.InitializeInvocation( requestId, isMultiConcurrency: true, mockClient, CancellationToken.None); @@ -482,9 +483,9 @@ public async Task MultiConcurrency_StreamingAndBufferedMixedConcurrently_NoInter await barrier.WaitAsync(); // No CreateStream — buffered mode - var retrieved = LambdaResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: true); + var retrieved = ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: true); bufferedResults.Add(retrieved == null); // should be null (no stream created) - LambdaResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); + ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); })); } @@ -511,7 +512,7 @@ public MockMultiConcurrencyStreamingClient() : base(new TestEnvironmentVariables(), new NoOpInternalRuntimeApiClient()) { } internal override async Task StartStreamingResponseAsync( - string awsRequestId, LambdaResponseStream responseStream, CancellationToken cancellationToken = default) + string awsRequestId, ResponseStream responseStream, CancellationToken cancellationToken = default) { // Provide the HTTP output stream so writes don't block await responseStream.SetHttpOutputStreamAsync(new MemoryStream()); diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingHttpContentTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingHttpContentTests.cs index 4fed4b810..bf87dd31a 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingHttpContentTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingHttpContentTests.cs @@ -16,8 +16,8 @@ using System; using System.IO; using System.Text; -using System.Threading; using System.Threading.Tasks; +using Amazon.Lambda.RuntimeSupport.Client.ResponseStreaming; using Xunit; namespace Amazon.Lambda.RuntimeSupport.UnitTests @@ -32,8 +32,8 @@ public class StreamingHttpContentTests /// Returns the bytes written to the HTTP output stream. /// private async Task SerializeWithConcurrentHandler( - LambdaResponseStream responseStream, - Func handlerAction) + ResponseStream responseStream, + Func handlerAction) { var content = new StreamingHttpContent(responseStream); var outputStream = new MemoryStream(); @@ -63,7 +63,7 @@ private async Task SerializeWithConcurrentHandler( [Fact] public async Task SerializeToStreamAsync_HandsOffHttpStream_WritesFlowThrough() { - var rs = new LambdaResponseStream(); + var rs = new ResponseStream(Array.Empty()); var output = await SerializeWithConcurrentHandler(rs, async stream => { @@ -84,7 +84,7 @@ public async Task SerializeToStreamAsync_HandsOffHttpStream_WritesFlowThrough() [Fact] public async Task SerializeToStreamAsync_BlocksUntilMarkCompleted() { - var rs = new LambdaResponseStream(); + var rs = new ResponseStream(Array.Empty()); var content = new StreamingHttpContent(rs); var outputStream = new MemoryStream(); @@ -108,7 +108,7 @@ public async Task SerializeToStreamAsync_BlocksUntilMarkCompleted() [Fact] public async Task SerializeToStreamAsync_BlocksUntilReportErrorAsync() { - var rs = new LambdaResponseStream(); + var rs = new ResponseStream(Array.Empty()); var content = new StreamingHttpContent(rs); var outputStream = new MemoryStream(); @@ -132,7 +132,7 @@ public async Task SerializeToStreamAsync_BlocksUntilReportErrorAsync() [Fact] public async Task FinalChunk_WrittenAfterCompletion() { - var rs = new LambdaResponseStream(); + var rs = new ResponseStream(Array.Empty()); var output = await SerializeWithConcurrentHandler(rs, async stream => { @@ -156,7 +156,7 @@ public async Task FinalChunk_WrittenAfterCompletion() [Fact] public async Task FinalChunk_EmptyStream_StillWritten() { - var rs = new LambdaResponseStream(); + var rs = new ResponseStream(Array.Empty()); var output = await SerializeWithConcurrentHandler(rs, stream => { @@ -177,7 +177,7 @@ public async Task FinalChunk_EmptyStream_StillWritten() [Fact] public async Task ErrorTrailers_AppearAfterFinalChunk() { - var rs = new LambdaResponseStream(); + var rs = new ResponseStream(Array.Empty()); var output = await SerializeWithConcurrentHandler(rs, async stream => { @@ -210,7 +210,7 @@ public async Task ErrorTrailers_AppearAfterFinalChunk() [InlineData(typeof(NullReferenceException))] public async Task ErrorTrailer_IncludesErrorType(Type exceptionType) { - var rs = new LambdaResponseStream(); + var rs = new ResponseStream(Array.Empty()); var output = await SerializeWithConcurrentHandler(rs, async stream => { @@ -232,7 +232,7 @@ public async Task ErrorTrailer_IncludesErrorType(Type exceptionType) [Fact] public async Task ErrorTrailer_IncludesJsonErrorBody() { - var rs = new LambdaResponseStream(); + var rs = new ResponseStream(Array.Empty()); var output = await SerializeWithConcurrentHandler(rs, async stream => { @@ -255,7 +255,7 @@ public async Task ErrorTrailer_IncludesJsonErrorBody() [Fact] public async Task SuccessfulCompletion_EndsWithCrlf() { - var rs = new LambdaResponseStream(); + var rs = new ResponseStream(Array.Empty()); var output = await SerializeWithConcurrentHandler(rs, async stream => { @@ -275,7 +275,7 @@ public async Task SuccessfulCompletion_EndsWithCrlf() [Fact] public async Task ErrorCompletion_EndsWithCrlf() { - var rs = new LambdaResponseStream(); + var rs = new ResponseStream(Array.Empty()); var output = await SerializeWithConcurrentHandler(rs, async stream => { @@ -292,7 +292,7 @@ public async Task ErrorCompletion_EndsWithCrlf() [Fact] public async Task NoError_NoTrailersWritten() { - var rs = new LambdaResponseStream(); + var rs = new ResponseStream(Array.Empty()); var output = await SerializeWithConcurrentHandler(rs, async stream => { @@ -310,7 +310,7 @@ public async Task NoError_NoTrailersWritten() [Fact] public void TryComputeLength_ReturnsFalse() { - var stream = new LambdaResponseStream(); + var stream = new ResponseStream(Array.Empty()); var content = new StreamingHttpContent(stream); var result = content.Headers.ContentLength; @@ -326,7 +326,7 @@ public void TryComputeLength_ReturnsFalse() [Fact] public async Task CrlfTerminators_NoBareLineFeed() { - var rs = new LambdaResponseStream(); + var rs = new ResponseStream(Array.Empty()); var output = await SerializeWithConcurrentHandler(rs, async stream => { diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/TestHelpers/TestStreamingRuntimeApiClient.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/TestHelpers/TestStreamingRuntimeApiClient.cs index 4d5166fb5..621f7af6f 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/TestHelpers/TestStreamingRuntimeApiClient.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/TestHelpers/TestStreamingRuntimeApiClient.cs @@ -13,6 +13,7 @@ * permissions and limitations under the License. */ +using Amazon.Lambda.RuntimeSupport.Client.ResponseStreaming; using Amazon.Lambda.RuntimeSupport.Helpers; using Amazon.Lambda.RuntimeSupport.UnitTests.TestHelpers; using System; @@ -54,7 +55,7 @@ public TestStreamingRuntimeApiClient(IEnvironmentVariables environmentVariables, public byte[] FunctionInput { get; set; } public Stream LastOutputStream { get; private set; } public Exception LastRecordedException { get; private set; } - public LambdaResponseStream LastStreamingResponseStream { get; private set; } + public ResponseStream LastStreamingResponseStream { get; private set; } public new async Task GetNextInvocationAsync(CancellationToken cancellationToken = default) { @@ -108,7 +109,7 @@ public TestStreamingRuntimeApiClient(IEnvironmentVariables environmentVariables, } internal override async Task StartStreamingResponseAsync( - string awsRequestId, LambdaResponseStream responseStream, CancellationToken cancellationToken = default) + string awsRequestId, ResponseStream responseStream, CancellationToken cancellationToken = default) { StartStreamingResponseAsyncCalled = true; LastStreamingResponseStream = responseStream; From 99b83c2a577df905d82712fa506733baf9c27a19 Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Fri, 6 Mar 2026 11:56:56 -0800 Subject: [PATCH 14/20] Rework encoding and add support for using API Gateway --- .../HttpResponseStreamPrelude.cs | 89 ++++++++++ .../ILambdaResponseStream.cs | 40 +++++ .../LambdaResponseStream.cs} | 65 +------ .../LambdaResponseStreamFactory.cs | 59 +++++++ .../ResponseStreaming/ResponseStream.cs | 34 ++-- ...onseStreamLambdaCoreInitializerIsolated.cs | 2 +- .../ResponseStreaming/StreamingHttpContent.cs | 12 +- .../Client/RuntimeApiClient.cs | 4 + .../ResponseStreamTests.cs | 93 +--------- .../StreamingE2EWithMoq.cs | 93 +--------- .../StreamingHttpContentTests.cs | 165 +----------------- 11 files changed, 209 insertions(+), 447 deletions(-) create mode 100644 Libraries/src/Amazon.Lambda.Core/ResponseStreaming/HttpResponseStreamPrelude.cs create mode 100644 Libraries/src/Amazon.Lambda.Core/ResponseStreaming/ILambdaResponseStream.cs rename Libraries/src/Amazon.Lambda.Core/{LambdaResponseStreamFactory.cs => ResponseStreaming/LambdaResponseStream.cs} (64%) create mode 100644 Libraries/src/Amazon.Lambda.Core/ResponseStreaming/LambdaResponseStreamFactory.cs diff --git a/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/HttpResponseStreamPrelude.cs b/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/HttpResponseStreamPrelude.cs new file mode 100644 index 000000000..ebd8a7018 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/HttpResponseStreamPrelude.cs @@ -0,0 +1,89 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +#if NET8_0_OR_GREATER +using System.Collections.Generic; +using System.Net; +using System.Runtime.Versioning; +using System.Text.Json; + +namespace Amazon.Lambda.Core.ResponseStreaming +{ + /// + /// The HTTP response prelude to be sent as the first chunk of a streaming response when using . + /// + [RequiresPreviewFeatures(LambdaResponseStreamFactory.ParameterizedPreviewMessage)] + public class HttpResponseStreamPrelude + { + /// + /// The Http status code to include in the response prelude. + /// + public HttpStatusCode? StatusCode { get; set; } + + /// + /// The response headers to include in the response prelude. This collection supports setting single value for the same headers. + /// + public IDictionary Headers { get; set; } = new Dictionary(); + + /// + /// The response headers to include in the response prelude. This collection supports setting multiple values for the same headers. + /// + public IDictionary> MultiValueHeaders { get; set; } = new Dictionary>(); + + /// + /// The list of cookies to include in the response prelude. This is used for Lambda Function URL responses, which support a separate "cookies" field in the response JSON for setting cookies, rather than requiring cookies to be set via the "Set-Cookie" header. + /// + public IList Cookies { get; set; } = new List(); + + internal byte[] ToByteArray() + { + var bufferWriter = new System.Buffers.ArrayBufferWriter(); + using (var writer = new Utf8JsonWriter(bufferWriter)) + { + writer.WriteStartObject(); + + if (StatusCode.HasValue) + writer.WriteNumber("statusCode", (int)StatusCode); + + if (Headers?.Count > 0) + { + writer.WriteStartObject("headers"); + foreach (var header in Headers) + { + writer.WriteString(header.Key, header.Value); + } + writer.WriteEndObject(); + } + + if (MultiValueHeaders?.Count > 0) + { + writer.WriteStartObject("multiValueHeaders"); + foreach (var header in MultiValueHeaders) + { + writer.WriteStartArray(header.Key); + foreach (var value in header.Value) + { + writer.WriteStringValue(value); + } + writer.WriteEndArray(); + } + writer.WriteEndObject(); + } + + if (Cookies?.Count > 0) + { + writer.WriteStartArray("cookies"); + foreach (var cookie in Cookies) + { + writer.WriteStringValue(cookie); + } + writer.WriteEndArray(); + } + + writer.WriteEndObject(); + } + + return bufferWriter.WrittenSpan.ToArray(); + } + } +} +#endif diff --git a/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/ILambdaResponseStream.cs b/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/ILambdaResponseStream.cs new file mode 100644 index 000000000..1385e551e --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/ILambdaResponseStream.cs @@ -0,0 +1,40 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +#if NET8_0_OR_GREATER +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Amazon.Lambda.Core.ResponseStreaming +{ + /// + /// Interface for writing streaming responses in AWS Lambda functions. + /// Obtained by calling within a handler. + /// + internal interface ILambdaResponseStream : IDisposable + { + /// + /// Asynchronously writes a portion of a byte array to the response stream. + /// + /// The byte array containing data to write. + /// The zero-based byte offset in buffer at which to begin copying bytes. + /// The number of bytes to write. + /// Optional cancellation token. + /// A task representing the asynchronous operation. + /// Thrown if the stream is already completed or an error has been reported. + Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken = default); + + + /// + /// Gets the total number of bytes written to the stream so far. + /// + long BytesWritten { get; } + + + /// + /// Gets whether an error has been reported. + /// + bool HasError { get; } + } +} +#endif diff --git a/Libraries/src/Amazon.Lambda.Core/LambdaResponseStreamFactory.cs b/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/LambdaResponseStream.cs similarity index 64% rename from Libraries/src/Amazon.Lambda.Core/LambdaResponseStreamFactory.cs rename to Libraries/src/Amazon.Lambda.Core/ResponseStreaming/LambdaResponseStream.cs index 46ff77d18..506db46d7 100644 --- a/Libraries/src/Amazon.Lambda.Core/LambdaResponseStreamFactory.cs +++ b/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/LambdaResponseStream.cs @@ -1,76 +1,15 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 #if NET8_0_OR_GREATER + using System; using System.IO; using System.Runtime.Versioning; using System.Threading; using System.Threading.Tasks; -namespace Amazon.Lambda.Core +namespace Amazon.Lambda.Core.ResponseStreaming { - /// - /// Factory to create Lambda response streams for writing streaming responses in AWS Lambda functions. The created streams are write-only and non-seekable. - /// - [RequiresPreviewFeatures(LambdaResponseStreamFactory.ParameterizedPreviewMessage)] - public class LambdaResponseStreamFactory - { - internal const string ParameterizedPreviewMessage = - "Response streaming is in preview till a new version of .NET Lambda runtime client that supports response streaming " + - "has been deployed to the .NET Lambda managed runtime. Till deployment has been made the feature can be used by deploying as an " + - "executable including the latest version of Amazon.Lambda.RuntimeSupport and setting the \"EnablePreviewFeatures\" in the Lambda " + - "project file to \"true\""; - - private static Func _streamFactory; - - internal static void SetLambdaResponseStream(Func streamFactory) - { - _streamFactory = streamFactory ?? throw new ArgumentNullException(nameof(streamFactory)); - } - - /// - /// Creates a that can be used to write streaming responses back to callers of the Lambda function. Once - /// A Lambda function creates a response stream all output must be returned by writing to the stream; the Lambda function's handler - /// return value will be ignored. The stream is write-only and non-seekable. - /// - /// - public static Stream CreateStream() - { - var runtimeResponseStream = _streamFactory(Array.Empty()); - return new LambdaResponseStream(runtimeResponseStream); - } - } - - /// - /// Interface for writing streaming responses in AWS Lambda functions. - /// Obtained by calling within a handler. - /// - internal interface ILambdaResponseStream : IDisposable - { - /// - /// Asynchronously writes a portion of a byte array to the response stream. - /// - /// The byte array containing data to write. - /// The zero-based byte offset in buffer at which to begin copying bytes. - /// The number of bytes to write. - /// Optional cancellation token. - /// A task representing the asynchronous operation. - /// Thrown if the stream is already completed or an error has been reported. - Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken = default); - - - /// - /// Gets the total number of bytes written to the stream so far. - /// - long BytesWritten { get; } - - - /// - /// Gets whether an error has been reported. - /// - bool HasError { get; } - } - /// /// A write-only, non-seekable subclass that streams response data /// to the Lambda Runtime API. Returned by . diff --git a/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/LambdaResponseStreamFactory.cs b/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/LambdaResponseStreamFactory.cs new file mode 100644 index 000000000..c82ce4a3d --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/LambdaResponseStreamFactory.cs @@ -0,0 +1,59 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +#if NET8_0_OR_GREATER +using System; +using System.IO; +using System.Runtime.Versioning; + +namespace Amazon.Lambda.Core.ResponseStreaming +{ + /// + /// Factory to create Lambda response streams for writing streaming responses in AWS Lambda functions. The created streams are write-only and non-seekable. + /// + [RequiresPreviewFeatures(LambdaResponseStreamFactory.ParameterizedPreviewMessage)] + public class LambdaResponseStreamFactory + { + internal const string ParameterizedPreviewMessage = + "Response streaming is in preview till a new version of .NET Lambda runtime client that supports response streaming " + + "has been deployed to the .NET Lambda managed runtime. Till deployment has been made the feature can be used by deploying as an " + + "executable including the latest version of Amazon.Lambda.RuntimeSupport and setting the \"EnablePreviewFeatures\" in the Lambda " + + "project file to \"true\""; + + private static Func _streamFactory; + + internal static void SetLambdaResponseStream(Func streamFactory) + { + _streamFactory = streamFactory ?? throw new ArgumentNullException(nameof(streamFactory)); + } + + /// + /// Creates a that can be used to write streaming responses back to callers of the Lambda function. Once + /// a Lambda function creates a response stream all output must be returned by writing to the stream; the Lambda function's handler + /// return value will be ignored. The stream is write-only and non-seekable. + /// + /// + public static Stream CreateStream() + { + var runtimeResponseStream = _streamFactory(Array.Empty()); + return new LambdaResponseStream(runtimeResponseStream); + } + + /// + /// Create a for writing streaming responses, with an HTTP response prelude containing status code and headers. This should be used for + /// Lambda functions using response streaming that are invoked via the Lambda Function URLs or API Gateway HTTP APIs, where the response format is expected to be an HTTP response. + /// The prelude will be serialized and sent as the first chunk of the response stream, and should contain any necessary HTTP status code and headers. + /// + /// Once a Lambda function creates a response stream all output must be returned by writing to the stream; the Lambda function's handler + /// return value will be ignored. The stream is write-only and non-seekable. + /// + /// + /// The HTTP response prelude including status code and headers. + /// + public static Stream CreateHttpStream(HttpResponseStreamPrelude prelude) + { + var runtimeResponseStream = _streamFactory(prelude.ToByteArray()); + return new LambdaResponseStream(runtimeResponseStream); + } + } +} +#endif diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStream.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStream.cs index 37db44c76..c825c3bb6 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStream.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStream.cs @@ -27,8 +27,6 @@ namespace Amazon.Lambda.RuntimeSupport.Client.ResponseStreaming /// internal class ResponseStream { - private static readonly byte[] CrlfBytes = Encoding.ASCII.GetBytes("\r\n"); - private long _bytesWritten; private bool _isCompleted; private bool _hasError; @@ -41,6 +39,8 @@ internal class ResponseStream private readonly SemaphoreSlim _httpStreamReady = new SemaphoreSlim(0, 1); private readonly SemaphoreSlim _completionSignal = new SemaphoreSlim(0, 1); + private static readonly byte[] PreludeDelimiter = new byte[8]; + /// /// The number of bytes written to the Lambda response stream so far. /// @@ -54,10 +54,14 @@ internal class ResponseStream private readonly byte[] _prelude; + private readonly InternalLogger _logger; + + internal Exception ReportedError => _reportedError; internal ResponseStream(byte[] prelude) { + _logger = InternalLogger.GetDefaultLogger(); _prelude = prelude; } @@ -69,7 +73,6 @@ internal async Task SetHttpOutputStreamAsync(Stream httpOutputStream, Cancellati _httpOutputStream = httpOutputStream; _httpStreamReady.Release(); - InternalLogger.GetDefaultLogger().LogDebug($"Writing prelude of {_prelude.Length} bytes to HTTP stream."); await WritePreludeAsync(cancellationToken); } @@ -77,6 +80,7 @@ private async Task WritePreludeAsync(CancellationToken cancellationToken = defau { if (_prelude?.Length > 0) { + _logger.LogDebug($"Writing prelude of {_prelude.Length} bytes to HTTP stream."); await _httpStreamReady.WaitAsync(cancellationToken); try { @@ -85,22 +89,8 @@ private async Task WritePreludeAsync(CancellationToken cancellationToken = defau ThrowIfCompletedOrError(); } - // Write prelude JSON chunk - var chunkSizeHex = _prelude.Length.ToString("X"); - var chunkSizeBytes = Encoding.ASCII.GetBytes(chunkSizeHex); - await _httpOutputStream.WriteAsync(chunkSizeBytes, 0, chunkSizeBytes.Length, cancellationToken); - await _httpOutputStream.WriteAsync(CrlfBytes, 0, CrlfBytes.Length, cancellationToken); await _httpOutputStream.WriteAsync(_prelude, 0, _prelude.Length, cancellationToken); - await _httpOutputStream.WriteAsync(CrlfBytes, 0, CrlfBytes.Length, cancellationToken); - - // Write 8 null bytes delimiter chunk - var delimiterBytes = new byte[8]; - chunkSizeHex = "8"; - chunkSizeBytes = Encoding.ASCII.GetBytes(chunkSizeHex); - await _httpOutputStream.WriteAsync(chunkSizeBytes, 0, chunkSizeBytes.Length, cancellationToken); - await _httpOutputStream.WriteAsync(CrlfBytes, 0, CrlfBytes.Length, cancellationToken); - await _httpOutputStream.WriteAsync(delimiterBytes, 0, delimiterBytes.Length, cancellationToken); - await _httpOutputStream.WriteAsync(CrlfBytes, 0, CrlfBytes.Length, cancellationToken); + await _httpOutputStream.WriteAsync(PreludeDelimiter, 0, PreludeDelimiter.Length, cancellationToken); await _httpOutputStream.FlushAsync(cancellationToken); } @@ -149,19 +139,15 @@ public async Task WriteAsync(byte[] buffer, int offset, int count, CancellationT await _httpStreamReady.WaitAsync(cancellationToken); try { + _logger.LogDebug($"Writing chuck of {count} bytes to HTTP stream."); + lock (_lock) { ThrowIfCompletedOrError(); _bytesWritten += count; } - // Write chunk directly to the HTTP stream: size(hex) + CRLF + data + CRLF - var chunkSizeHex = count.ToString("X"); - var chunkSizeBytes = Encoding.ASCII.GetBytes(chunkSizeHex); - await _httpOutputStream.WriteAsync(chunkSizeBytes, 0, chunkSizeBytes.Length, cancellationToken); - await _httpOutputStream.WriteAsync(CrlfBytes, 0, CrlfBytes.Length, cancellationToken); await _httpOutputStream.WriteAsync(buffer, offset, count, cancellationToken); - await _httpOutputStream.WriteAsync(CrlfBytes, 0, CrlfBytes.Length, cancellationToken); await _httpOutputStream.FlushAsync(cancellationToken); } finally diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStreamLambdaCoreInitializerIsolated.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStreamLambdaCoreInitializerIsolated.cs index 15791d0b3..e9e846723 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStreamLambdaCoreInitializerIsolated.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStreamLambdaCoreInitializerIsolated.cs @@ -5,7 +5,7 @@ using System; using System.Threading; using System.Threading.Tasks; -using Amazon.Lambda.Core; +using Amazon.Lambda.Core.ResponseStreaming; using Amazon.Lambda.RuntimeSupport.Client.ResponseStreaming; #pragma warning disable CA2252 namespace Amazon.Lambda.RuntimeSupport diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/StreamingHttpContent.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/StreamingHttpContent.cs index 797c92758..a0cc0511a 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/StreamingHttpContent.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/StreamingHttpContent.cs @@ -29,9 +29,6 @@ namespace Amazon.Lambda.RuntimeSupport.Client.ResponseStreaming /// internal class StreamingHttpContent : HttpContent { - private static readonly byte[] CrlfBytes = Encoding.ASCII.GetBytes("\r\n"); - private static readonly byte[] FinalChunkBytes = Encoding.ASCII.GetBytes("0\r\n"); - private readonly ResponseStream _responseStream; private readonly CancellationToken _cancellationToken; @@ -47,23 +44,16 @@ protected override async Task SerializeToStreamAsync(Stream stream, TransportCon // can write chunks directly to it. await _responseStream.SetHttpOutputStreamAsync(stream, _cancellationToken); - InternalLogger.GetDefaultLogger().LogInformation("In SerializeToStreamAsync waiting for the undlying Lambda response stream in indicate it is complete."); + InternalLogger.GetDefaultLogger().LogInformation("In SerializeToStreamAsync waiting for the underlying Lambda response stream in indicate it is complete."); // Wait for the handler to finish writing (MarkCompleted or ReportErrorAsync) await _responseStream.WaitForCompletionAsync(_cancellationToken); - // Write final chunk - await stream.WriteAsync(FinalChunkBytes, 0, FinalChunkBytes.Length, _cancellationToken); - // Write error trailers if present if (_responseStream.HasError) { InternalLogger.GetDefaultLogger().LogError(_responseStream.ReportedError, "An error occurred during Lambda execution. Writing error trailers to response."); await WriteErrorTrailersAsync(stream, _responseStream.ReportedError); } - - // Write final CRLF to end the chunked message - await stream.WriteAsync(CrlfBytes, 0, CrlfBytes.Length, _cancellationToken); - await stream.FlushAsync(_cancellationToken); } protected override bool TryComputeLength(out long length) diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs index 041097057..dcec11ae3 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs @@ -201,6 +201,10 @@ internal virtual async Task StartStreamingResponseAsync( { request.Headers.Add(StreamingConstants.ResponseModeHeader, StreamingConstants.StreamingResponseMode); request.Headers.TransferEncodingChunked = true; + request.Headers.TryAddWithoutValidation( + "Content-Type", + "application/vnd.awslambda.http-integration-response" + ); // Declare trailers upfront — we always declare them since we don't know // at request start time whether an error will occur mid-stream. diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs index ac4fc60eb..1aa2eb10c 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs @@ -50,36 +50,6 @@ public void Constructor_InitializesStateCorrectly() Assert.Null(stream.ReportedError); } - // ---- Chunked encoding format (Property 9, Property 22) ---- - - /// - /// Property 9: Chunked Encoding Format — each chunk is hex-size + CRLF + data + CRLF. - /// Property 22: CRLF Line Terminators — all line terminators are \r\n. - /// Validates: Requirements 3.2, 10.1, 10.5 - /// - [Theory] - [InlineData(new byte[] { 1, 2, 3 }, "3")] // 3 bytes → "3" - [InlineData(new byte[] { 0xFF }, "1")] // 1 byte → "1" - [InlineData(new byte[0], "0")] // 0 bytes → "0" - public async Task WriteAsync_WritesChunkedEncodingFormat(byte[] data, string expectedHexSize) - { - var (stream, httpOutput) = await CreateWiredStream(); - - await stream.WriteAsync(data, 0, data.Length); - - var written = httpOutput.ToArray(); - var expected = Encoding.ASCII.GetBytes(expectedHexSize + "\r\n") - .Concat(data) - .Concat(Encoding.ASCII.GetBytes("\r\n")) - .ToArray(); - - Assert.Equal(expected, written); - } - - /// - /// Property 9: Chunked Encoding Format — verify with offset/count overload. - /// Validates: Requirements 3.2, 10.1 - /// [Fact] public async Task WriteAsync_WithOffset_WritesCorrectSliceAsChunk() { @@ -90,21 +60,11 @@ public async Task WriteAsync_WithOffset_WritesCorrectSliceAsChunk() var written = httpOutput.ToArray(); // 3 bytes → hex "3", data is {1,2,3} - var expected = Encoding.ASCII.GetBytes("3\r\n") - .Concat(new byte[] { 1, 2, 3 }) - .Concat(Encoding.ASCII.GetBytes("\r\n")) - .ToArray(); + var expected = new byte[] { 1, 2, 3 }; Assert.Equal(expected, written); } - // ---- Property 5: Written Data Appears in HTTP Response Immediately ---- - - /// - /// Property 5: Written Data Appears in HTTP Response Immediately — - /// each WriteAsync call writes to the HTTP stream before returning. - /// Validates: Requirements 3.2 - /// [Fact] public async Task WriteAsync_MultipleWrites_EachAppearsImmediately() { @@ -122,29 +82,6 @@ public async Task WriteAsync_MultipleWrites_EachAppearsImmediately() Assert.Equal(3, stream.BytesWritten); } - /// - /// Property 5: Written Data Appears in HTTP Response Immediately — - /// verify with a larger payload that hex size is multi-character. - /// Validates: Requirements 3.2 - /// - [Fact] - public async Task WriteAsync_LargerPayload_HexSizeIsCorrect() - { - var (stream, httpOutput) = await CreateWiredStream(); - var data = new byte[256]; // 0x100 - - await stream.WriteAsync(data, 0, data.Length); - - var written = Encoding.ASCII.GetString(httpOutput.ToArray()); - Assert.StartsWith("100\r\n", written); - } - - // ---- Semaphore coordination: _httpStreamReady blocks until SetHttpOutputStream ---- - - /// - /// Test that WriteAsync blocks until SetHttpOutputStream is called. - /// Validates: Requirements 3.2, 10.1 - /// [Fact] public async Task WriteAsync_BlocksUntilSetHttpOutputStream() { @@ -174,12 +111,6 @@ public async Task WriteAsync_BlocksUntilSetHttpOutputStream() Assert.True(httpOutput.ToArray().Length > 0); } - // ---- Completion signaling: MarkCompleted releases _completionSignal ---- - - /// - /// Test that MarkCompleted releases the completion signal (WaitForCompletionAsync unblocks). - /// Validates: Requirements 5.5, 8.3 - /// [Fact] public async Task MarkCompleted_ReleasesCompletionSignal() { @@ -195,12 +126,6 @@ public async Task MarkCompleted_ReleasesCompletionSignal() Assert.Same(waitTask, completed); } - // ---- Completion signaling: ReportErrorAsync releases _completionSignal ---- - - /// - /// Test that ReportErrorAsync releases the completion signal. - /// Validates: Requirements 5.5 - /// [Fact] public async Task ReportErrorAsync_ReleasesCompletionSignal() { @@ -216,12 +141,6 @@ public async Task ReportErrorAsync_ReleasesCompletionSignal() Assert.True(stream.HasError); } - // ---- Property 19: Writes After Completion Rejected ---- - - /// - /// Property 19: Writes After Completion Rejected — writes after MarkCompleted throw. - /// Validates: Requirements 8.8 - /// [Fact] public async Task WriteAsync_AfterMarkCompleted_Throws() { @@ -233,10 +152,6 @@ await Assert.ThrowsAsync( () => stream.WriteAsync(new byte[] { 2 }, 0, 1)); } - /// - /// Property 19: Writes After Completion Rejected — writes after ReportErrorAsync throw. - /// Validates: Requirements 8.8 - /// [Fact] public async Task WriteAsync_AfterReportError_Throws() { @@ -248,8 +163,6 @@ await Assert.ThrowsAsync( () => stream.WriteAsync(new byte[] { 2 }, 0, 1)); } - // ---- Error handling tests ---- - [Fact] public async Task ReportErrorAsync_SetsErrorState() { @@ -282,8 +195,6 @@ public async Task ReportErrorAsync_CalledTwice_Throws() () => stream.ReportError(new Exception("second"))); } - // ---- Argument validation ---- - [Fact] public async Task WriteAsync_NullBuffer_ThrowsArgumentNull() { @@ -308,8 +219,6 @@ public async Task ReportErrorAsync_NullException_ThrowsArgumentNull() Assert.Throws(() => stream.ReportError(null)); } - // ---- Dispose signals completion ---- - [Fact] public async Task Dispose_ReleasesCompletionSignalIfNotAlreadyReleased() { diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingE2EWithMoq.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingE2EWithMoq.cs index 2417b3ccb..14018e02b 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingE2EWithMoq.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingE2EWithMoq.cs @@ -57,7 +57,7 @@ private static Dictionary> MakeHeaders(string reques /// /// A capturing RuntimeApiClient that records the raw bytes written to the HTTP output stream - /// by SerializeToStreamAsync, enabling assertions on chunked-encoding format. + /// by SerializeToStreamAsync. /// private class CapturingStreamingRuntimeApiClient : RuntimeApiClient, IRuntimeApiClient { @@ -145,46 +145,6 @@ internal override async Task StartStreamingResponseAsync( private static CapturingStreamingRuntimeApiClient CreateClient(string requestId = "test-request-id") => new CapturingStreamingRuntimeApiClient(new TestEnvironmentVariables(), MakeHeaders(requestId)); - // ─── 10.1 End-to-end streaming response ───────────────────────────────────── - - /// - /// End-to-end: handler calls CreateStream, writes multiple chunks. - /// Verifies data flows through with correct chunked encoding and stream is finalized. - /// Requirements: 3.2, 4.3, 10.1 - /// - [Fact] - public async Task Streaming_MultipleChunks_FlowThroughWithChunkedEncoding() - { - var client = CreateClient(); - var chunks = new[] { "Hello", ", ", "World" }; - - LambdaBootstrapHandler handler = async (invocation) => - { - var stream = ResponseStreamFactory.CreateStream(Array.Empty()); - foreach (var chunk in chunks) - await stream.WriteAsync(Encoding.UTF8.GetBytes(chunk)); - return new InvocationResponse(Stream.Null, false); - }; - - using var bootstrap = new LambdaBootstrap(handler, null); - bootstrap.Client = client; - await bootstrap.InvokeOnceAsync(); - - Assert.True(client.StartStreamingCalled); - Assert.NotNull(client.CapturedHttpBytes); - - var output = Encoding.UTF8.GetString(client.CapturedHttpBytes); - - // Each chunk should appear as: hex-size\r\ndata\r\n - Assert.Contains("5\r\nHello\r\n", output); - Assert.Contains("2\r\n, \r\n", output); - Assert.Contains("5\r\nWorld\r\n", output); - - // Final chunk terminates the stream - Assert.Contains("0\r\n", output); - Assert.EndsWith("0\r\n\r\n", output); - } - /// /// End-to-end: all data is transmitted correctly (content round-trip). /// Requirements: 3.2, 4.3, 10.1 @@ -209,10 +169,7 @@ public async Task Streaming_AllDataTransmitted_ContentRoundTrip() var output = client.CapturedHttpBytes; Assert.NotNull(output); - // Decode the single chunk: hex-size\r\ndata\r\n var outputStr = Encoding.UTF8.GetString(output); - var hexSize = payload.Length.ToString("X"); - Assert.Contains(hexSize + "\r\n", outputStr); Assert.Contains("integration test payload", outputStr); } @@ -296,54 +253,6 @@ public async Task Buffered_ResponseBodyTransmittedCorrectly() Assert.Equal(responseBody, received.ToArray()); } - // ─── 10.3 Midstream error ──────────────────────────────────────────────────── - - /// - /// End-to-end: handler writes data then throws — error trailers appear after final chunk. - /// Requirements: 5.1, 5.2, 5.3, 5.6 - /// - [Fact] - public async Task MidstreamError_ErrorTrailersIncludedAfterFinalChunk() - { - var client = CreateClient(); - - LambdaBootstrapHandler handler = async (invocation) => - { - var stream = ResponseStreamFactory.CreateStream(Array.Empty()); - await stream.WriteAsync(Encoding.UTF8.GetBytes("partial data")); - throw new InvalidOperationException("midstream failure"); - }; - - using var bootstrap = new LambdaBootstrap(handler, null); - bootstrap.Client = client; - await bootstrap.InvokeOnceAsync(); - - Assert.True(client.StartStreamingCalled); - Assert.NotNull(client.CapturedHttpBytes); - - var output = Encoding.UTF8.GetString(client.CapturedHttpBytes); - - // Data chunk should be present - Assert.Contains("partial data", output); - - // Final chunk must appear - Assert.Contains("0\r\n", output); - - // Error trailers must appear after the final chunk - var finalChunkIdx = output.LastIndexOf("0\r\n"); - var errorTypeIdx = output.IndexOf(StreamingConstants.ErrorTypeTrailer + ":"); - var errorBodyIdx = output.IndexOf(StreamingConstants.ErrorBodyTrailer + ":"); - - Assert.True(errorTypeIdx > finalChunkIdx, "Error-Type trailer should appear after final chunk"); - Assert.True(errorBodyIdx > finalChunkIdx, "Error-Body trailer should appear after final chunk"); - - // Error type should reference the exception type - Assert.Contains("InvalidOperationException", output); - - // Standard error reporting should NOT be used (error went via trailers) - Assert.False(client.ReportInvocationErrorCalled); - } - /// /// End-to-end: error body trailer contains JSON with exception details. /// Requirements: 5.2, 5.3 diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingHttpContentTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingHttpContentTests.cs index bf87dd31a..21fe303b3 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingHttpContentTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingHttpContentTests.cs @@ -24,8 +24,6 @@ namespace Amazon.Lambda.RuntimeSupport.UnitTests { public class StreamingHttpContentTests { - private const long MaxResponseSize = 20 * 1024 * 1024; - /// /// Helper: runs SerializeToStreamAsync concurrently with handler actions. /// The handlerAction receives the ResponseStream and should write data then signal completion. @@ -53,13 +51,6 @@ private async Task SerializeWithConcurrentHandler( return outputStream.ToArray(); } - // ---- SerializeToStreamAsync hands off HTTP stream ---- - - /// - /// Test that SerializeToStreamAsync calls SetHttpOutputStream on the ResponseStream, - /// enabling writes to flow through. - /// Validates: Requirements 4.3, 10.1 - /// [Fact] public async Task SerializeToStreamAsync_HandsOffHttpStream_WritesFlowThrough() { @@ -71,16 +62,9 @@ public async Task SerializeToStreamAsync_HandsOffHttpStream_WritesFlowThrough() stream.MarkCompleted(); }); - var outputStr = Encoding.ASCII.GetString(output); - // Should contain the chunk data written by the handler - Assert.Contains("2\r\n", outputStr); - Assert.True(output.Length > 0); + Assert.Equal(2, output.Length); } - /// - /// Test that SerializeToStreamAsync blocks until MarkCompleted is called. - /// Validates: Requirements 4.3 - /// [Fact] public async Task SerializeToStreamAsync_BlocksUntilMarkCompleted() { @@ -101,10 +85,6 @@ public async Task SerializeToStreamAsync_BlocksUntilMarkCompleted() Assert.True(serializeTask.IsCompleted); } - /// - /// Test that SerializeToStreamAsync blocks until ReportErrorAsync is called. - /// Validates: Requirements 4.3, 5.1 - /// [Fact] public async Task SerializeToStreamAsync_BlocksUntilReportErrorAsync() { @@ -123,87 +103,6 @@ public async Task SerializeToStreamAsync_BlocksUntilReportErrorAsync() Assert.True(serializeTask.IsCompleted); } - // ---- Property 20: Final Chunk Termination ---- - - /// - /// Property 20: Final Chunk Termination — final chunk "0\r\n" is written after completion. - /// Validates: Requirements 4.3, 10.2, 10.3 - /// - [Fact] - public async Task FinalChunk_WrittenAfterCompletion() - { - var rs = new ResponseStream(Array.Empty()); - - var output = await SerializeWithConcurrentHandler(rs, async stream => - { - await stream.WriteAsync(new byte[] { 1 }); - stream.MarkCompleted(); - }); - - var outputStr = Encoding.ASCII.GetString(output); - Assert.Contains("0\r\n", outputStr); - - // Final chunk should appear after the data chunk - var dataChunkEnd = outputStr.IndexOf("1\r\n") + 3 + 1 + 2; // "1\r\n" + 1 byte data + "\r\n" - var finalChunkIndex = outputStr.IndexOf("0\r\n", dataChunkEnd); - Assert.True(finalChunkIndex >= 0, "Final chunk 0\\r\\n should appear after data chunks"); - } - - /// - /// Property 20: Final Chunk Termination — empty stream still gets final chunk. - /// Validates: Requirements 10.2 - /// - [Fact] - public async Task FinalChunk_EmptyStream_StillWritten() - { - var rs = new ResponseStream(Array.Empty()); - - var output = await SerializeWithConcurrentHandler(rs, stream => - { - stream.MarkCompleted(); - return Task.CompletedTask; - }); - - var outputStr = Encoding.ASCII.GetString(output); - Assert.StartsWith("0\r\n", outputStr); - } - - // ---- Property 21: Trailer Ordering ---- - - /// - /// Property 21: Trailer Ordering — trailers appear after final chunk. - /// Validates: Requirements 10.3 - /// - [Fact] - public async Task ErrorTrailers_AppearAfterFinalChunk() - { - var rs = new ResponseStream(Array.Empty()); - - var output = await SerializeWithConcurrentHandler(rs, async stream => - { - await stream.WriteAsync(new byte[] { 1 }); - stream.ReportError(new Exception("fail")); - }); - - var outputStr = Encoding.UTF8.GetString(output); - - // Find the final chunk "0\r\n" that appears after data chunks - var dataEnd = outputStr.IndexOf("1\r\n") + 3 + 1 + 2; - var finalChunkIndex = outputStr.IndexOf("0\r\n", dataEnd); - var errorTypeIndex = outputStr.IndexOf("Lambda-Runtime-Function-Error-Type:"); - var errorBodyIndex = outputStr.IndexOf("Lambda-Runtime-Function-Error-Body:"); - - Assert.True(finalChunkIndex >= 0, "Final chunk not found"); - Assert.True(errorTypeIndex > finalChunkIndex, "Error type trailer should appear after final chunk"); - Assert.True(errorBodyIndex > finalChunkIndex, "Error body trailer should appear after final chunk"); - } - - // ---- Property 11: Midstream Error Type Trailer ---- - - /// - /// Property 11: Midstream Error Type Trailer — error type trailer is included for various exception types. - /// Validates: Requirements 5.1, 5.2 - /// [Theory] [InlineData(typeof(InvalidOperationException))] [InlineData(typeof(ArgumentException))] @@ -223,12 +122,6 @@ public async Task ErrorTrailer_IncludesErrorType(Type exceptionType) Assert.Contains($"Lambda-Runtime-Function-Error-Type: {exceptionType.Name}", outputStr); } - // ---- Property 12: Midstream Error Body Trailer ---- - - /// - /// Property 12: Midstream Error Body Trailer — error body trailer includes JSON exception details. - /// Validates: Requirements 5.3 - /// [Fact] public async Task ErrorTrailer_IncludesJsonErrorBody() { @@ -246,32 +139,7 @@ public async Task ErrorTrailer_IncludesJsonErrorBody() Assert.Contains("InvalidOperationException", outputStr); } - // ---- Final CRLF termination ---- - - /// - /// Test that the chunked message ends with CRLF after successful completion (no trailers). - /// Validates: Requirements 10.2, 10.5 - /// - [Fact] - public async Task SuccessfulCompletion_EndsWithCrlf() - { - var rs = new ResponseStream(Array.Empty()); - - var output = await SerializeWithConcurrentHandler(rs, async stream => - { - await stream.WriteAsync(new byte[] { 1 }); - stream.MarkCompleted(); - }); - - var outputStr = Encoding.ASCII.GetString(output); - // Should end with "0\r\n" (final chunk) + "\r\n" (end of message) - Assert.EndsWith("0\r\n\r\n", outputStr); - } - /// - /// Test that the chunked message ends with CRLF after error trailers. - /// Validates: Requirements 10.3, 10.5 - /// [Fact] public async Task ErrorCompletion_EndsWithCrlf() { @@ -287,8 +155,6 @@ public async Task ErrorCompletion_EndsWithCrlf() Assert.EndsWith("\r\n", outputStr); } - // ---- No error, no trailers ---- - [Fact] public async Task NoError_NoTrailersWritten() { @@ -305,8 +171,6 @@ public async Task NoError_NoTrailersWritten() Assert.DoesNotContain("Lambda-Runtime-Function-Error-Body:", outputStr); } - // ---- TryComputeLength ---- - [Fact] public void TryComputeLength_ReturnsFalse() { @@ -316,32 +180,5 @@ public void TryComputeLength_ReturnsFalse() var result = content.Headers.ContentLength; Assert.Null(result); } - - // ---- CRLF correctness ---- - - /// - /// Property 22: CRLF Line Terminators — all line terminators are CRLF, not just LF. - /// Validates: Requirements 10.5 - /// - [Fact] - public async Task CrlfTerminators_NoBareLineFeed() - { - var rs = new ResponseStream(Array.Empty()); - - var output = await SerializeWithConcurrentHandler(rs, async stream => - { - await stream.WriteAsync(new byte[] { 65, 66, 67 }); // "ABC" - stream.MarkCompleted(); - }); - - for (int i = 0; i < output.Length; i++) - { - if (output[i] == (byte)'\n') - { - Assert.True(i > 0 && output[i - 1] == (byte)'\r', - $"Found bare LF at position {i} without preceding CR"); - } - } - } } } From ab93ce9513d46658f4e2847f6a4b21eb82e35eac Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Fri, 6 Mar 2026 14:04:59 -0800 Subject: [PATCH 15/20] Backfill tests after refactor --- .../LambdaResponseStreamingCoreTests.cs | 557 ++++++++++++++++++ .../ResponseStreamTests.cs | 130 +++- 2 files changed, 682 insertions(+), 5 deletions(-) create mode 100644 Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/LambdaResponseStreamingCoreTests.cs diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/LambdaResponseStreamingCoreTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/LambdaResponseStreamingCoreTests.cs new file mode 100644 index 000000000..6759627db --- /dev/null +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/LambdaResponseStreamingCoreTests.cs @@ -0,0 +1,557 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +#if NET8_0_OR_GREATER +#pragma warning disable CA2252 + +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Amazon.Lambda.Core.ResponseStreaming; +using Amazon.Lambda.RuntimeSupport.Client.ResponseStreaming; +using Xunit; + +namespace Amazon.Lambda.RuntimeSupport.UnitTests +{ + // ───────────────────────────────────────────────────────────────────────────── + // HttpResponseStreamPrelude.ToByteArray() tests + // ───────────────────────────────────────────────────────────────────────────── + + public class HttpResponseStreamPreludeTests + { + private static JsonDocument ParsePrelude(HttpResponseStreamPrelude prelude) + => JsonDocument.Parse(prelude.ToByteArray()); + + [Fact] + public void ToByteArray_EmptyPrelude_ProducesEmptyJsonObject() + { + var prelude = new HttpResponseStreamPrelude(); + var doc = ParsePrelude(prelude); + + Assert.Equal(JsonValueKind.Object, doc.RootElement.ValueKind); + // No properties should be present + Assert.False(doc.RootElement.TryGetProperty("statusCode", out _)); + Assert.False(doc.RootElement.TryGetProperty("headers", out _)); + Assert.False(doc.RootElement.TryGetProperty("multiValueHeaders", out _)); + Assert.False(doc.RootElement.TryGetProperty("cookies", out _)); + } + + [Fact] + public void ToByteArray_WithStatusCode_IncludesStatusCode() + { + var prelude = new HttpResponseStreamPrelude { StatusCode = HttpStatusCode.OK }; + var doc = ParsePrelude(prelude); + + Assert.True(doc.RootElement.TryGetProperty("statusCode", out var sc)); + Assert.Equal(200, sc.GetInt32()); + } + + [Fact] + public void ToByteArray_WithHeaders_IncludesHeaders() + { + var prelude = new HttpResponseStreamPrelude + { + Headers = new Dictionary + { + ["Content-Type"] = "application/json", + ["X-Custom"] = "value" + } + }; + var doc = ParsePrelude(prelude); + + Assert.True(doc.RootElement.TryGetProperty("headers", out var headers)); + Assert.Equal("application/json", headers.GetProperty("Content-Type").GetString()); + Assert.Equal("value", headers.GetProperty("X-Custom").GetString()); + } + + [Fact] + public void ToByteArray_WithMultiValueHeaders_IncludesMultiValueHeaders() + { + var prelude = new HttpResponseStreamPrelude + { + MultiValueHeaders = new Dictionary> + { + ["Set-Cookie"] = new List { "a=1", "b=2" } + } + }; + var doc = ParsePrelude(prelude); + + Assert.True(doc.RootElement.TryGetProperty("multiValueHeaders", out var mvh)); + var cookies = mvh.GetProperty("Set-Cookie"); + Assert.Equal(JsonValueKind.Array, cookies.ValueKind); + Assert.Equal(2, cookies.GetArrayLength()); + } + + [Fact] + public void ToByteArray_WithCookies_IncludesCookies() + { + var prelude = new HttpResponseStreamPrelude + { + Cookies = new List { "session=abc", "pref=dark" } + }; + var doc = ParsePrelude(prelude); + + Assert.True(doc.RootElement.TryGetProperty("cookies", out var cookies)); + Assert.Equal(JsonValueKind.Array, cookies.ValueKind); + Assert.Equal(2, cookies.GetArrayLength()); + Assert.Equal("session=abc", cookies[0].GetString()); + } + + [Fact] + public void ToByteArray_AllFieldsPopulated_ProducesCorrectJson() + { + var prelude = new HttpResponseStreamPrelude + { + StatusCode = HttpStatusCode.Created, + Headers = new Dictionary { ["X-Req"] = "1" }, + MultiValueHeaders = new Dictionary> { ["X-Multi"] = new List { "a", "b" } }, + Cookies = new List { "c=1" } + }; + var doc = ParsePrelude(prelude); + + Assert.Equal(201, doc.RootElement.GetProperty("statusCode").GetInt32()); + Assert.Equal("1", doc.RootElement.GetProperty("headers").GetProperty("X-Req").GetString()); + Assert.Equal(2, doc.RootElement.GetProperty("multiValueHeaders").GetProperty("X-Multi").GetArrayLength()); + Assert.Equal("c=1", doc.RootElement.GetProperty("cookies")[0].GetString()); + } + + [Fact] + public void ToByteArray_EmptyCollections_OmitsThoseFields() + { + var prelude = new HttpResponseStreamPrelude + { + StatusCode = HttpStatusCode.OK, + Headers = new Dictionary(), // empty — should be omitted + MultiValueHeaders = new Dictionary>(), // empty + Cookies = new List() // empty + }; + var doc = ParsePrelude(prelude); + + Assert.True(doc.RootElement.TryGetProperty("statusCode", out _)); + Assert.False(doc.RootElement.TryGetProperty("headers", out _)); + Assert.False(doc.RootElement.TryGetProperty("multiValueHeaders", out _)); + Assert.False(doc.RootElement.TryGetProperty("cookies", out _)); + } + + [Fact] + public void ToByteArray_ProducesValidUtf8() + { + var prelude = new HttpResponseStreamPrelude + { + StatusCode = HttpStatusCode.OK, + Headers = new Dictionary { ["Content-Type"] = "text/plain; charset=utf-8" } + }; + var bytes = prelude.ToByteArray(); + + // Should not throw + var text = Encoding.UTF8.GetString(bytes); + Assert.NotEmpty(text); + } + } + + // ───────────────────────────────────────────────────────────────────────────── + // LambdaResponseStream (Stream subclass) tests + // ───────────────────────────────────────────────────────────────────────────── + + public class LambdaResponseStreamTests + { + /// + /// Creates a LambdaResponseStream backed by a real ResponseStream wired to a MemoryStream. + /// + private static async Task<(LambdaResponseStream lambdaStream, MemoryStream httpOutput)> CreateWiredLambdaStream() + { + var inner = new ResponseStream(Array.Empty()); + var output = new MemoryStream(); + await inner.SetHttpOutputStreamAsync(output); + + var implStream = new ResponseStreamLambdaCoreInitializerIsolated.ImplLambdaResponseStream(inner); + var lambdaStream = new LambdaResponseStream(implStream); + return (lambdaStream, output); + } + + [Fact] + public void LambdaResponseStream_IsStreamSubclass() + { + var inner = new ResponseStream(Array.Empty()); + var impl = new ResponseStreamLambdaCoreInitializerIsolated.ImplLambdaResponseStream(inner); + var stream = new LambdaResponseStream(impl); + + Assert.IsAssignableFrom(stream); + } + + [Fact] + public void CanWrite_IsTrue() + { + var inner = new ResponseStream(Array.Empty()); + var impl = new ResponseStreamLambdaCoreInitializerIsolated.ImplLambdaResponseStream(inner); + var stream = new LambdaResponseStream(impl); + + Assert.True(stream.CanWrite); + } + + [Fact] + public void CanRead_IsFalse() + { + var inner = new ResponseStream(Array.Empty()); + var impl = new ResponseStreamLambdaCoreInitializerIsolated.ImplLambdaResponseStream(inner); + var stream = new LambdaResponseStream(impl); + + Assert.False(stream.CanRead); + } + + [Fact] + public void CanSeek_IsFalse() + { + var inner = new ResponseStream(Array.Empty()); + var impl = new ResponseStreamLambdaCoreInitializerIsolated.ImplLambdaResponseStream(inner); + var stream = new LambdaResponseStream(impl); + + Assert.False(stream.CanSeek); + } + + [Fact] + public void Read_ThrowsNotImplementedException() + { + var inner = new ResponseStream(Array.Empty()); + var impl = new ResponseStreamLambdaCoreInitializerIsolated.ImplLambdaResponseStream(inner); + var stream = new LambdaResponseStream(impl); + + Assert.Throws(() => stream.Read(new byte[1], 0, 1)); + } + + [Fact] + public void ReadAsync_ThrowsNotImplementedException() + { + var inner = new ResponseStream(Array.Empty()); + var impl = new ResponseStreamLambdaCoreInitializerIsolated.ImplLambdaResponseStream(inner); + var stream = new LambdaResponseStream(impl); + + // ReadAsync throws synchronously (not async) — capture the thrown task + var ex = Assert.Throws( + () => { var _ = stream.ReadAsync(new byte[1], 0, 1, CancellationToken.None); }); + Assert.NotNull(ex); + } + + [Fact] + public void Seek_ThrowsNotImplementedException() + { + var inner = new ResponseStream(Array.Empty()); + var impl = new ResponseStreamLambdaCoreInitializerIsolated.ImplLambdaResponseStream(inner); + var stream = new LambdaResponseStream(impl); + + Assert.Throws(() => stream.Seek(0, SeekOrigin.Begin)); + } + + [Fact] + public void Position_Get_ThrowsNotSupportedException() + { + var inner = new ResponseStream(Array.Empty()); + var impl = new ResponseStreamLambdaCoreInitializerIsolated.ImplLambdaResponseStream(inner); + var stream = new LambdaResponseStream(impl); + + Assert.Throws(() => _ = stream.Position); + } + + [Fact] + public void Position_Set_ThrowsNotSupportedException() + { + var inner = new ResponseStream(Array.Empty()); + var impl = new ResponseStreamLambdaCoreInitializerIsolated.ImplLambdaResponseStream(inner); + var stream = new LambdaResponseStream(impl); + + Assert.Throws(() => stream.Position = 0); + } + + [Fact] + public void SetLength_ThrowsNotSupportedException() + { + var inner = new ResponseStream(Array.Empty()); + var impl = new ResponseStreamLambdaCoreInitializerIsolated.ImplLambdaResponseStream(inner); + var stream = new LambdaResponseStream(impl); + + Assert.Throws(() => stream.SetLength(100)); + } + + [Fact] + public async Task WriteAsync_WritesRawBytesToHttpStream() + { + var (stream, output) = await CreateWiredLambdaStream(); + var data = Encoding.UTF8.GetBytes("hello streaming"); + + await stream.WriteAsync(data, 0, data.Length); + + Assert.Equal(data, output.ToArray()); + } + + [Fact] + public async Task Write_SyncOverload_WritesRawBytes() + { + var (stream, output) = await CreateWiredLambdaStream(); + var data = new byte[] { 1, 2, 3 }; + + stream.Write(data, 0, data.Length); + + Assert.Equal(data, output.ToArray()); + } + + [Fact] + public async Task Length_ReflectsBytesWritten() + { + var (stream, _) = await CreateWiredLambdaStream(); + var data = new byte[42]; + + await stream.WriteAsync(data, 0, data.Length); + + Assert.Equal(42, stream.Length); + Assert.Equal(42, stream.BytesWritten); + } + + [Fact] + public async Task Flush_IsNoOp() + { + var (stream, _) = await CreateWiredLambdaStream(); + // Should not throw + stream.Flush(); + } + + [Fact] + public async Task WriteAsync_ByteArrayOverload_WritesFullArray() + { + var (stream, output) = await CreateWiredLambdaStream(); + var data = new byte[] { 0xDE, 0xAD, 0xBE, 0xEF }; + + await stream.WriteAsync(data); + + Assert.Equal(data, output.ToArray()); + } + } + + // ───────────────────────────────────────────────────────────────────────────── + // ImplLambdaResponseStream (bridge class) tests + // ───────────────────────────────────────────────────────────────────────────── + + public class ImplLambdaResponseStreamTests + { + [Fact] + public async Task WriteAsync_DelegatesToInnerResponseStream() + { + var inner = new ResponseStream(Array.Empty()); + var output = new MemoryStream(); + await inner.SetHttpOutputStreamAsync(output); + + var impl = new ResponseStreamLambdaCoreInitializerIsolated.ImplLambdaResponseStream(inner); + var data = new byte[] { 1, 2, 3 }; + + await impl.WriteAsync(data, 0, data.Length); + + Assert.Equal(data, output.ToArray()); + } + + [Fact] + public async Task BytesWritten_ReflectsInnerStreamBytesWritten() + { + var inner = new ResponseStream(Array.Empty()); + var output = new MemoryStream(); + await inner.SetHttpOutputStreamAsync(output); + + var impl = new ResponseStreamLambdaCoreInitializerIsolated.ImplLambdaResponseStream(inner); + await impl.WriteAsync(new byte[7], 0, 7); + + Assert.Equal(7, impl.BytesWritten); + } + + [Fact] + public void HasError_InitiallyFalse() + { + var inner = new ResponseStream(Array.Empty()); + var impl = new ResponseStreamLambdaCoreInitializerIsolated.ImplLambdaResponseStream(inner); + + Assert.False(impl.HasError); + } + + [Fact] + public void HasError_TrueAfterReportError() + { + var inner = new ResponseStream(Array.Empty()); + inner.ReportError(new Exception("test")); + + var impl = new ResponseStreamLambdaCoreInitializerIsolated.ImplLambdaResponseStream(inner); + + Assert.True(impl.HasError); + } + + [Fact] + public void Dispose_DisposesInnerStream() + { + var inner = new ResponseStream(Array.Empty()); + var impl = new ResponseStreamLambdaCoreInitializerIsolated.ImplLambdaResponseStream(inner); + + // Should not throw + impl.Dispose(); + } + } + + // ───────────────────────────────────────────────────────────────────────────── + // LambdaResponseStreamFactory tests + // ───────────────────────────────────────────────────────────────────────────── + + [Collection("ResponseStreamFactory")] + public class LambdaResponseStreamFactoryTests : IDisposable + { + + public LambdaResponseStreamFactoryTests() + { + // Wire up the factory via the initializer (same as production bootstrap does) + ResponseStreamLambdaCoreInitializerIsolated.InitializeCore(); + } + + public void Dispose() + { + ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: false); + } + + private void InitializeInvocation(string requestId = "test-req") + { + var envVars = new TestEnvironmentVariables(); + var client = new NoOpStreamingRuntimeApiClient(envVars); + ResponseStreamFactory.InitializeInvocation(requestId, false, client, CancellationToken.None); + } + + /// + /// Minimal RuntimeApiClient that accepts StartStreamingResponseAsync without real HTTP. + /// + private class NoOpStreamingRuntimeApiClient : RuntimeApiClient + { + public NoOpStreamingRuntimeApiClient(IEnvironmentVariables envVars) + : base(envVars, new TestHelpers.NoOpInternalRuntimeApiClient()) { } + + internal override async Task StartStreamingResponseAsync( + string awsRequestId, ResponseStream responseStream, CancellationToken cancellationToken = default) + { + // Provide the HTTP output stream so writes don't block + await responseStream.SetHttpOutputStreamAsync(new MemoryStream(), cancellationToken); + await responseStream.WaitForCompletionAsync(cancellationToken); + } + } + + [Fact] + public void CreateStream_ReturnsLambdaResponseStream() + { + InitializeInvocation(); + + var stream = LambdaResponseStreamFactory.CreateStream(); + + Assert.NotNull(stream); + Assert.IsType(stream); + } + + [Fact] + public void CreateStream_ReturnsStreamSubclass() + { + InitializeInvocation(); + + var stream = LambdaResponseStreamFactory.CreateStream(); + + Assert.IsAssignableFrom(stream); + } + + [Fact] + public void CreateStream_ReturnedStream_IsWritable() + { + InitializeInvocation(); + + var stream = LambdaResponseStreamFactory.CreateStream(); + + Assert.True(stream.CanWrite); + } + + [Fact] + public void CreateStream_ReturnedStream_IsNotSeekable() + { + InitializeInvocation(); + + var stream = LambdaResponseStreamFactory.CreateStream(); + + Assert.False(stream.CanSeek); + } + + [Fact] + public void CreateStream_ReturnedStream_IsNotReadable() + { + InitializeInvocation(); + + var stream = LambdaResponseStreamFactory.CreateStream(); + + Assert.False(stream.CanRead); + } + + [Fact] + public void CreateHttpStream_WithPrelude_ReturnsLambdaResponseStream() + { + InitializeInvocation(); + + var prelude = new HttpResponseStreamPrelude { StatusCode = HttpStatusCode.OK }; + var stream = LambdaResponseStreamFactory.CreateHttpStream(prelude); + + Assert.NotNull(stream); + Assert.IsType(stream); + } + + [Fact] + public void CreateHttpStream_PassesSerializedPreludeToFactory() + { + // Capture the prelude bytes passed to the inner factory + byte[] capturedPrelude = null; + LambdaResponseStreamFactory.SetLambdaResponseStream(prelude => + { + capturedPrelude = prelude; + // Return a minimal stub that satisfies the interface + return new StubLambdaResponseStream(); + }); + + var httpPrelude = new HttpResponseStreamPrelude + { + StatusCode = HttpStatusCode.Created, + Headers = new Dictionary { ["X-Test"] = "1" } + }; + LambdaResponseStreamFactory.CreateHttpStream(httpPrelude); + + Assert.NotNull(capturedPrelude); + Assert.True(capturedPrelude.Length > 0); + + // Verify the bytes are valid JSON containing the status code + var doc = JsonDocument.Parse(capturedPrelude); + Assert.Equal(201, doc.RootElement.GetProperty("statusCode").GetInt32()); + } + + [Fact] + public void CreateStream_PassesEmptyPreludeToFactory() + { + byte[] capturedPrelude = null; + LambdaResponseStreamFactory.SetLambdaResponseStream(prelude => + { + capturedPrelude = prelude; + return new StubLambdaResponseStream(); + }); + + LambdaResponseStreamFactory.CreateStream(); + + Assert.NotNull(capturedPrelude); + Assert.Empty(capturedPrelude); + } + + private class StubLambdaResponseStream : ILambdaResponseStream + { + public long BytesWritten => 0; + public bool HasError => false; + public void Dispose() { } + public Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken = default) + => Task.CompletedTask; + } + } +} +#endif diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs index 1aa2eb10c..517f2b8da 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs @@ -51,18 +51,16 @@ public void Constructor_InitializesStateCorrectly() } [Fact] - public async Task WriteAsync_WithOffset_WritesCorrectSliceAsChunk() + public async Task WriteAsync_WithOffset_WritesCorrectSlice() { var (stream, httpOutput) = await CreateWiredStream(); var data = new byte[] { 0, 1, 2, 3, 0 }; await stream.WriteAsync(data, 1, 3); - var written = httpOutput.ToArray(); - // 3 bytes → hex "3", data is {1,2,3} + // Raw bytes {1,2,3} written directly — no chunked encoding var expected = new byte[] { 1, 2, 3 }; - - Assert.Equal(expected, written); + Assert.Equal(expected, httpOutput.ToArray()); } [Fact] @@ -232,5 +230,127 @@ public async Task Dispose_ReleasesCompletionSignalIfNotAlreadyReleased() var completed = await Task.WhenAny(waitTask, Task.Delay(TimeSpan.FromSeconds(2))); Assert.Same(waitTask, completed); } + + [Fact] + public async Task Dispose_CalledTwice_DoesNotThrow() + { + var stream = new ResponseStream(Array.Empty()); + stream.Dispose(); + // Second dispose should be a no-op + stream.Dispose(); + } + + // ---- Prelude tests ---- + + [Fact] + public async Task SetHttpOutputStreamAsync_WithPrelude_WritesPreludeBeforeHandlerData() + { + var prelude = new byte[] { 0x01, 0x02, 0x03 }; + var rs = new ResponseStream(prelude); + var output = new MemoryStream(); + + await rs.SetHttpOutputStreamAsync(output); + + // Prelude bytes + 8-byte null delimiter should be written before any handler data + var written = output.ToArray(); + Assert.True(written.Length >= prelude.Length + 8, "Prelude + delimiter should be written"); + Assert.Equal(prelude, written[..prelude.Length]); + Assert.Equal(new byte[8], written[prelude.Length..(prelude.Length + 8)]); + } + + [Fact] + public async Task SetHttpOutputStreamAsync_WithEmptyPrelude_WritesNoPreludeBytes() + { + var rs = new ResponseStream(Array.Empty()); + var output = new MemoryStream(); + + await rs.SetHttpOutputStreamAsync(output); + + // Empty prelude — nothing written yet (handler hasn't written anything) + Assert.Empty(output.ToArray()); + } + + [Fact] + public async Task SetHttpOutputStreamAsync_WithPrelude_HandlerDataAppendsAfterDelimiter() + { + var prelude = new byte[] { 0xAA, 0xBB }; + var rs = new ResponseStream(prelude); + var output = new MemoryStream(); + + await rs.SetHttpOutputStreamAsync(output); + await rs.WriteAsync(new byte[] { 0xFF }, 0, 1); + + var written = output.ToArray(); + // Layout: [prelude][8 null bytes][handler data] + int expectedMinLength = prelude.Length + 8 + 1; + Assert.Equal(expectedMinLength, written.Length); + Assert.Equal(new byte[] { 0xFF }, written[^1..]); + } + + [Fact] + public async Task SetHttpOutputStreamAsync_NullPrelude_WritesNoPreludeBytes() + { + var rs = new ResponseStream(null); + var output = new MemoryStream(); + + await rs.SetHttpOutputStreamAsync(output); + + Assert.Empty(output.ToArray()); + } + + // ---- MarkCompleted idempotency ---- + + [Fact] + public async Task MarkCompleted_CalledTwice_DoesNotThrowOrDoubleRelease() + { + var (stream, _) = await CreateWiredStream(); + + stream.MarkCompleted(); + // Second call should be a no-op — semaphore should not be double-released + stream.MarkCompleted(); + + // WaitForCompletionAsync should complete exactly once without hanging + var waitTask = stream.WaitForCompletionAsync(); + var completed = await Task.WhenAny(waitTask, Task.Delay(TimeSpan.FromSeconds(2))); + Assert.Same(waitTask, completed); + } + + [Fact] + public async Task ReportError_ThenMarkCompleted_MarkCompletedIsNoOp() + { + var stream = new ResponseStream(Array.Empty()); + stream.ReportError(new Exception("error")); + + // MarkCompleted after ReportError should not throw and not double-release + stream.MarkCompleted(); + + // WaitForCompletionAsync should complete (released by ReportError) + var waitTask = stream.WaitForCompletionAsync(); + var completed = await Task.WhenAny(waitTask, Task.Delay(TimeSpan.FromSeconds(2))); + Assert.Same(waitTask, completed); + } + + // ---- BytesWritten tracking ---- + + [Fact] + public async Task BytesWritten_TracksAcrossMultipleWrites() + { + var (stream, _) = await CreateWiredStream(); + + await stream.WriteAsync(new byte[10], 0, 10); + await stream.WriteAsync(new byte[5], 0, 5); + + Assert.Equal(15, stream.BytesWritten); + } + + [Fact] + public async Task BytesWritten_ReflectsOffsetAndCount() + { + var (stream, _) = await CreateWiredStream(); + + await stream.WriteAsync(new byte[10], 2, 6); // only 6 bytes + + Assert.Equal(6, stream.BytesWritten); + } } } From d60bb933005faa00461ccdedeaf53b86afe8ce17 Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Wed, 11 Mar 2026 00:24:57 -0700 Subject: [PATCH 16/20] Add integ tests --- .gitignore | 2 + Libraries/Libraries.sln | 19 ++- ...bda.RuntimeSupport.IntegrationTests.csproj | 18 +-- .../BaseCustomRuntimeTest.cs | 8 +- .../CustomRuntimeTests.cs | 2 +- .../Helpers/CommandLineWrapper.cs | 6 +- .../Helpers/LambdaToolsHelper.cs | 6 +- .../IntegrationTestCollection.cs | 4 +- .../IntegrationTestFixture.cs | 14 +- .../ResponseStreamingTests.cs | 136 ++++++++++++++++++ .../Function.cs | 56 ++++++++ .../ResponseStreamingFunctionHandlers.csproj | 19 +++ .../aws-lambda-tools-defaults.json | 15 ++ 13 files changed, 282 insertions(+), 23 deletions(-) create mode 100644 Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/ResponseStreamingTests.cs create mode 100644 Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/ResponseStreamingFunctionHandlers/Function.cs create mode 100644 Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/ResponseStreamingFunctionHandlers/ResponseStreamingFunctionHandlers.csproj create mode 100644 Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/ResponseStreamingFunctionHandlers/aws-lambda-tools-defaults.json diff --git a/.gitignore b/.gitignore index f91715274..1caae6fe4 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ *.suo *.user +**/.kiro/ + #################### # Build/Test folders #################### diff --git a/Libraries/Libraries.sln b/Libraries/Libraries.sln index f3214606a..23840bdfa 100644 --- a/Libraries/Libraries.sln +++ b/Libraries/Libraries.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.31717.71 +# Visual Studio Version 18 +VisualStudioVersion = 18.3.11512.155 d18.3 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{AAB54E74-20B1-42ED-BC3D-CE9F7BC7FD12}" EndProject @@ -151,6 +151,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestCustomAuthorizerApp.Int EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestCustomAuthorizerApp", "test\TestCustomAuthorizerApp\TestCustomAuthorizerApp.csproj", "{3BFA4B73-BA61-4578-833B-C5B3A16EDA9E}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ResponseStreamingFunctionHandlers", "test\Amazon.Lambda.RuntimeSupport.Tests\ResponseStreamingFunctionHandlers\ResponseStreamingFunctionHandlers.csproj", "{E404A7AC-812B-BC03-CA76-02C0BC2BA7F9}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -941,6 +943,18 @@ Global {3BFA4B73-BA61-4578-833B-C5B3A16EDA9E}.Release|x64.Build.0 = Release|Any CPU {3BFA4B73-BA61-4578-833B-C5B3A16EDA9E}.Release|x86.ActiveCfg = Release|Any CPU {3BFA4B73-BA61-4578-833B-C5B3A16EDA9E}.Release|x86.Build.0 = Release|Any CPU + {E404A7AC-812B-BC03-CA76-02C0BC2BA7F9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E404A7AC-812B-BC03-CA76-02C0BC2BA7F9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E404A7AC-812B-BC03-CA76-02C0BC2BA7F9}.Debug|x64.ActiveCfg = Debug|Any CPU + {E404A7AC-812B-BC03-CA76-02C0BC2BA7F9}.Debug|x64.Build.0 = Debug|Any CPU + {E404A7AC-812B-BC03-CA76-02C0BC2BA7F9}.Debug|x86.ActiveCfg = Debug|Any CPU + {E404A7AC-812B-BC03-CA76-02C0BC2BA7F9}.Debug|x86.Build.0 = Debug|Any CPU + {E404A7AC-812B-BC03-CA76-02C0BC2BA7F9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E404A7AC-812B-BC03-CA76-02C0BC2BA7F9}.Release|Any CPU.Build.0 = Release|Any CPU + {E404A7AC-812B-BC03-CA76-02C0BC2BA7F9}.Release|x64.ActiveCfg = Release|Any CPU + {E404A7AC-812B-BC03-CA76-02C0BC2BA7F9}.Release|x64.Build.0 = Release|Any CPU + {E404A7AC-812B-BC03-CA76-02C0BC2BA7F9}.Release|x86.ActiveCfg = Release|Any CPU + {E404A7AC-812B-BC03-CA76-02C0BC2BA7F9}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1015,6 +1029,7 @@ Global {8D03BDF3-7078-4B46-A3F1-C73BE6D6CE0D} = {1DE4EE60-45BA-4EF7-BE00-B9EB861E4C69} {8EEDD576-7FC4-4FAC-A5A2-F58562753A53} = {1DE4EE60-45BA-4EF7-BE00-B9EB861E4C69} {3BFA4B73-BA61-4578-833B-C5B3A16EDA9E} = {1DE4EE60-45BA-4EF7-BE00-B9EB861E4C69} + {E404A7AC-812B-BC03-CA76-02C0BC2BA7F9} = {B5BD0336-7D08-492C-8489-42C987E29B39} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {503678A4-B8D1-4486-8915-405A3E9CF0EB} diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/Amazon.Lambda.RuntimeSupport.IntegrationTests.csproj b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/Amazon.Lambda.RuntimeSupport.IntegrationTests.csproj index 86a3b5c1e..d206a1f1c 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/Amazon.Lambda.RuntimeSupport.IntegrationTests.csproj +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/Amazon.Lambda.RuntimeSupport.IntegrationTests.csproj @@ -1,7 +1,7 @@  - net8.0 + net10.0 @@ -19,19 +19,19 @@ - - - - - - + + + + + + all runtime; build; native; contentfiles; analyzers - + - + diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/BaseCustomRuntimeTest.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/BaseCustomRuntimeTest.cs index c220a671e..fa10cee7d 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/BaseCustomRuntimeTest.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/BaseCustomRuntimeTest.cs @@ -17,7 +17,7 @@ public class BaseCustomRuntimeTest { public const int FUNCTION_MEMORY_MB = 512; - protected static readonly RegionEndpoint TestRegion = RegionEndpoint.USWest2; + public static readonly RegionEndpoint TestRegion = RegionEndpoint.USWest2; protected static readonly string LAMBDA_ASSUME_ROLE_POLICY = @" { @@ -63,7 +63,7 @@ protected BaseCustomRuntimeTest(IntegrationTestFixture fixture, string functionN /// /// /// - protected async Task CleanUpTestResources(AmazonS3Client s3Client, AmazonLambdaClient lambdaClient, + public async Task CleanUpTestResources(AmazonS3Client s3Client, AmazonLambdaClient lambdaClient, AmazonIdentityManagementServiceClient iamClient, bool roleAlreadyExisted) { await DeleteFunctionIfExistsAsync(lambdaClient); @@ -109,7 +109,7 @@ await iamClient.DetachRolePolicyAsync(new DetachRolePolicyRequest } } - protected async Task PrepareTestResources(IAmazonS3 s3Client, IAmazonLambda lambdaClient, + public async Task PrepareTestResources(IAmazonS3 s3Client, IAmazonLambda lambdaClient, AmazonIdentityManagementServiceClient iamClient) { var roleAlreadyExisted = await ValidateAndSetIamRoleArn(iamClient); @@ -288,7 +288,7 @@ protected async Task CreateFunctionAsync(IAmazonLambda lambdaClient, string buck Handler = Handler, MemorySize = FUNCTION_MEMORY_MB, Timeout = 30, - Runtime = Runtime.Dotnet6, + Runtime = Runtime.Dotnet10, Role = ExecutionRoleArn }; diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/CustomRuntimeTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/CustomRuntimeTests.cs index b548d5ba0..8ab008d66 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/CustomRuntimeTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/CustomRuntimeTests.cs @@ -48,7 +48,7 @@ public async Task TestAllNET8HandlersAsync() public class CustomRuntimeTests : BaseCustomRuntimeTest { - public enum TargetFramework { NET6, NET8} + public enum TargetFramework { NET8 } private TargetFramework _targetFramework; diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/Helpers/CommandLineWrapper.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/Helpers/CommandLineWrapper.cs index aa8651eae..e18f31833 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/Helpers/CommandLineWrapper.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/Helpers/CommandLineWrapper.cs @@ -1,5 +1,6 @@ using System; using System.Diagnostics; +using System.Text; using System.Threading; using System.Threading.Tasks; using Xunit; @@ -31,6 +32,7 @@ public static async Task Run(string command, string arguments, string workingDir tcs.TrySetResult(true); }; + var output = new StringBuilder(); try { // Attach event handlers @@ -39,6 +41,7 @@ public static async Task Run(string command, string arguments, string workingDir if (!string.IsNullOrEmpty(args.Data)) { Console.WriteLine(args.Data); + output.Append(args.Data); } }; @@ -47,6 +50,7 @@ public static async Task Run(string command, string arguments, string workingDir if (!string.IsNullOrEmpty(args.Data)) { Console.WriteLine(args.Data); + output.Append(args.Data); } }; @@ -87,4 +91,4 @@ public static async Task Run(string command, string arguments, string workingDir Assert.True(process.ExitCode == 0, $"Command '{command} {arguments}' failed."); } } -} \ No newline at end of file +} diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/Helpers/LambdaToolsHelper.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/Helpers/LambdaToolsHelper.cs index 42a02aac6..154c84f75 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/Helpers/LambdaToolsHelper.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/Helpers/LambdaToolsHelper.cs @@ -10,6 +10,9 @@ public static class LambdaToolsHelper public static string GetTempTestAppDirectory(string workingDirectory, string testAppPath) { +#if DEBUG + return Path.GetFullPath(Path.Combine(workingDirectory, testAppPath)); +#else var customTestAppPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); Directory.CreateDirectory(customTestAppPath); @@ -17,6 +20,7 @@ public static string GetTempTestAppDirectory(string workingDirectory, string tes CopyDirectory(currentDir, customTestAppPath); return Path.Combine(customTestAppPath, testAppPath); +#endif } public static async Task InstallLambdaTools() @@ -78,4 +82,4 @@ private static void CopyDirectory(DirectoryInfo dir, string destDirName) CopyDirectory(subDir, tempPath); } } -} \ No newline at end of file +} diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/IntegrationTestCollection.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/IntegrationTestCollection.cs index c9ce90e35..9b637b547 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/IntegrationTestCollection.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/IntegrationTestCollection.cs @@ -3,7 +3,7 @@ namespace Amazon.Lambda.RuntimeSupport.IntegrationTests; [CollectionDefinition("Integration Tests")] -public class IntegrationTestCollection : ICollectionFixture +public class IntegrationTestCollection : ICollectionFixture, ICollectionFixture { -} \ No newline at end of file +} diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/IntegrationTestFixture.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/IntegrationTestFixture.cs index 89d62d61f..ee63888c0 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/IntegrationTestFixture.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/IntegrationTestFixture.cs @@ -14,10 +14,11 @@ public class IntegrationTestFixture : IAsyncLifetime public async Task InitializeAsync() { + var toolPath = await LambdaToolsHelper.InstallLambdaTools(); + var testAppPath = LambdaToolsHelper.GetTempTestAppDirectory( "../../../../../../..", "Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/CustomRuntimeFunctionTest"); - var toolPath = await LambdaToolsHelper.InstallLambdaTools(); _tempPaths.AddRange([testAppPath, toolPath] ); await LambdaToolsHelper.LambdaPackage(toolPath, "net8.0", testAppPath); TestAppPaths[@"CustomRuntimeFunctionTest\bin\Release\net8.0\CustomRuntimeFunctionTest.zip"] = Path.Combine(testAppPath, @"bin\Release\net8.0\CustomRuntimeFunctionTest.zip"); @@ -25,7 +26,6 @@ public async Task InitializeAsync() testAppPath = LambdaToolsHelper.GetTempTestAppDirectory( "../../../../../../..", "Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/CustomRuntimeAspNetCoreMinimalApiTest"); - toolPath = await LambdaToolsHelper.InstallLambdaTools(); _tempPaths.AddRange([testAppPath, toolPath] ); await LambdaToolsHelper.LambdaPackage(toolPath, "net8.0", testAppPath); TestAppPaths[@"CustomRuntimeAspNetCoreMinimalApiTest\bin\Release\net8.0\CustomRuntimeAspNetCoreMinimalApiTest.zip"] = Path.Combine(testAppPath, @"bin\Release\net8.0\CustomRuntimeAspNetCoreMinimalApiTest.zip"); @@ -33,19 +33,27 @@ public async Task InitializeAsync() testAppPath = LambdaToolsHelper.GetTempTestAppDirectory( "../../../../../../..", "Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/CustomRuntimeAspNetCoreMinimalApiCustomSerializerTest"); - toolPath = await LambdaToolsHelper.InstallLambdaTools(); _tempPaths.AddRange([testAppPath, toolPath] ); await LambdaToolsHelper.LambdaPackage(toolPath, "net8.0", testAppPath); TestAppPaths[@"CustomRuntimeAspNetCoreMinimalApiCustomSerializerTest\bin\Release\net8.0\CustomRuntimeAspNetCoreMinimalApiCustomSerializerTest.zip"] = Path.Combine(testAppPath, @"bin\Release\net8.0\CustomRuntimeAspNetCoreMinimalApiCustomSerializerTest.zip"); + + testAppPath = LambdaToolsHelper.GetTempTestAppDirectory( + "../../../../../../..", + "Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/ResponseStreamingFunctionHandlers"); + _tempPaths.AddRange([testAppPath, toolPath]); + await LambdaToolsHelper.LambdaPackage(toolPath, "net10.0", testAppPath); + TestAppPaths[@"ResponseStreamingFunctionHandlers\bin\Release\net10.0\ResponseStreamingFunctionHandlers.zip"] = Path.Combine(testAppPath, @"bin\Release\net10.0\ResponseStreamingFunctionHandlers.zip"); } public Task DisposeAsync() { +#if !DEBUG foreach (var tempPath in _tempPaths) { LambdaToolsHelper.CleanUp(tempPath); } +#endif return Task.CompletedTask; } diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/ResponseStreamingTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/ResponseStreamingTests.cs new file mode 100644 index 000000000..650d968d4 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/ResponseStreamingTests.cs @@ -0,0 +1,136 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Amazon.IdentityManagement; +using Amazon.Lambda.Model; +using Amazon.Runtime.EventStreams; +using Amazon.S3; +using Xunit; + +namespace Amazon.Lambda.RuntimeSupport.IntegrationTests +{ + [Collection("Integration Tests")] + public class ResponseStreamingTests : BaseCustomRuntimeTest + { + private readonly static string s_functionName = "IntegTestResponseStreamingFunctionHandlers" + DateTime.Now.Ticks; + + private readonly ResponseStreamingTestsFixture _streamFixture; + + public ResponseStreamingTests(IntegrationTestFixture fixture, ResponseStreamingTestsFixture streamFixture) + : base(fixture, s_functionName, "ResponseStreamingFunctionHandlers.zip", @"ResponseStreamingFunctionHandlers\bin\Release\net10.0\ResponseStreamingFunctionHandlers.zip", "ResponseStreamingFunctionHandlers") + { + _streamFixture = streamFixture; + } + + [Fact] + public async Task SimpleFunctionHandler() + { + await _streamFixture.EnsureResourcesDeployedAsync(this); + + var evnts = await InvokeFunctionAsync(nameof(SimpleFunctionHandler)); + Assert.True(evnts.Any()); + + var content = GetCombinedStreamContent(evnts); + Assert.Equal("Hello, World!", content); + } + + [Fact] + public async Task StreamContentHandler() + { + await _streamFixture.EnsureResourcesDeployedAsync(this); + + var evnts = await InvokeFunctionAsync(nameof(StreamContentHandler)); + Assert.True(evnts.Length > 5); + + var content = GetCombinedStreamContent(evnts); + Assert.Contains("Line 9999", content); + Assert.EndsWith("Finish stream content\n", content); + } + + [Fact] + public async Task UnhandledExceptionHandler() + { + await _streamFixture.EnsureResourcesDeployedAsync(this); + + var evnts = await InvokeFunctionAsync(nameof(UnhandledExceptionHandler)); + Assert.True(evnts.Any()); + + var content = GetCombinedStreamContent(evnts); + Assert.Contains("This method will fail", content); + Assert.Contains("This is an unhandled exception", content); + Assert.Contains("Lambda-Runtime-Function-Error-Type", content); + Assert.Contains("InvalidOperationException", content); + Assert.Contains("This is an unhandled exception", content); + Assert.Contains("stackTrace", content); + } + + private async Task InvokeFunctionAsync(string handlerScenario) + { + using var client = new AmazonLambdaClient(TestRegion); + + var request = new InvokeWithResponseStreamRequest + { + FunctionName = base.FunctionName, + Payload = new MemoryStream(System.Text.Encoding.UTF8.GetBytes($"\"{handlerScenario}\"")), + InvocationType = ResponseStreamingInvocationType.RequestResponse + }; + + var response = await client.InvokeWithResponseStreamAsync(request); + var evnts = response.EventStream.AsEnumerable().ToArray(); + return evnts; + } + + private string GetCombinedStreamContent(IEventStreamEvent[] events) + { + var sb = new StringBuilder(); + foreach (var evnt in events) + { + if (evnt is InvokeResponseStreamUpdate chunk) + { + var text = System.Text.Encoding.UTF8.GetString(chunk.Payload.ToArray()); + sb.Append(text); + } + } + return sb.ToString(); + } + } + + public class ResponseStreamingTestsFixture : IAsyncLifetime + { + private readonly AmazonLambdaClient _lambdaClient = new AmazonLambdaClient(BaseCustomRuntimeTest.TestRegion); + private readonly AmazonS3Client _s3Client = new AmazonS3Client(BaseCustomRuntimeTest.TestRegion); + private readonly AmazonIdentityManagementServiceClient _iamClient = new AmazonIdentityManagementServiceClient(BaseCustomRuntimeTest.TestRegion); + bool _resourcesCreated; + bool _roleAlreadyExisted; + + ResponseStreamingTests _tests; + + public async Task EnsureResourcesDeployedAsync(ResponseStreamingTests tests) + { + if (_resourcesCreated) + return; + + _tests = tests; + _roleAlreadyExisted = await _tests.PrepareTestResources(_s3Client, _lambdaClient, _iamClient); + + _resourcesCreated = true; + } + + public async Task DisposeAsync() + { + await _tests.CleanUpTestResources(_s3Client, _lambdaClient, _iamClient, _roleAlreadyExisted); + + _lambdaClient.Dispose(); + _s3Client.Dispose(); + _iamClient.Dispose(); + } + + public Task InitializeAsync() => Task.CompletedTask; + } +} diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/ResponseStreamingFunctionHandlers/Function.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/ResponseStreamingFunctionHandlers/Function.cs new file mode 100644 index 000000000..29d06941a --- /dev/null +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/ResponseStreamingFunctionHandlers/Function.cs @@ -0,0 +1,56 @@ +#pragma warning disable CA2252 + +using Amazon.Lambda.Core; +using Amazon.Lambda.Core.ResponseStreaming; +using Amazon.Lambda.RuntimeSupport; +using Amazon.Lambda.Serialization.SystemTextJson; + +// The function handler that will be called for each Lambda event +var handler = async (string input, ILambdaContext context) => +{ + var stream = LambdaResponseStreamFactory.CreateStream(); + + switch(input) + { + case $"{nameof(SimpleFunctionHandler)}": + await SimpleFunctionHandler(stream, context); + break; + case $"{nameof(StreamContentHandler)}": + await StreamContentHandler(stream, context); + break; + case $"{nameof(UnhandledExceptionHandler)}": + await UnhandledExceptionHandler(stream, context); + break; + default: + throw new ArgumentException($"Unknown handler scenario {input}"); + } +}; + +async Task SimpleFunctionHandler(Stream stream, ILambdaContext context) +{ + using var writer = new StreamWriter(stream); + await writer.WriteAsync("Hello, World!"); +} + +async Task StreamContentHandler(Stream stream, ILambdaContext context) +{ + using var writer = new StreamWriter(stream); + + await writer.WriteLineAsync("Starting stream content..."); + for(var i = 0; i < 10000; i++) + { + await writer.WriteLineAsync($"Line {i}"); + } + await writer.WriteLineAsync("Finish stream content"); +} + +async Task UnhandledExceptionHandler(Stream stream, ILambdaContext context) +{ + using var writer = new StreamWriter(stream); + await writer.WriteAsync("This method will fail"); + throw new InvalidOperationException("This is an unhandled exception"); +} + +await LambdaBootstrapBuilder.Create(handler, new DefaultLambdaJsonSerializer()) + .Build() + .RunAsync(); diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/ResponseStreamingFunctionHandlers/ResponseStreamingFunctionHandlers.csproj b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/ResponseStreamingFunctionHandlers/ResponseStreamingFunctionHandlers.csproj new file mode 100644 index 000000000..fa81eaa17 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/ResponseStreamingFunctionHandlers/ResponseStreamingFunctionHandlers.csproj @@ -0,0 +1,19 @@ + + + Exe + net10.0 + enable + enable + true + Lambda + + true + + true + + + + + + + \ No newline at end of file diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/ResponseStreamingFunctionHandlers/aws-lambda-tools-defaults.json b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/ResponseStreamingFunctionHandlers/aws-lambda-tools-defaults.json new file mode 100644 index 000000000..3042c3978 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/ResponseStreamingFunctionHandlers/aws-lambda-tools-defaults.json @@ -0,0 +1,15 @@ +{ + "Information": [ + "This file provides default values for the deployment wizard inside Visual Studio and the AWS Lambda commands added to the .NET Core CLI.", + "To learn more about the Lambda commands with the .NET Core CLI execute the following command at the command line in the project root directory.", + "dotnet lambda help", + "All the command line options for the Lambda command can be specified in this file." + ], + "profile": "default", + "region": "us-west-2", + "configuration": "Release", + "function-runtime": "dotnet10", + "function-memory-size": 512, + "function-timeout": 30, + "function-handler": "ResponseStreamingFunctionHandlers" +} \ No newline at end of file From 9b308ae9f8834ff794b20f90811ef205e75c74a7 Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Wed, 11 Mar 2026 16:30:56 -0700 Subject: [PATCH 17/20] Improve test error message --- .../BaseCustomRuntimeTest.cs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/BaseCustomRuntimeTest.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/BaseCustomRuntimeTest.cs index fa10cee7d..b91dfc924 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/BaseCustomRuntimeTest.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/BaseCustomRuntimeTest.cs @@ -351,7 +351,16 @@ private string GetDeploymentZipPath() if (!File.Exists(deploymentZipFile)) { - throw new NoDeploymentPackageFoundException(); + var message = new StringBuilder(); + message.AppendLine($"Deployment package not found at expected path: {deploymentZipFile}"); + message.AppendLine("Available Test Bundles:"); + foreach (var kvp in _fixture.TestAppPaths) + { + message.AppendLine($"{kvp.Key}: {kvp.Value}"); + } + + + throw new NoDeploymentPackageFoundException(message.ToString()); } return deploymentZipFile; @@ -380,7 +389,9 @@ private static string FindUp(string path, string fileOrDirectoryName, bool combi protected class NoDeploymentPackageFoundException : Exception { + public NoDeploymentPackageFoundException() { } + public NoDeploymentPackageFoundException(string message) : base(message) { } } private ApplicationLogLevel ConvertRuntimeLogLevel(RuntimeLogLevel runtimeLogLevel) From d0861c618c4f43ad3452516294280f49bf117c45 Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Wed, 11 Mar 2026 19:54:48 -0700 Subject: [PATCH 18/20] Debugging integ tests --- .../BaseCustomRuntimeTest.cs | 2 +- .../IntegrationTestCollection.cs | 2 +- .../IntegrationTestFixture.cs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/BaseCustomRuntimeTest.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/BaseCustomRuntimeTest.cs index b91dfc924..314aa45c4 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/BaseCustomRuntimeTest.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/BaseCustomRuntimeTest.cs @@ -352,7 +352,7 @@ private string GetDeploymentZipPath() if (!File.Exists(deploymentZipFile)) { var message = new StringBuilder(); - message.AppendLine($"Deployment package not found at expected path: {deploymentZipFile}"); + message.AppendLine($"Deployment package for {DeploymentPackageZipRelativePath} not found at expected path: {deploymentZipFile}"); message.AppendLine("Available Test Bundles:"); foreach (var kvp in _fixture.TestAppPaths) { diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/IntegrationTestCollection.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/IntegrationTestCollection.cs index 9b637b547..6e066eb28 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/IntegrationTestCollection.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/IntegrationTestCollection.cs @@ -2,7 +2,7 @@ namespace Amazon.Lambda.RuntimeSupport.IntegrationTests; -[CollectionDefinition("Integration Tests")] +[CollectionDefinition("Integration Tests", DisableParallelization = true)] public class IntegrationTestCollection : ICollectionFixture, ICollectionFixture { diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/IntegrationTestFixture.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/IntegrationTestFixture.cs index ee63888c0..cc95ac8bc 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/IntegrationTestFixture.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/IntegrationTestFixture.cs @@ -42,7 +42,7 @@ public async Task InitializeAsync() "Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/ResponseStreamingFunctionHandlers"); _tempPaths.AddRange([testAppPath, toolPath]); await LambdaToolsHelper.LambdaPackage(toolPath, "net10.0", testAppPath); - TestAppPaths[@"ResponseStreamingFunctionHandlers\bin\Release\net10.0\ResponseStreamingFunctionHandlers.zip"] = Path.Combine(testAppPath, @"bin\Release\net10.0\ResponseStreamingFunctionHandlers.zip"); + TestAppPaths[@"ResponseStreamingFunctionHandlers\bin\Release\net10.0\ResponseStreamingFunctionHandlers.zip"] = Path.Combine(testAppPath, "bin", "Release", "net10.0", "ResponseStreamingFunctionHandlers.zip"); ; } From 3c866296504f30f5aa524cf25417592e251a80f1 Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Thu, 12 Mar 2026 11:55:35 -0700 Subject: [PATCH 19/20] Update change file --- .../changes/c27a62e6-91ca-4a59-9406-394866cdfa62.json | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.autover/changes/c27a62e6-91ca-4a59-9406-394866cdfa62.json b/.autover/changes/c27a62e6-91ca-4a59-9406-394866cdfa62.json index 9ad5afe6e..39be8933f 100644 --- a/.autover/changes/c27a62e6-91ca-4a59-9406-394866cdfa62.json +++ b/.autover/changes/c27a62e6-91ca-4a59-9406-394866cdfa62.json @@ -4,8 +4,15 @@ "Name": "Amazon.Lambda.RuntimeSupport", "Type": "Minor", "ChangelogMessages": [ - "Add response streaming support" + "(Preview) Add response streaming support" ] - } + }, + { + "Name": "Amazon.Lambda.Core", + "Type": "Minor", + "ChangelogMessages": [ + "(Preview) Add response streaming support" + ] + } ] } From c637ef1421e3d36043857acc6886af9c69014fbd Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Tue, 17 Mar 2026 16:59:55 -0700 Subject: [PATCH 20/20] Address PR comments --- .../HttpResponseStreamPrelude.cs | 2 +- .../ResponseStreaming/LambdaResponseStream.cs | 2 +- .../LambdaResponseStreamFactory.cs | 17 +++++++++++++++-- .../Bootstrap/LambdaBootstrap.cs | 1 + .../ResponseStreaming/ResponseStream.cs | 12 +++++++++--- .../ResponseStreaming/ResponseStreamContext.cs | 4 +++- .../ResponseStreaming/ResponseStreamFactory.cs | 3 ++- ...sponseStreamLambdaCoreInitializerIsolated.cs | 2 +- .../ResponseStreaming/StreamingHttpContent.cs | 2 +- .../Client/RuntimeApiClient.cs | 5 ++--- .../Helpers/CommandLineWrapper.cs | 1 + .../IntegrationTestFixture.cs | 2 +- .../Function.cs | 2 +- 13 files changed, 39 insertions(+), 16 deletions(-) diff --git a/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/HttpResponseStreamPrelude.cs b/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/HttpResponseStreamPrelude.cs index ebd8a7018..d218de397 100644 --- a/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/HttpResponseStreamPrelude.cs +++ b/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/HttpResponseStreamPrelude.cs @@ -11,7 +11,7 @@ namespace Amazon.Lambda.Core.ResponseStreaming /// /// The HTTP response prelude to be sent as the first chunk of a streaming response when using . /// - [RequiresPreviewFeatures(LambdaResponseStreamFactory.ParameterizedPreviewMessage)] + [RequiresPreviewFeatures(LambdaResponseStreamFactory.PreviewMessage)] public class HttpResponseStreamPrelude { /// diff --git a/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/LambdaResponseStream.cs b/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/LambdaResponseStream.cs index 506db46d7..47ce48588 100644 --- a/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/LambdaResponseStream.cs +++ b/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/LambdaResponseStream.cs @@ -15,7 +15,7 @@ namespace Amazon.Lambda.Core.ResponseStreaming /// to the Lambda Runtime API. Returned by . /// Integrates with standard .NET stream consumers such as . /// - [RequiresPreviewFeatures(LambdaResponseStreamFactory.ParameterizedPreviewMessage)] + [RequiresPreviewFeatures(LambdaResponseStreamFactory.PreviewMessage)] public class LambdaResponseStream : Stream { private readonly ILambdaResponseStream _responseStream; diff --git a/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/LambdaResponseStreamFactory.cs b/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/LambdaResponseStreamFactory.cs index c82ce4a3d..7adccc2da 100644 --- a/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/LambdaResponseStreamFactory.cs +++ b/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/LambdaResponseStreamFactory.cs @@ -10,15 +10,19 @@ namespace Amazon.Lambda.Core.ResponseStreaming /// /// Factory to create Lambda response streams for writing streaming responses in AWS Lambda functions. The created streams are write-only and non-seekable. /// - [RequiresPreviewFeatures(LambdaResponseStreamFactory.ParameterizedPreviewMessage)] + [RequiresPreviewFeatures(LambdaResponseStreamFactory.PreviewMessage)] public class LambdaResponseStreamFactory { - internal const string ParameterizedPreviewMessage = + internal const string PreviewMessage = "Response streaming is in preview till a new version of .NET Lambda runtime client that supports response streaming " + "has been deployed to the .NET Lambda managed runtime. Till deployment has been made the feature can be used by deploying as an " + "executable including the latest version of Amazon.Lambda.RuntimeSupport and setting the \"EnablePreviewFeatures\" in the Lambda " + "project file to \"true\""; + internal const string UninitializedFactoryMessage = + "LambdaResponseStreamFactory is not initialized. This is caused by mismatch versions of Amazon.Lambda.Core and Amazon.Lambda.RuntimeSupport. " + + "Update both packages to the current version to address the issue."; + private static Func _streamFactory; internal static void SetLambdaResponseStream(Func streamFactory) @@ -34,6 +38,9 @@ internal static void SetLambdaResponseStream(Func /// public static Stream CreateStream() { + if (_streamFactory == null) + throw new InvalidOperationException(UninitializedFactoryMessage); + var runtimeResponseStream = _streamFactory(Array.Empty()); return new LambdaResponseStream(runtimeResponseStream); } @@ -51,6 +58,12 @@ public static Stream CreateStream() /// public static Stream CreateHttpStream(HttpResponseStreamPrelude prelude) { + if (_streamFactory == null) + throw new InvalidOperationException(UninitializedFactoryMessage); + + if (prelude is null) + throw new ArgumentNullException(nameof(prelude)); + var runtimeResponseStream = _streamFactory(prelude.ToByteArray()); return new LambdaResponseStream(runtimeResponseStream); } diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/LambdaBootstrap.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/LambdaBootstrap.cs index a804b0b10..cd0e9bc4f 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/LambdaBootstrap.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/LambdaBootstrap.cs @@ -425,6 +425,7 @@ internal async Task InvokeOnceAsync(CancellationToken cancellationToken = defaul { // Wait for the streaming response to finish sending before allowing the next invocation to be processed. This ensures that responses are sent in the order the invocations were received. await sendTask; + sendTask.Result.Dispose(); } streamIfCreated.Dispose(); diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStream.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStream.cs index c825c3bb6..c9daa6972 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStream.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStream.cs @@ -37,8 +37,14 @@ internal class ResponseStream private Stream _httpOutputStream; private bool _disposedValue; private readonly SemaphoreSlim _httpStreamReady = new SemaphoreSlim(0, 1); + + // The wait time is a sanity timeout to avoid waiting indefinitely if SerializeToStreamAsync is not called or takes too long to call. + // Reality is that SerializeToStreamAsync should be called very quickly after CreateStream, so this timeout is generous to avoid false positives but still protects against hanging indefinitely. + private readonly static TimeSpan _httpStreamWaitTimeout = TimeSpan.FromSeconds(30); + private readonly SemaphoreSlim _completionSignal = new SemaphoreSlim(0, 1); + private static readonly byte[] PreludeDelimiter = new byte[8]; /// @@ -81,7 +87,7 @@ private async Task WritePreludeAsync(CancellationToken cancellationToken = defau if (_prelude?.Length > 0) { _logger.LogDebug($"Writing prelude of {_prelude.Length} bytes to HTTP stream."); - await _httpStreamReady.WaitAsync(cancellationToken); + await _httpStreamReady.WaitAsync(_httpStreamWaitTimeout, cancellationToken); try { lock (_lock) @@ -136,10 +142,10 @@ public async Task WriteAsync(byte[] buffer, int offset, int count, CancellationT throw new ArgumentOutOfRangeException(nameof(count)); // Wait for the HTTP stream to be ready (first write only blocks) - await _httpStreamReady.WaitAsync(cancellationToken); + await _httpStreamReady.WaitAsync(_httpStreamWaitTimeout, cancellationToken); try { - _logger.LogDebug($"Writing chuck of {count} bytes to HTTP stream."); + _logger.LogDebug($"Writing chunk of {count} bytes to HTTP stream."); lock (_lock) { diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStreamContext.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStreamContext.cs index 3fb92e51d..970c43138 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStreamContext.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStreamContext.cs @@ -13,6 +13,8 @@ * permissions and limitations under the License. */ +using System; +using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -52,6 +54,6 @@ internal class ResponseStreamContext /// The Task representing the in-flight HTTP POST to the Runtime API. /// Started when CreateStream() is called, completes when the stream is finalized. /// - public Task SendTask { get; set; } + public Task SendTask { get; set; } } } diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStreamFactory.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStreamFactory.cs index dcbdf4c92..f430393c5 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStreamFactory.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStreamFactory.cs @@ -14,6 +14,7 @@ */ using System; +using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -103,7 +104,7 @@ internal static ResponseStream GetStreamIfCreated(bool isMultiConcurrency) /// Returns the Task for the in-flight HTTP send, or null if streaming wasn't started. /// LambdaBootstrap awaits this after the handler returns to ensure the HTTP request completes. /// - internal static Task GetSendTask(bool isMultiConcurrency) + internal static Task GetSendTask(bool isMultiConcurrency) { var context = isMultiConcurrency ? _asyncLocalContext.Value : _onDemandContext; return context?.SendTask; diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStreamLambdaCoreInitializerIsolated.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStreamLambdaCoreInitializerIsolated.cs index e9e846723..b86864480 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStreamLambdaCoreInitializerIsolated.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStreamLambdaCoreInitializerIsolated.cs @@ -54,7 +54,7 @@ internal ImplLambdaResponseStream(ResponseStream innerStream) public void Dispose() => _innerStream.Dispose(); /// - public Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken = default) => _innerStream.WriteAsync(buffer, offset, count); + public Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken = default) => _innerStream.WriteAsync(buffer, offset, count, cancellationToken); } } } diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/StreamingHttpContent.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/StreamingHttpContent.cs index a0cc0511a..c11c86d49 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/StreamingHttpContent.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/StreamingHttpContent.cs @@ -44,7 +44,7 @@ protected override async Task SerializeToStreamAsync(Stream stream, TransportCon // can write chunks directly to it. await _responseStream.SetHttpOutputStreamAsync(stream, _cancellationToken); - InternalLogger.GetDefaultLogger().LogInformation("In SerializeToStreamAsync waiting for the underlying Lambda response stream in indicate it is complete."); + InternalLogger.GetDefaultLogger().LogInformation("In SerializeToStreamAsync waiting for the underlying Lambda response stream to indicate it is complete."); // Wait for the handler to finish writing (MarkCompleted or ReportErrorAsync) await _responseStream.WaitForCompletionAsync(_cancellationToken); diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs index dcec11ae3..c9acc3832 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs @@ -189,7 +189,7 @@ public Task ReportRestoreErrorAsync(Exception exception, String errorType = null /// The ResponseStream that will provide the streaming data. /// The optional cancellation token to use. /// A Task representing the in-flight HTTP POST. - internal virtual async Task StartStreamingResponseAsync( + internal virtual async Task StartStreamingResponseAsync( string awsRequestId, ResponseStream responseStream, CancellationToken cancellationToken = default) { if (awsRequestId == null) throw new ArgumentNullException(nameof(awsRequestId)); @@ -213,11 +213,10 @@ internal virtual async Task StartStreamingResponseAsync( request.Content = new StreamingHttpContent(responseStream, cancellationToken); - // SendAsync calls SerializeToStreamAsync, which blocks until the handler - // finishes writing. This is why this method runs concurrently with the handler. var response = await _httpClient.SendAsync( request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); response.EnsureSuccessStatusCode(); + return response; } } diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/Helpers/CommandLineWrapper.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/Helpers/CommandLineWrapper.cs index e18f31833..ea6fd059e 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/Helpers/CommandLineWrapper.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/Helpers/CommandLineWrapper.cs @@ -82,6 +82,7 @@ public static async Task Run(string command, string arguments, string workingDir catch (Exception ex) { Console.WriteLine("Exception: " + ex); + Console.WriteLine(output.ToString()); if (!process.HasExited) { process.Kill(); diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/IntegrationTestFixture.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/IntegrationTestFixture.cs index cc95ac8bc..b8c71519e 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/IntegrationTestFixture.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/IntegrationTestFixture.cs @@ -42,7 +42,7 @@ public async Task InitializeAsync() "Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/ResponseStreamingFunctionHandlers"); _tempPaths.AddRange([testAppPath, toolPath]); await LambdaToolsHelper.LambdaPackage(toolPath, "net10.0", testAppPath); - TestAppPaths[@"ResponseStreamingFunctionHandlers\bin\Release\net10.0\ResponseStreamingFunctionHandlers.zip"] = Path.Combine(testAppPath, "bin", "Release", "net10.0", "ResponseStreamingFunctionHandlers.zip"); ; + TestAppPaths[@"ResponseStreamingFunctionHandlers\bin\Release\net10.0\ResponseStreamingFunctionHandlers.zip"] = Path.Combine(testAppPath, "bin", "Release", "net10.0", "ResponseStreamingFunctionHandlers.zip"); } diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/ResponseStreamingFunctionHandlers/Function.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/ResponseStreamingFunctionHandlers/Function.cs index 29d06941a..8c645ff5b 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/ResponseStreamingFunctionHandlers/Function.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/ResponseStreamingFunctionHandlers/Function.cs @@ -8,7 +8,7 @@ // The function handler that will be called for each Lambda event var handler = async (string input, ILambdaContext context) => { - var stream = LambdaResponseStreamFactory.CreateStream(); + using var stream = LambdaResponseStreamFactory.CreateStream(); switch(input) {