Skip to content

Local Debugging: LambdaHttpServer Drops All But Last Inbound Body Chunk #481

@macserv

Description

@macserv

Expected behavior

When handling the connection, all body data chunks should be collected into the requestBody buffer from the inbound stream for processing.

Actual behavior

The requestBody buffer is repeatedly reset to the last chunk received, so that only the last chunk is sent for processing.

Steps to reproduce

  1. Create a sample StreamingLambdaHandler and run it locally.
  2. Feed a decent amount of data to the invoke endpoint (my test data is around 260KB, but I don't think it needs to be anywhere near that large to get split into chunks).
  3. Log the event data.
  4. Observe that only the last chunk of body data is processed.

If possible, minimal yet complete reproducer code (or URL to code)

Sample StreamingLambdaHandler

@main
struct RawInputHandler: StreamingLambdaHandler
{
    func handle(_ event: ByteBuffer, responseWriter: some LambdaResponseStreamWriter, context: LambdaContext) async throws
    {
        context.logger.info("Raw JSON:\n\( String(data: Data(buffer: event), encoding: .utf8)! )")
        try await responseWriter.finish()
    }

    static func main() async throws { try await LambdaRuntime(handler: Self()).run() }
}

Invocation with curl

cat ./TestData.json | curl --json '@-' 'http://localhost:7000/invoke'

Root Cause

The issue seems to reside in the handleConnection() function in /Sources/AWSLambdaRuntimeCore/Lambda+LocalServer.swift:163–204:

    /// This method handles individual TCP connections
    private func handleConnection(
        channel: NIOAsyncChannel<HTTPServerRequestPart, HTTPServerResponsePart>
    ) async {

        var requestHead: HTTPRequestHead!
        var requestBody: ByteBuffer?

        // Note that this method is non-throwing and we are catching any error.
        // We do this since we don't want to tear down the whole server when a single connection
        // encounters an error.
        do {
            try await channel.executeThenClose { inbound, outbound in
                for try await inboundData in inbound {
                    if case .head(let head) = inboundData {
                        requestHead = head
                    }
                    if case .body(let body) = inboundData {
                        requestBody = body
                    }
                    if case .end = inboundData {
                        precondition(requestHead != nil, "Received .end without .head")
                        // process the request
                        let response = try await self.processRequest(
                            head: requestHead,
                            body: requestBody
                        )
                        // send the responses
                        try await self.sendResponse(
                            response: response,
                            outbound: outbound
                        )

                        requestHead = nil
                        requestBody = nil
                    }
                }
            }
        } catch {
            logger.error("Hit error: \(error)")
        }
    }

Within the closure for executeThenClose, each inboundData chunk arrives, and if it is determined to be .body data, the requestBody variable is reassigned to the newest chunk. When the .end case is reached, only the last chunk of body data has been captured for processing.

Recommendation

An easy change would be to replace requestBody = body with requestBody.setOrWriteImmutableBuffer(body). This would allow the body chunks to accumulate in the requestBody buffer.

I've not spent much time evaluating potential edge cases, but the following modification works without issue in my own environment:

    /// This method handles individual TCP connections
    ///
    /// - Note: This method is non-throwing and we are catching any error.  This
    ///     was done so that we don't tear down the whole server when a single
    ///     connection encounters an error.
    private func handleConnection(channel: NIOAsyncChannel<HTTPServerRequestPart, HTTPServerResponsePart>) async {
        var requestHead: HTTPRequestHead!
        var requestBody: ByteBuffer?

        do {
            try await channel.executeThenClose { inbound, outbound in
                for try await inboundData in inbound {
                    switch inboundData {
                        case .head(let head): requestHead = head
                        case .body(let body): requestBody.setOrWriteImmutableBuffer(body)
                        case .end:
                            precondition(requestHead != nil, "Received .end without .head")

                            // Process the request and send the responses.
                            let response = try await self.processRequest(head: requestHead, body: requestBody)
                            try await self.sendResponse(response: response, outbound: outbound)

                            requestHead = nil
                            requestBody = nil
                    }
                }
            }
        } catch {
            logger.error("Hit error: \(error)")
        }
    }

swift-aws-lambda-runtime version

main@40e2291532fdc0cf2e54f285bf33dc37b740646f

Swift version

swift-driver version: 1.115.1 Apple Swift version 6.0.3 (swiftlang-6.0.3.1.9 clang-1600.0.30.1)
Target: arm64-apple-macosx15.0
Darwin XYHY90YH44 24.3.0 Darwin Kernel Version 24.3.0: Thu Jan  2 20:24:23 PST 2025; root:xnu-11215.81.4~3/RELEASE_ARM64_T6020 arm64

Amazon Linux 2 docker image version

6.0-amazonlinux2

Thanks

I'm tremendously grateful to this project's contributors for their time spent evaluating this issue, and for the great work they've done as a whole. As part of my efforts to promote Swift as a general-purpose language beyond it's iOS-app-shaped pigeonhole, the swift-aws-lambda-runtime package has allowed me to deliver a particularly simple and impactful demonstration of the benefits and possibilities afforded by the language.

Metadata

Metadata

Assignees

Labels

kind/bugFeature doesn't work as expected.

Type

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions