Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions Sources/AWSLambdaRuntime/Lambda+LocalServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import NIOHTTP1
import NIOPosix
import Synchronization

// This functionality is designed for local testing hence being a #if DEBUG flag.
// This functionality is designed for local testing when the LocalServerSupport trait is enabled.

// For example:
// try Lambda.withLocalServer {
Expand All @@ -42,18 +42,24 @@ extension Lambda {
/// Execute code in the context of a mock Lambda server.
///
/// - parameters:
/// - host: the hostname or IP address to listen on
/// - port: the TCP port to listen to
/// - invocationEndpoint: The endpoint to post events to.
/// - body: Code to run within the context of the mock server. Typically this would be a Lambda.run function call.
///
/// - note: This API is designed strictly for local testing and is behind a DEBUG flag
/// - note: This API is designed strictly for local testing when the LocalServerSupport trait is enabled.
@usableFromInline
static func withLocalServer(
host: String,
port: Int,
invocationEndpoint: String? = nil,
logger: Logger,
_ body: sending @escaping () async throws -> Void
) async throws {
do {
try await LambdaHTTPServer.withLocalServer(
host: host,
port: port,
invocationEndpoint: invocationEndpoint,
logger: logger
) {
Expand Down Expand Up @@ -112,9 +118,9 @@ internal struct LambdaHTTPServer {
}

static func withLocalServer<Result: Sendable>(
host: String,
port: Int,
invocationEndpoint: String?,
host: String = "127.0.0.1",
port: Int = 7000,
eventLoopGroup: MultiThreadedEventLoopGroup = .singleton,
logger: Logger,
_ closure: sending @escaping () async throws -> Result
Expand Down
14 changes: 11 additions & 3 deletions Sources/AWSLambdaRuntime/LambdaRuntime.swift
Original file line number Diff line number Diff line change
Expand Up @@ -124,15 +124,23 @@ public final class LambdaRuntime<Handler>: Sendable where Handler: StreamingLamb
} else {

#if LocalServerSupport

// we're not running on Lambda and we're compiled in DEBUG mode,
// let's start a local server for testing

let host = Lambda.env("LOCAL_LAMBDA_HOST") ?? "127.0.0.1"
let port = Lambda.env("LOCAL_LAMBDA_PORT").flatMap(Int.init) ?? 7000
let endpoint = Lambda.env("LOCAL_LAMBDA_INVOCATION_ENDPOINT")

try await Lambda.withLocalServer(
invocationEndpoint: Lambda.env("LOCAL_LAMBDA_SERVER_INVOCATION_ENDPOINT"),
host: host,
port: port,
invocationEndpoint: endpoint,
logger: self.logger
) {

try await LambdaRuntimeClient.withRuntimeClient(
configuration: .init(ip: "127.0.0.1", port: 7000),
configuration: .init(ip: host, port: port),
eventLoop: self.eventLoop,
logger: self.logger
) { runtimeClient in
Expand All @@ -144,7 +152,7 @@ public final class LambdaRuntime<Handler>: Sendable where Handler: StreamingLamb
}
}
#else
// in release mode, we can't start a local server because the local server code is not compiled.
// When the LocalServerSupport trait is disabled, we can't start a local server because the local server code is not compiled.
throw LambdaRuntimeError(code: .missingLambdaRuntimeAPIEnvironmentVariable)
#endif
}
Expand Down
94 changes: 94 additions & 0 deletions Tests/AWSLambdaRuntimeTests/LambdaLocalServerTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the SwiftAWSLambdaRuntime open source project
//
// Copyright (c) 2025 Apple Inc. and the SwiftAWSLambdaRuntime project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.txt for the list of SwiftAWSLambdaRuntime project authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//

import Logging
import NIOCore
import NIOPosix
import Testing

@testable import AWSLambdaRuntime

extension LambdaRuntimeTests {

@Test("Local server respects LOCAL_LAMBDA_PORT environment variable")
func testLocalServerCustomPort() async throws {
let customPort = 8080

// Set environment variable
setenv("LOCAL_LAMBDA_PORT", "\(customPort)", 1)
defer { unsetenv("LOCAL_LAMBDA_PORT") }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given these tests are running in parallel, there is a chance the LOCAL_LAMBDA_PORT environment var setting will affect other tests.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@adam-fowler good point. I aggressively added .serialized on all tests using the server or the runtime client

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that might only run tests serially within each suite so two suites can still run in parallel, even though the tests internally run serially. Might be worth asking swift-testing folks

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@adam-fowler During the tests, we let the MockServer bind on any available port (port = 0) and share its actual port number with the runtime client. There is no risk of conflict.

See

I kept the .serialized only on the new test in case someone adds another test to this suite later on.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem isn't the fact you might have multiple servers binding to the same address (although that is a problem and am surprised it hasn't reared it head before). The problem is the environment variable is being set when other tests could be running and could affect those tests.

We can leave as is, but I think we will get the occasional test failure as multiple tests inherit the environment variable and bind to the same address.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@adam-fowler

am surprised it hasn't reared it head before

In general, the unit tests don't use the local server. They use a MockServer that uses a random port number to avoid conflicts.

I think we will get the occasional test failure as multiple tests inherit the environment variable and bind to the same address

There are three groups of tests that use the real server :

  • LambdaRuntimeTests.swift
  • LambdaRuntime+ServiceLifecycle.swift
  • LambdaLocalServerTests.swift (this PR)

I will add a commit to group them in the same .serialized suite.

Copy link
Contributor Author

@sebsto sebsto Sep 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


let result = try? await withThrowingTaskGroup(of: Bool.self) { group in

// start a local lambda + local server on custom port
group.addTask {
// Create a simple handler
struct TestHandler: StreamingLambdaHandler {
func handle(
_ event: ByteBuffer,
responseWriter: some LambdaResponseStreamWriter,
context: LambdaContext
) async throws {
try await responseWriter.write(ByteBuffer(string: "test"))
try await responseWriter.finish()
}
}

// create the Lambda Runtime
let runtime = LambdaRuntime(
handler: TestHandler(),
logger: Logger(label: "test", factory: { _ in SwiftLogNoOpLogHandler() })
)

// Start runtime
try await runtime._run()

// we reach this line when the group is cancelled
return false
}

// start a client to check if something responds on the custom port
group.addTask {
// Give server time to start
try await Task.sleep(for: .milliseconds(100))

// Verify server is listening on custom port
return try await isPortResponding(host: "127.0.0.1", port: customPort)
}

let first = try await group.next()
group.cancelAll()
return first ?? false

}

#expect(result == true)
}

private func isPortResponding(host: String, port: Int) async throws -> Bool {
let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)

let bootstrap = ClientBootstrap(group: group)

do {
let channel = try await bootstrap.connect(host: host, port: port).get()
try await channel.close().get()
try await group.shutdownGracefully()
return true
} catch {
try await group.shutdownGracefully()
return false
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,7 @@ import ServiceLifecycle
import Testing
import Logging

@Suite
struct LambdaRuntimeServiceLifecycleTests {
extension LambdaRuntimeTests {
@Test
@available(LambdaSwift 2.0, *)
func testLambdaRuntimeGracefulShutdown() async throws {
Expand Down
2 changes: 1 addition & 1 deletion Tests/AWSLambdaRuntimeTests/LambdaRuntimeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import Testing

@testable import AWSLambdaRuntime

@Suite("LambdaRuntimeTests")
@Suite(.serialized)
struct LambdaRuntimeTests {

@Test("LambdaRuntime can only be run once")
Expand Down
2 changes: 1 addition & 1 deletion Tests/AWSLambdaRuntimeTests/MockLambdaServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ final class MockLambdaServer<Behavior: LambdaServerBehavior> {
init(
behavior: Behavior,
host: String = "127.0.0.1",
port: Int = 7000,
port: Int = 0,
keepAlive: Bool = true,
eventLoopGroup: MultiThreadedEventLoopGroup
) {
Expand Down
14 changes: 10 additions & 4 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -464,16 +464,22 @@ curl -v --header "Content-Type:\ application/json" --data @events/create-session
* Connection #0 to host 127.0.0.1 left intact
{"statusCode":200,"isBase64Encoded":false,"body":"...","headers":{"Access-Control-Allow-Origin":"*","Content-Type":"application\/json; charset=utf-8","Access-Control-Allow-Headers":"*"}}
```
### Modifying the local endpoint
### Modifying the local server URI

By default, when using the local Lambda server, it listens on the `/invoke` endpoint.
By default, when using the local Lambda server during your tests, it listens on `http://127.0.0.1:7000/invoke`.

Some testing tools, such as the [AWS Lambda runtime interface emulator](https://docs.aws.amazon.com/lambda/latest/dg/images-test.html), require a different endpoint. In that case, you can use the `LOCAL_LAMBDA_SERVER_INVOCATION_ENDPOINT` environment variable to force the runtime to listen on a different endpoint.
Some testing tools, such as the [AWS Lambda runtime interface emulator](https://docs.aws.amazon.com/lambda/latest/dg/images-test.html), require a different endpoint, the port might be used, or you may want to bind a specific IP address.

In these cases, you can use three environment variables to control the local server:

- Set `LOCAL_LAMBDA_HOST` to configure the local server to listen on a different TCP address.
- Set `LOCAL_LAMBDA_PORT` to configure the local server to listen on a different TCP port.
- Set `LOCAL_LAMBDA_INVOCATION_ENDPOINT` to force the local server to listen on a different endpoint.

Example:

```sh
LOCAL_LAMBDA_SERVER_INVOCATION_ENDPOINT=/2015-03-31/functions/function/invocations swift run
LOCAL_LAMBDA_PORT=8080 LOCAL_LAMBDA_INVOCATION_ENDPOINT=/2015-03-31/functions/function/invocations swift run
```

## Deploying your Swift Lambda functions
Expand Down