diff --git a/Package.swift b/Package.swift index 44e2434f..42e4c3ed 100644 --- a/Package.swift +++ b/Package.swift @@ -47,20 +47,12 @@ let package = Package( .byName(name: "AWSLambdaRuntime"), .product(name: "NIO", package: "swift-nio"), ]), - .testTarget(name: "AWSLambdaTestingTests", dependencies: [ - .byName(name: "AWSLambdaTesting"), - .byName(name: "AWSLambdaRuntime"), - ]), - // samples - .target(name: "StringSample", dependencies: [ - .byName(name: "AWSLambdaRuntime"), - ]), - .target(name: "CodableSample", dependencies: [ - .byName(name: "AWSLambdaRuntime"), - ]), - // perf tests + .testTarget(name: "AWSLambdaTestingTests", dependencies: ["AWSLambdaTesting"]), + // for perf testing .target(name: "MockServer", dependencies: [ .product(name: "NIOHTTP1", package: "swift-nio"), ]), + .target(name: "StringSample", dependencies: ["AWSLambdaRuntime"]), + .target(name: "CodableSample", dependencies: ["AWSLambdaRuntime"]), ] ) diff --git a/Sources/AWSLambdaRuntimeCore/HTTPClient.swift b/Sources/AWSLambdaRuntimeCore/HTTPClient.swift index fd7df7e5..17b77b13 100644 --- a/Sources/AWSLambdaRuntimeCore/HTTPClient.swift +++ b/Sources/AWSLambdaRuntimeCore/HTTPClient.swift @@ -25,7 +25,7 @@ internal final class HTTPClient { private let targetHost: String private var state = State.disconnected - private let executing = NIOAtomic.makeAtomic(value: false) + private var executing = false init(eventLoop: EventLoop, configuration: Lambda.Configuration.RuntimeEngine) { self.eventLoop = eventLoop @@ -48,9 +48,26 @@ internal final class HTTPClient { timeout: timeout ?? self.configuration.requestTimeout)) } + /// cancels the current request if there is one + func cancel() { + guard self.executing else { + // there is no request running. nothing to cancel + return + } + + guard case .connected(let channel) = self.state else { + preconditionFailure("if we are executing, we expect to have an open channel") + } + + channel.triggerUserOutboundEvent(RequestCancelEvent(), promise: nil) + } + // TODO: cap reconnect attempt private func execute(_ request: Request, validate: Bool = true) -> EventLoopFuture { - precondition(!validate || self.executing.compareAndExchange(expected: false, desired: true), "expecting single request at a time") + if validate { + precondition(self.executing == false, "expecting single request at a time") + self.executing = true + } switch self.state { case .disconnected: @@ -66,7 +83,8 @@ internal final class HTTPClient { let promise = channel.eventLoop.makePromise(of: Response.self) promise.futureResult.whenComplete { _ in - precondition(self.executing.compareAndExchange(expected: true, desired: false), "invalid execution state") + precondition(self.executing == true, "invalid execution state") + self.executing = false } let wrapper = HTTPRequestWrapper(request: request, promise: promise) channel.writeAndFlush(wrapper).cascadeFailure(to: promise) @@ -120,6 +138,7 @@ internal final class HTTPClient { internal enum Errors: Error { case connectionResetByPeer case timeout + case cancelled } private enum State { @@ -284,6 +303,17 @@ private final class UnaryHandler: ChannelDuplexHandler { context.fireChannelInactive() } + func triggerUserOutboundEvent(context: ChannelHandlerContext, event: Any, promise: EventLoopPromise?) { + switch event { + case is RequestCancelEvent: + if self.pending != nil { + self.completeWith(.failure(HTTPClient.Errors.cancelled)) + } + default: + context.triggerUserOutboundEvent(event, promise: promise) + } + } + private func completeWith(_ result: Result) { guard let pending = self.pending else { preconditionFailure("invalid state, no pending request") @@ -299,3 +329,5 @@ private struct HTTPRequestWrapper { let request: HTTPClient.Request let promise: EventLoopPromise } + +private struct RequestCancelEvent {} diff --git a/Sources/AWSLambdaRuntimeCore/Lambda+LocalServer.swift b/Sources/AWSLambdaRuntimeCore/Lambda+LocalServer.swift new file mode 100644 index 00000000..e39c1179 --- /dev/null +++ b/Sources/AWSLambdaRuntimeCore/Lambda+LocalServer.swift @@ -0,0 +1,268 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAWSLambdaRuntime open source project +// +// Copyright (c) 2020 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 +// +//===----------------------------------------------------------------------===// + +#if DEBUG +import Dispatch +import Logging +import NIO +import NIOConcurrencyHelpers +import NIOHTTP1 + +// This functionality is designed for local testing hence beind a #if DEBUG flag. +// For example: +// +// try Lambda.withLocalServer { +// Lambda.run { (context: Lambda.Context, payload: String, callback: @escaping (Result) -> Void) in +// callback(.success("Hello, \(payload)!")) +// } +// } +extension Lambda { + /// Execute code in the context of a mock Lambda server. + /// + /// - parameters: + /// - invocationEndpoint: The endpoint to post payloads 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 stricly for local testing and is behind a DEBUG flag + public static func withLocalServer(invocationEndpoint: String? = nil, _ body: @escaping () -> Void) throws { + let server = LocalLambda.Server(invocationEndpoint: invocationEndpoint) + try server.start().wait() + defer { try! server.stop() } // FIXME: + body() + } +} + +// MARK: - Local Mock Server + +private enum LocalLambda { + struct Server { + private let logger: Logger + private let group: EventLoopGroup + private let host: String + private let port: Int + private let invocationEndpoint: String + + public init(invocationEndpoint: String?) { + let configuration = Lambda.Configuration() + var logger = Logger(label: "LocalLambdaServer") + logger.logLevel = configuration.general.logLevel + self.logger = logger + self.group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + self.host = configuration.runtimeEngine.ip + self.port = configuration.runtimeEngine.port + self.invocationEndpoint = invocationEndpoint ?? "/invoke" + } + + func start() -> EventLoopFuture { + let bootstrap = ServerBootstrap(group: group) + .serverChannelOption(ChannelOptions.socket(SocketOptionLevel(SOL_SOCKET), SO_REUSEADDR), value: 1) + .childChannelInitializer { channel in + channel.pipeline.configureHTTPServerPipeline(withErrorHandling: true).flatMap { _ in + channel.pipeline.addHandler(HTTPHandler(logger: self.logger, invocationEndpoint: self.invocationEndpoint)) + } + } + return bootstrap.bind(host: self.host, port: self.port).flatMap { channel -> EventLoopFuture in + guard channel.localAddress != nil else { + return channel.eventLoop.makeFailedFuture(ServerError.cantBind) + } + self.logger.info("LocalLambdaServer started and listening on \(self.host):\(self.port), receiving payloads on \(self.invocationEndpoint)") + return channel.eventLoop.makeSucceededFuture(()) + } + } + + func stop() throws { + try self.group.syncShutdownGracefully() + } + } + + final class HTTPHandler: ChannelInboundHandler { + public typealias InboundIn = HTTPServerRequestPart + public typealias OutboundOut = HTTPServerResponsePart + + private var pending = CircularBuffer<(head: HTTPRequestHead, body: ByteBuffer?)>() + + private static var invocations = CircularBuffer() + private static var invocationState = InvocationState.waitingForLambdaRequest + + private let logger: Logger + private let invocationEndpoint: String + + init(logger: Logger, invocationEndpoint: String) { + self.logger = logger + self.invocationEndpoint = invocationEndpoint + } + + func channelRead(context: ChannelHandlerContext, data: NIOAny) { + let requestPart = unwrapInboundIn(data) + + switch requestPart { + case .head(let head): + self.pending.append((head: head, body: nil)) + case .body(var buffer): + var request = self.pending.removeFirst() + if request.body == nil { + request.body = buffer + } else { + request.body!.writeBuffer(&buffer) + } + self.pending.prepend(request) + case .end: + let request = self.pending.removeFirst() + self.processRequest(context: context, request: request) + } + } + + func processRequest(context: ChannelHandlerContext, request: (head: HTTPRequestHead, body: ByteBuffer?)) { + switch (request.head.method, request.head.uri) { + // this endpoint is called by the client invoking the lambda + case (.POST, let url) where url.hasSuffix(self.invocationEndpoint): + guard let work = request.body else { + return self.writeResponse(context: context, response: .init(status: .badRequest)) + } + let requestID = "\(DispatchTime.now().uptimeNanoseconds)" // FIXME: + let promise = context.eventLoop.makePromise(of: Response.self) + promise.futureResult.whenComplete { result in + switch result { + case .failure(let error): + self.logger.error("invocation error: \(error)") + self.writeResponse(context: context, response: .init(status: .internalServerError)) + case .success(let response): + self.writeResponse(context: context, response: response) + } + } + let invocation = Invocation(requestID: requestID, request: work, responsePromise: promise) + switch Self.invocationState { + case .waitingForInvocation(let promise): + promise.succeed(invocation) + case .waitingForLambdaRequest, .waitingForLambdaResponse: + Self.invocations.append(invocation) + } + // /next endpoint is called by the lambda polling for work + case (.GET, let url) where url.hasSuffix(Consts.getNextInvocationURLSuffix): + // check if our server is in the correct state + guard case .waitingForLambdaRequest = Self.invocationState else { + self.logger.error("invalid invocation state \(Self.invocationState)") + self.writeResponse(context: context, response: .init(status: .unprocessableEntity)) + return + } + + // pop the first task from the queue + switch Self.invocations.popFirst() { + case .none: + // if there is nothing in the queue, + // create a promise that we can fullfill when we get a new task + let promise = context.eventLoop.makePromise(of: Invocation.self) + promise.futureResult.whenComplete { result in + switch result { + case .failure(let error): + self.logger.error("invocation error: \(error)") + self.writeResponse(context: context, response: .init(status: .internalServerError)) + case .success(let invocation): + Self.invocationState = .waitingForLambdaResponse(invocation) + self.writeResponse(context: context, response: invocation.makeResponse()) + } + } + Self.invocationState = .waitingForInvocation(promise) + case .some(let invocation): + // if there is a task pending, we can immediatly respond with it. + Self.invocationState = .waitingForLambdaResponse(invocation) + self.writeResponse(context: context, response: invocation.makeResponse()) + } + // :requestID/response endpoint is called by the lambda posting the response + case (.POST, let url) where url.hasSuffix(Consts.postResponseURLSuffix): + let parts = request.head.uri.split(separator: "/") + guard let requestID = parts.count > 2 ? String(parts[parts.count - 2]) : nil else { + // the request is malformed, since we were expecting a requestId in the path + return self.writeResponse(context: context, response: .init(status: .badRequest)) + } + guard case .waitingForLambdaResponse(let invocation) = Self.invocationState else { + // a response was send, but we did not expect to receive one + self.logger.error("invalid invocation state \(Self.invocationState)") + return self.writeResponse(context: context, response: .init(status: .unprocessableEntity)) + } + guard requestID == invocation.requestID else { + // the request's requestId is not matching the one we are expecting + self.logger.error("invalid invocation state request ID \(requestID) does not match expected \(invocation.requestID)") + return self.writeResponse(context: context, response: .init(status: .badRequest)) + } + + invocation.responsePromise.succeed(.init(status: .ok, body: request.body)) + self.writeResponse(context: context, response: .init(status: .accepted)) + Self.invocationState = .waitingForLambdaRequest + // unknown call + default: + self.writeResponse(context: context, response: .init(status: .notFound)) + } + } + + func writeResponse(context: ChannelHandlerContext, response: Response) { + var headers = HTTPHeaders(response.headers ?? []) + headers.add(name: "content-length", value: "\(response.body?.readableBytes ?? 0)") + let head = HTTPResponseHead(version: HTTPVersion(major: 1, minor: 1), status: response.status, headers: headers) + + context.write(wrapOutboundOut(.head(head))).whenFailure { error in + self.logger.error("\(self) write error \(error)") + } + + if let buffer = response.body { + context.write(wrapOutboundOut(.body(.byteBuffer(buffer)))).whenFailure { error in + self.logger.error("\(self) write error \(error)") + } + } + + context.writeAndFlush(wrapOutboundOut(.end(nil))).whenComplete { result in + if case .failure(let error) = result { + self.logger.error("\(self) write error \(error)") + } + } + } + + struct Response { + var status: HTTPResponseStatus = .ok + var headers: [(String, String)]? + var body: ByteBuffer? + } + + struct Invocation { + let requestID: String + let request: ByteBuffer + let responsePromise: EventLoopPromise + + func makeResponse() -> Response { + var response = Response() + response.body = self.request + // required headers + response.headers = [ + (AmazonHeaders.requestID, self.requestID), + (AmazonHeaders.invokedFunctionARN, "arn:aws:lambda:us-east-1:\(Int16.random(in: Int16.min ... Int16.max)):function:custom-runtime"), + (AmazonHeaders.traceID, "Root=\(Int16.random(in: Int16.min ... Int16.max));Parent=\(Int16.random(in: Int16.min ... Int16.max));Sampled=1"), + (AmazonHeaders.deadline, "\(DispatchWallTime.distantFuture.millisSinceEpoch)"), + ] + return response + } + } + + enum InvocationState { + case waitingForInvocation(EventLoopPromise) + case waitingForLambdaRequest + case waitingForLambdaResponse(Invocation) + } + } + + enum ServerError: Error { + case notReady + case cantBind + } +} +#endif diff --git a/Sources/AWSLambdaRuntimeCore/Lambda.swift b/Sources/AWSLambdaRuntimeCore/Lambda.swift index bf552b55..1170ce65 100644 --- a/Sources/AWSLambdaRuntimeCore/Lambda.swift +++ b/Sources/AWSLambdaRuntimeCore/Lambda.swift @@ -104,15 +104,19 @@ public enum Lambda { var result: Result! MultiThreadedEventLoopGroup.withCurrentThreadAsEventLoop { eventLoop in let lifecycle = Lifecycle(eventLoop: eventLoop, logger: logger, configuration: configuration, factory: factory) + #if DEBUG let signalSource = trap(signal: configuration.lifecycle.stopSignal) { signal in logger.info("intercepted signal: \(signal)") lifecycle.shutdown() } + #endif lifecycle.start().flatMap { lifecycle.shutdownFuture }.whenComplete { lifecycleResult in + #if DEBUG signalSource.cancel() + #endif eventLoop.shutdownGracefully { error in if let error = error { preconditionFailure("Failed to shutdown eventloop: \(error)") diff --git a/Sources/AWSLambdaRuntimeCore/LambdaContext.swift b/Sources/AWSLambdaRuntimeCore/LambdaContext.swift index ca69cbf7..fef386a4 100644 --- a/Sources/AWSLambdaRuntimeCore/LambdaContext.swift +++ b/Sources/AWSLambdaRuntimeCore/LambdaContext.swift @@ -19,7 +19,7 @@ import NIO extension Lambda { /// Lambda runtime context. /// The Lambda runtime generates and passes the `Context` to the Lambda handler as an argument. - public final class Context { + public final class Context: CustomDebugStringConvertible { /// The request ID, which identifies the request that triggered the function invocation. public let requestId: String @@ -85,5 +85,9 @@ extension Lambda { let remaining = deadline - now return .milliseconds(remaining) } + + public var debugDescription: String { + "\(Self.self)(requestId: \(self.requestId), traceId: \(self.traceId), invokedFunctionArn: \(self.invokedFunctionArn), cognitoIdentity: \(self.cognitoIdentity ?? "nil"), clientContext: \(self.clientContext ?? "nil"), deadline: \(self.deadline))" + } } } diff --git a/Sources/AWSLambdaRuntimeCore/LambdaLifecycle.swift b/Sources/AWSLambdaRuntimeCore/LambdaLifecycle.swift index 03e22cc4..b5f4554a 100644 --- a/Sources/AWSLambdaRuntimeCore/LambdaLifecycle.swift +++ b/Sources/AWSLambdaRuntimeCore/LambdaLifecycle.swift @@ -18,6 +18,8 @@ import NIOConcurrencyHelpers extension Lambda { /// `Lifecycle` manages the Lambda process lifecycle. + /// + /// - note: It is intended to be used within a single `EventLoop`. For this reason this class is not thread safe. public final class Lifecycle { private let eventLoop: EventLoop private let shutdownPromise: EventLoopPromise @@ -25,8 +27,12 @@ extension Lambda { private let configuration: Configuration private let factory: HandlerFactory - private var _state = State.idle - private let stateLock = Lock() + private var state = State.idle { + willSet { + assert(self.eventLoop.inEventLoop, "State may only be changed on the `Lifecycle`'s `eventLoop`") + precondition(newValue.order > self.state.order, "invalid state \(newValue) after \(self.state.order)") + } + } /// Create a new `Lifecycle`. /// @@ -61,8 +67,12 @@ extension Lambda { /// Start the `Lifecycle`. /// - /// - Returns: An `EventLoopFuture` that is fulfilled after the Lambda hander has been created and initiliazed, and a first run has been schduled. + /// - Returns: An `EventLoopFuture` that is fulfilled after the Lambda hander has been created and initiliazed, and a first run has been scheduled. + /// + /// - note: This method must be called on the `EventLoop` the `Lifecycle` has been initialized with. public func start() -> EventLoopFuture { + assert(self.eventLoop.inEventLoop, "Start must be called on the `EventLoop` the `Lifecycle` has been initialized with.") + logger.info("lambda lifecycle starting with \(self.configuration)") self.state = .initializing // triggered when the Lambda has finished its last run @@ -81,10 +91,19 @@ extension Lambda { // MARK: - Private - /// Begin the `Lifecycle` shutdown. + #if DEBUG + /// Begin the `Lifecycle` shutdown. Only needed for debugging purposes, hence behind a `DEBUG` flag. public func shutdown() { - self.state = .shuttingdown + // make this method thread safe by dispatching onto the eventloop + self.eventLoop.execute { + let oldState = self.state + self.state = .shuttingdown + if case .active(let runner, _) = oldState { + runner.cancelWaitingForNextInvocation() + } + } } + #endif private func markShutdown() { self.state = .shutdown @@ -105,6 +124,14 @@ extension Lambda { case .success: // recursive! per aws lambda runtime spec the polling requests are to be done one at a time _run(count + 1) + case .failure(HTTPClient.Errors.cancelled): + if case .shuttingdown = self.state { + // if we ware shutting down, we expect to that the get next + // invocation request might have been cancelled. For this reason we + // succeed the promise here. + return promise.succeed(count) + } + promise.fail(HTTPClient.Errors.cancelled) case .failure(let error): promise.fail(error) } @@ -119,21 +146,6 @@ extension Lambda { _run(0) } - private var state: State { - get { - self.stateLock.withLock { - self._state - } - } - set { - self.stateLock.withLockVoid { - precondition(newValue.order > self._state.order, "invalid state \(newValue) after \(self._state)") - self._state = newValue - } - self.logger.debug("lambda lifecycle state: \(newValue)") - } - } - private enum State { case idle case initializing diff --git a/Sources/AWSLambdaRuntimeCore/LambdaRunner.swift b/Sources/AWSLambdaRuntimeCore/LambdaRunner.swift index 093f6f2e..6489c04f 100644 --- a/Sources/AWSLambdaRuntimeCore/LambdaRunner.swift +++ b/Sources/AWSLambdaRuntimeCore/LambdaRunner.swift @@ -18,10 +18,12 @@ import NIO extension Lambda { /// LambdaRunner manages the Lambda runtime workflow, or business logic. - internal struct Runner { + internal final class Runner { private let runtimeClient: RuntimeClient private let eventLoop: EventLoop + private var isGettingNextInvocation = false + init(eventLoop: EventLoop, configuration: Configuration) { self.eventLoop = eventLoop self.runtimeClient = RuntimeClient(eventLoop: self.eventLoop, configuration: configuration.runtimeEngine) @@ -46,10 +48,12 @@ extension Lambda { func run(logger: Logger, handler: Handler) -> EventLoopFuture { logger.debug("lambda invocation sequence starting") // 1. request invocation from lambda runtime engine + self.isGettingNextInvocation = true return self.runtimeClient.getNextInvocation(logger: logger).peekError { error in - logger.error("could not fetch invocation from lambda runtime engine: \(error)") + logger.error("could not fetch work from lambda runtime engine: \(error)") }.flatMap { invocation, payload in // 2. send invocation to handler + self.isGettingNextInvocation = false let context = Context(logger: logger, eventLoop: self.eventLoop, invocation: invocation) logger.debug("sending invocation to lambda handler \(handler)") return handler.handle(context: context, payload: payload) @@ -69,6 +73,14 @@ extension Lambda { logger.log(level: result.successful ? .debug : .warning, "lambda invocation sequence completed \(result.successful ? "successfully" : "with failure")") } } + + /// cancels the current run, if we are waiting for next invocation (long poll from Lambda control plane) + /// only needed for debugging purposes. + func cancelWaitingForNextInvocation() { + if self.isGettingNextInvocation { + self.runtimeClient.cancel() + } + } } } diff --git a/Sources/AWSLambdaRuntimeCore/LambdaRuntimeClient.swift b/Sources/AWSLambdaRuntimeCore/LambdaRuntimeClient.swift index af00e22a..dd6f6954 100644 --- a/Sources/AWSLambdaRuntimeCore/LambdaRuntimeClient.swift +++ b/Sources/AWSLambdaRuntimeCore/LambdaRuntimeClient.swift @@ -114,6 +114,11 @@ extension Lambda { } } } + + /// Cancels the current request, if one is running. Only needed for debugging purposes + func cancel() { + self.httpClient.cancel() + } } } diff --git a/Sources/AWSLambdaRuntimeCore/Utils.swift b/Sources/AWSLambdaRuntimeCore/Utils.swift index 05b206b9..ae5db50f 100644 --- a/Sources/AWSLambdaRuntimeCore/Utils.swift +++ b/Sources/AWSLambdaRuntimeCore/Utils.swift @@ -16,7 +16,7 @@ import Dispatch import NIO internal enum Consts { - private static let apiPrefix = "/2018-06-01" + static let apiPrefix = "/2018-06-01" static let invocationURLPrefix = "\(apiPrefix)/runtime/invocation" static let getNextInvocationURLSuffix = "/next" static let postResponseURLSuffix = "/response" diff --git a/Sources/AWSLambdaTesting/Lambda+Testing.swift b/Sources/AWSLambdaTesting/Lambda+Testing.swift index da14faa1..eb698075 100644 --- a/Sources/AWSLambdaTesting/Lambda+Testing.swift +++ b/Sources/AWSLambdaTesting/Lambda+Testing.swift @@ -12,9 +12,27 @@ // //===----------------------------------------------------------------------===// -// this is designed to only work for testing +// This functionality is designed to help with Lambda unit testing with XCTest // #if filter required for release builds which do not support @testable import // @testable is used to access of internal functions +// For exmaple: +// +// func test() { +// struct MyLambda: EventLoopLambdaHandler { +// typealias In = String +// typealias Out = String +// +// func handle(context: Lambda.Context, payload: String) -> EventLoopFuture { +// return context.eventLoop.makeSucceededFuture("echo" + payload) +// } +// } +// +// let input = UUID().uuidString +// var result: String? +// XCTAssertNoThrow(result = try Lambda.test(MyLambda(), with: input)) +// XCTAssertEqual(result, "echo" + input) +// } + #if DEBUG @testable import AWSLambdaRuntime @testable import AWSLambdaRuntimeCore diff --git a/Tests/AWSLambdaRuntimeCoreTests/LambdaTest.swift b/Tests/AWSLambdaRuntimeCoreTests/LambdaTest.swift index 764f7bcf..91289691 100644 --- a/Tests/AWSLambdaRuntimeCoreTests/LambdaTest.swift +++ b/Tests/AWSLambdaRuntimeCoreTests/LambdaTest.swift @@ -145,12 +145,13 @@ class LambdaTest: XCTestCase { } let result = Lambda.run(configuration: configuration, factory: { $0.makeSucceededFuture(EchoHandler()) }) - guard case .success(let invocationCount) = result else { - return XCTFail("expected to have not failed") + switch result { + case .success(let invocationCount): + XCTAssertGreaterThan(invocationCount, 0, "should have stopped before any request made") + XCTAssertLessThan(invocationCount, maxTimes, "should have stopped before \(maxTimes)") + case .failure(let error): + XCTFail("Unexpected error: \(error)") } - - XCTAssertGreaterThan(invocationCount, 0, "should have stopped before any request made") - XCTAssertLessThan(invocationCount, maxTimes, "should have stopped before \(maxTimes)") } func testTimeout() { diff --git a/docker/docker-compose.1804.53.yaml b/docker/docker-compose.1804.53.yaml index e200426c..1121f2d2 100644 --- a/docker/docker-compose.1804.53.yaml +++ b/docker/docker-compose.1804.53.yaml @@ -6,7 +6,7 @@ services: image: swift-aws-lambda:18.04-5.3 build: args: - base_image: "swiftlang/swift:nightly-bionic" + base_image: "swiftlang/swift:nightly-5.3-bionic" test: image: swift-aws-lambda:18.04-5.3