//===----------------------------------------------------------------------===// // // This source file is part of the AsyncHTTPClient open source project // // Copyright (c) 2021 Apple Inc. and the AsyncHTTPClient project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information // See CONTRIBUTORS.txt for the list of AsyncHTTPClient project authors // // SPDX-License-Identifier: Apache-2.0 // //===----------------------------------------------------------------------===// import Logging import NIOConcurrencyHelpers import NIOCore import NIOEmbedded import NIOHTTP1 import XCTest @testable import AsyncHTTPClient class HTTP1ClientChannelHandlerTests: XCTestCase { func testResponseBackpressure() { let embedded = EmbeddedChannel() var maybeTestUtils: HTTP1TestTools? XCTAssertNoThrow(maybeTestUtils = try embedded.setupHTTP1Connection()) guard let testUtils = maybeTestUtils else { return XCTFail("Expected connection setup works") } var maybeRequest: HTTPClient.Request? XCTAssertNoThrow(maybeRequest = try HTTPClient.Request(url: "http://localhost/")) guard let request = maybeRequest else { return XCTFail("Expected to be able to create a request") } let delegate = ResponseBackpressureDelegate(eventLoop: embedded.eventLoop) var maybeRequestBag: RequestBag? XCTAssertNoThrow( maybeRequestBag = try RequestBag( request: request, eventLoopPreference: .delegate(on: embedded.eventLoop), task: .init(eventLoop: embedded.eventLoop, logger: testUtils.logger), redirectHandler: nil, connectionDeadline: .now() + .seconds(30), requestOptions: .forTests(), delegate: delegate ) ) guard let requestBag = maybeRequestBag else { return XCTFail("Expected to be able to create a request bag") } testUtils.connection.executeRequest(requestBag) XCTAssertNoThrow( try embedded.receiveHeadAndVerify { XCTAssertEqual($0.method, .GET) XCTAssertEqual($0.uri, "/") XCTAssertEqual($0.headers.first(name: "host"), "localhost") } ) XCTAssertEqual(try embedded.readOutbound(as: HTTPClientRequestPart.self), .end(nil)) let responseHead = HTTPResponseHead( version: .http1_1, status: .ok, headers: HTTPHeaders([("content-length", "12")]) ) XCTAssertEqual(testUtils.readEventHandler.readHitCounter, 0) embedded.read() XCTAssertEqual(testUtils.readEventHandler.readHitCounter, 1) XCTAssertNoThrow(try embedded.writeInbound(HTTPClientResponsePart.head(responseHead))) let part0 = ByteBuffer(bytes: 0...3) let part1 = ByteBuffer(bytes: 4...7) let part2 = ByteBuffer(bytes: 8...11) // part 0. Demand first, read second XCTAssertEqual(testUtils.readEventHandler.readHitCounter, 1) let part0Future = delegate.next() XCTAssertEqual(testUtils.readEventHandler.readHitCounter, 1) embedded.read() XCTAssertEqual(testUtils.readEventHandler.readHitCounter, 2) XCTAssertNoThrow(try embedded.writeInbound(HTTPClientResponsePart.body(part0))) XCTAssertEqual(try part0Future.wait(), part0) // part 1. read first, demand second XCTAssertEqual(testUtils.readEventHandler.readHitCounter, 2) embedded.read() XCTAssertEqual(testUtils.readEventHandler.readHitCounter, 2) let part1Future = delegate.next() XCTAssertEqual(testUtils.readEventHandler.readHitCounter, 3) XCTAssertNoThrow(try embedded.writeInbound(HTTPClientResponsePart.body(part1))) XCTAssertEqual(try part1Future.wait(), part1) // part 2. Demand first, read second XCTAssertEqual(testUtils.readEventHandler.readHitCounter, 3) let part2Future = delegate.next() XCTAssertEqual(testUtils.readEventHandler.readHitCounter, 3) embedded.read() XCTAssertEqual(testUtils.readEventHandler.readHitCounter, 4) XCTAssertNoThrow(try embedded.writeInbound(HTTPClientResponsePart.body(part2))) XCTAssertEqual(try part2Future.wait(), part2) // end. read first, demand second XCTAssertEqual(testUtils.readEventHandler.readHitCounter, 4) embedded.read() XCTAssertEqual(testUtils.readEventHandler.readHitCounter, 4) let endFuture = delegate.next() XCTAssertEqual(testUtils.readEventHandler.readHitCounter, 5) XCTAssertEqual(testUtils.connectionDelegate.hitConnectionReleased, 0) XCTAssertNoThrow(try embedded.writeInbound(HTTPClientResponsePart.end(nil))) XCTAssertEqual(testUtils.connectionDelegate.hitConnectionReleased, 1) XCTAssertEqual(try endFuture.wait(), .none) XCTAssertNoThrow(try requestBag.task.futureResult.wait()) } func testWriteBackpressure() { let embedded = EmbeddedChannel() let testWriter = TestBackpressureWriter(eventLoop: embedded.eventLoop, parts: 50) var maybeTestUtils: HTTP1TestTools? XCTAssertNoThrow(maybeTestUtils = try embedded.setupHTTP1Connection()) guard let testUtils = maybeTestUtils else { return XCTFail("Expected connection setup works") } var maybeRequest: HTTPClient.Request? XCTAssertNoThrow( maybeRequest = try HTTPClient.Request( url: "http://localhost/", method: .POST, body: .stream(contentLength: 100) { writer in testWriter.start(writer: writer) } ) ) guard let request = maybeRequest else { return XCTFail("Expected to be able to create a request") } let delegate = ResponseAccumulator(request: request) var maybeRequestBag: RequestBag? XCTAssertNoThrow( maybeRequestBag = try RequestBag( request: request, eventLoopPreference: .delegate(on: embedded.eventLoop), task: .init(eventLoop: embedded.eventLoop, logger: testUtils.logger), redirectHandler: nil, connectionDeadline: .now() + .seconds(30), requestOptions: .forTests(idleReadTimeout: .milliseconds(200)), delegate: delegate ) ) guard let requestBag = maybeRequestBag else { return XCTFail("Expected to be able to create a request bag") } // the handler only writes once the channel is writable embedded.isWritable = false testWriter.writabilityChanged(false) embedded.pipeline.fireChannelWritabilityChanged() testUtils.connection.executeRequest(requestBag) XCTAssertEqual(try embedded.readOutbound(as: HTTPClientRequestPart.self), .none) embedded.isWritable = true testWriter.writabilityChanged(true) embedded.pipeline.fireChannelWritabilityChanged() XCTAssertNoThrow( try embedded.receiveHeadAndVerify { XCTAssertEqual($0.method, .POST) XCTAssertEqual($0.uri, "/") XCTAssertEqual($0.headers.first(name: "host"), "localhost") XCTAssertEqual($0.headers.first(name: "content-length"), "100") } ) // the next body write will be executed once we tick the el. before we make the channel // unwritable for index in 0..<50 { embedded.isWritable = false testWriter.writabilityChanged(false) embedded.pipeline.fireChannelWritabilityChanged() XCTAssertEqual(testWriter.written, index) embedded.embeddedEventLoop.run() XCTAssertNoThrow( try embedded.receiveBodyAndVerify { XCTAssertEqual($0.readableBytes, 2) } ) XCTAssertEqual(testWriter.written, index + 1) embedded.isWritable = true testWriter.writabilityChanged(true) embedded.pipeline.fireChannelWritabilityChanged() } embedded.embeddedEventLoop.run() XCTAssertNoThrow(try embedded.receiveEnd()) let responseHead = HTTPResponseHead(version: .http1_1, status: .ok) XCTAssertNoThrow(try embedded.writeInbound(HTTPClientResponsePart.head(responseHead))) embedded.read() XCTAssertEqual(testUtils.connectionDelegate.hitConnectionClosed, 0) XCTAssertEqual(testUtils.connectionDelegate.hitConnectionReleased, 0) XCTAssertNoThrow(try embedded.writeInbound(HTTPClientResponsePart.end(nil))) XCTAssertEqual(testUtils.connectionDelegate.hitConnectionClosed, 0) XCTAssertEqual(testUtils.connectionDelegate.hitConnectionReleased, 1) XCTAssertNoThrow(try requestBag.task.futureResult.wait()) } func testClientHandlerCancelsRequestIfWeWantToShutdown() { let embedded = EmbeddedChannel() var maybeTestUtils: HTTP1TestTools? XCTAssertNoThrow(maybeTestUtils = try embedded.setupHTTP1Connection()) guard let testUtils = maybeTestUtils else { return XCTFail("Expected connection setup works") } var maybeRequest: HTTPClient.Request? XCTAssertNoThrow(maybeRequest = try HTTPClient.Request(url: "http://localhost/")) guard let request = maybeRequest else { return XCTFail("Expected to be able to create a request") } let delegate = ResponseAccumulator(request: request) var maybeRequestBag: RequestBag? XCTAssertNoThrow( maybeRequestBag = try RequestBag( request: request, eventLoopPreference: .delegate(on: embedded.eventLoop), task: .init(eventLoop: embedded.eventLoop, logger: testUtils.logger), redirectHandler: nil, connectionDeadline: .now() + .seconds(30), requestOptions: .forTests(idleReadTimeout: .milliseconds(200)), delegate: delegate ) ) guard let requestBag = maybeRequestBag else { return XCTFail("Expected to be able to create a request bag") } testUtils.connection.executeRequest(requestBag) XCTAssertNoThrow( try embedded.receiveHeadAndVerify { XCTAssertEqual($0.method, .GET) XCTAssertEqual($0.uri, "/") XCTAssertEqual($0.headers.first(name: "host"), "localhost") } ) XCTAssertNoThrow(try embedded.receiveEnd()) XCTAssertTrue(embedded.isActive) XCTAssertEqual(testUtils.connectionDelegate.hitConnectionClosed, 0) XCTAssertEqual(testUtils.connectionDelegate.hitConnectionReleased, 0) testUtils.connection.shutdown() XCTAssertFalse(embedded.isActive) embedded.embeddedEventLoop.run() XCTAssertEqual(testUtils.connectionDelegate.hitConnectionClosed, 1) XCTAssertEqual(testUtils.connectionDelegate.hitConnectionReleased, 0) XCTAssertThrowsError(try requestBag.task.futureResult.wait()) { XCTAssertEqual($0 as? HTTPClientError, .cancelled) } } func testIdleReadTimeout() { let embedded = EmbeddedChannel() var maybeTestUtils: HTTP1TestTools? XCTAssertNoThrow(maybeTestUtils = try embedded.setupHTTP1Connection()) guard let testUtils = maybeTestUtils else { return XCTFail("Expected connection setup works") } var maybeRequest: HTTPClient.Request? XCTAssertNoThrow(maybeRequest = try HTTPClient.Request(url: "http://localhost/")) guard let request = maybeRequest else { return XCTFail("Expected to be able to create a request") } let delegate = ResponseBackpressureDelegate(eventLoop: embedded.eventLoop) var maybeRequestBag: RequestBag? XCTAssertNoThrow( maybeRequestBag = try RequestBag( request: request, eventLoopPreference: .delegate(on: embedded.eventLoop), task: .init(eventLoop: embedded.eventLoop, logger: testUtils.logger), redirectHandler: nil, connectionDeadline: .now() + .seconds(30), requestOptions: .forTests(idleReadTimeout: .milliseconds(200)), delegate: delegate ) ) guard let requestBag = maybeRequestBag else { return XCTFail("Expected to be able to create a request bag") } testUtils.connection.executeRequest(requestBag) XCTAssertNoThrow( try embedded.receiveHeadAndVerify { XCTAssertEqual($0.method, .GET) XCTAssertEqual($0.uri, "/") XCTAssertEqual($0.headers.first(name: "host"), "localhost") } ) XCTAssertNoThrow(try embedded.receiveEnd()) let responseHead = HTTPResponseHead( version: .http1_1, status: .ok, headers: HTTPHeaders([("content-length", "12")]) ) XCTAssertEqual(testUtils.readEventHandler.readHitCounter, 0) embedded.read() XCTAssertEqual(testUtils.readEventHandler.readHitCounter, 1) XCTAssertNoThrow(try embedded.writeInbound(HTTPClientResponsePart.head(responseHead))) // not sending anything after the head should lead to request fail and connection close XCTAssertEqual(testUtils.connectionDelegate.hitConnectionClosed, 0) XCTAssertEqual(testUtils.connectionDelegate.hitConnectionReleased, 0) embedded.embeddedEventLoop.advanceTime(by: .milliseconds(250)) XCTAssertEqual(testUtils.connectionDelegate.hitConnectionClosed, 1) XCTAssertEqual(testUtils.connectionDelegate.hitConnectionReleased, 0) XCTAssertThrowsError(try requestBag.task.futureResult.wait()) { XCTAssertEqual($0 as? HTTPClientError, .readTimeout) } } func testIdleReadTimeoutIsCanceledIfRequestIsCanceled() { let embedded = EmbeddedChannel() var maybeTestUtils: HTTP1TestTools? XCTAssertNoThrow(maybeTestUtils = try embedded.setupHTTP1Connection()) guard let testUtils = maybeTestUtils else { return XCTFail("Expected connection setup works") } var maybeRequest: HTTPClient.Request? XCTAssertNoThrow(maybeRequest = try HTTPClient.Request(url: "http://localhost/")) guard let request = maybeRequest else { return XCTFail("Expected to be able to create a request") } let delegate = ResponseBackpressureDelegate(eventLoop: embedded.eventLoop) var maybeRequestBag: RequestBag? XCTAssertNoThrow( maybeRequestBag = try RequestBag( request: request, eventLoopPreference: .delegate(on: embedded.eventLoop), task: .init(eventLoop: embedded.eventLoop, logger: testUtils.logger), redirectHandler: nil, connectionDeadline: .now() + .seconds(30), requestOptions: .forTests(idleReadTimeout: .milliseconds(200)), delegate: delegate ) ) guard let requestBag = maybeRequestBag else { return XCTFail("Expected to be able to create a request bag") } testUtils.connection.executeRequest(requestBag) XCTAssertNoThrow( try embedded.receiveHeadAndVerify { XCTAssertEqual($0.method, .GET) XCTAssertEqual($0.uri, "/") XCTAssertEqual($0.headers.first(name: "host"), "localhost") } ) XCTAssertNoThrow(try embedded.receiveEnd()) let responseHead = HTTPResponseHead( version: .http1_1, status: .ok, headers: HTTPHeaders([("content-length", "12")]) ) XCTAssertEqual(testUtils.readEventHandler.readHitCounter, 0) embedded.read() XCTAssertEqual(testUtils.readEventHandler.readHitCounter, 1) XCTAssertNoThrow(try embedded.writeInbound(HTTPClientResponsePart.head(responseHead))) // canceling the request requestBag.fail(HTTPClientError.cancelled) XCTAssertThrowsError(try requestBag.task.futureResult.wait()) { XCTAssertEqual($0 as? HTTPClientError, .cancelled) } // the idle read timeout should be cleared because we canceled the request // therefore advancing the time should not trigger a crash embedded.embeddedEventLoop.advanceTime(by: .milliseconds(250)) } func testIdleWriteTimeout() { let embedded = EmbeddedChannel() let testWriter = TestBackpressureWriter(eventLoop: embedded.eventLoop, parts: 5) var maybeTestUtils: HTTP1TestTools? XCTAssertNoThrow(maybeTestUtils = try embedded.setupHTTP1Connection()) guard let testUtils = maybeTestUtils else { return XCTFail("Expected connection setup works") } var maybeRequest: HTTPClient.Request? XCTAssertNoThrow( maybeRequest = try HTTPClient.Request( url: "http://localhost/", method: .POST, body: .stream(contentLength: 10) { writer in // Advance time by more than the idle write timeout (that's 1 millisecond) to trigger the timeout. embedded.embeddedEventLoop.advanceTime(by: .milliseconds(2)) return testWriter.start(writer: writer) } ) ) guard let request = maybeRequest else { return XCTFail("Expected to be able to create a request") } let delegate = ResponseAccumulator(request: request) var maybeRequestBag: RequestBag? XCTAssertNoThrow( maybeRequestBag = try RequestBag( request: request, eventLoopPreference: .delegate(on: embedded.eventLoop), task: .init(eventLoop: embedded.eventLoop, logger: testUtils.logger), redirectHandler: nil, connectionDeadline: .now() + .seconds(30), requestOptions: .forTests(idleWriteTimeout: .milliseconds(1)), delegate: delegate ) ) guard let requestBag = maybeRequestBag else { return XCTFail("Expected to be able to create a request bag") } embedded.isWritable = true testWriter.writabilityChanged(true) embedded.pipeline.fireChannelWritabilityChanged() testUtils.connection.executeRequest(requestBag) XCTAssertThrowsError(try requestBag.task.futureResult.wait()) { XCTAssertEqual($0 as? HTTPClientError, .writeTimeout) } } func testIdleWriteTimeoutRaceToEnd() { let embedded = EmbeddedChannel() var maybeTestUtils: HTTP1TestTools? XCTAssertNoThrow(maybeTestUtils = try embedded.setupHTTP1Connection()) guard let testUtils = maybeTestUtils else { return XCTFail("Expected connection setup works") } var maybeRequest: HTTPClient.Request? XCTAssertNoThrow( maybeRequest = try HTTPClient.Request( url: "http://localhost/", method: .POST, body: .stream { _ in // Advance time by more than the idle write timeout (that's 1 millisecond) to trigger the timeout. let scheduled = embedded.embeddedEventLoop.flatScheduleTask(in: .milliseconds(2)) { embedded.embeddedEventLoop.makeSucceededVoidFuture() } return scheduled.futureResult } ) ) guard let request = maybeRequest else { return XCTFail("Expected to be able to create a request") } let delegate = ResponseAccumulator(request: request) var maybeRequestBag: RequestBag? XCTAssertNoThrow( maybeRequestBag = try RequestBag( request: request, eventLoopPreference: .delegate(on: embedded.eventLoop), task: .init(eventLoop: embedded.eventLoop, logger: testUtils.logger), redirectHandler: nil, connectionDeadline: .now() + .seconds(30), requestOptions: .forTests(idleWriteTimeout: .milliseconds(5)), delegate: delegate ) ) guard let requestBag = maybeRequestBag else { return XCTFail("Expected to be able to create a request bag") } embedded.isWritable = true embedded.pipeline.fireChannelWritabilityChanged() testUtils.connection.executeRequest(requestBag) let expectedHeaders: HTTPHeaders = ["host": "localhost", "Transfer-Encoding": "chunked"] XCTAssertEqual( try embedded.readOutbound(as: HTTPClientRequestPart.self), .head(HTTPRequestHead(version: .http1_1, method: .POST, uri: "/", headers: expectedHeaders)) ) // change the writability to false. embedded.isWritable = false embedded.pipeline.fireChannelWritabilityChanged() embedded.embeddedEventLoop.run() // let the writer, write an end (while writability is false) embedded.embeddedEventLoop.advanceTime(by: .milliseconds(2)) XCTAssertEqual(try embedded.readOutbound(as: HTTPClientRequestPart.self), .end(nil)) } func testIdleWriteTimeoutWritabilityChanged() { let embedded = EmbeddedChannel() let testWriter = TestBackpressureWriter(eventLoop: embedded.eventLoop, parts: 5) var maybeTestUtils: HTTP1TestTools? XCTAssertNoThrow(maybeTestUtils = try embedded.setupHTTP1Connection()) guard let testUtils = maybeTestUtils else { return XCTFail("Expected connection setup works") } var maybeRequest: HTTPClient.Request? XCTAssertNoThrow( maybeRequest = try HTTPClient.Request( url: "http://localhost/", method: .POST, body: .stream(contentLength: 10) { writer in embedded.isWritable = false embedded.pipeline.fireChannelWritabilityChanged() // This should not trigger any errors or timeouts, because the timer isn't running // as the channel is not writable. embedded.embeddedEventLoop.advanceTime(by: .milliseconds(20)) // Now that the channel will become writable, this should trigger a timeout. embedded.isWritable = true embedded.pipeline.fireChannelWritabilityChanged() embedded.embeddedEventLoop.advanceTime(by: .milliseconds(2)) return testWriter.start(writer: writer) } ) ) guard let request = maybeRequest else { return XCTFail("Expected to be able to create a request") } let delegate = ResponseAccumulator(request: request) var maybeRequestBag: RequestBag? XCTAssertNoThrow( maybeRequestBag = try RequestBag( request: request, eventLoopPreference: .delegate(on: embedded.eventLoop), task: .init(eventLoop: embedded.eventLoop, logger: testUtils.logger), redirectHandler: nil, connectionDeadline: .now() + .seconds(30), requestOptions: .forTests(idleWriteTimeout: .milliseconds(1)), delegate: delegate ) ) guard let requestBag = maybeRequestBag else { return XCTFail("Expected to be able to create a request bag") } embedded.isWritable = true testWriter.writabilityChanged(true) embedded.pipeline.fireChannelWritabilityChanged() testUtils.connection.executeRequest(requestBag) XCTAssertThrowsError(try requestBag.task.futureResult.wait()) { XCTAssertEqual($0 as? HTTPClientError, .writeTimeout) } } func testIdleWriteTimeoutIsCancelledIfRequestIsCancelled() { let embedded = EmbeddedChannel() let testWriter = TestBackpressureWriter(eventLoop: embedded.eventLoop, parts: 1) var maybeTestUtils: HTTP1TestTools? XCTAssertNoThrow(maybeTestUtils = try embedded.setupHTTP1Connection()) guard let testUtils = maybeTestUtils else { return XCTFail("Expected connection setup works") } var maybeRequest: HTTPClient.Request? XCTAssertNoThrow( maybeRequest = try HTTPClient.Request( url: "http://localhost/", method: .POST, body: .stream(contentLength: 2) { writer in testWriter.start(writer: writer, expectedErrors: [HTTPClientError.cancelled]) } ) ) guard let request = maybeRequest else { return XCTFail("Expected to be able to create a request") } let delegate = ResponseAccumulator(request: request) var maybeRequestBag: RequestBag? XCTAssertNoThrow( maybeRequestBag = try RequestBag( request: request, eventLoopPreference: .delegate(on: embedded.eventLoop), task: .init(eventLoop: embedded.eventLoop, logger: testUtils.logger), redirectHandler: nil, connectionDeadline: .now() + .seconds(30), requestOptions: .forTests(idleWriteTimeout: .milliseconds(1)), delegate: delegate ) ) guard let requestBag = maybeRequestBag else { return XCTFail("Expected to be able to create a request bag") } embedded.isWritable = true testWriter.writabilityChanged(true) embedded.pipeline.fireChannelWritabilityChanged() testUtils.connection.executeRequest(requestBag) // canceling the request requestBag.fail(HTTPClientError.cancelled) XCTAssertThrowsError(try requestBag.task.futureResult.wait()) { XCTAssertEqual($0 as? HTTPClientError, .cancelled) } // the idle write timeout should be cleared because we canceled the request // therefore advancing the time should not trigger a crash embedded.embeddedEventLoop.advanceTime(by: .milliseconds(250)) } func testFailHTTPRequestWithContentLengthBecauseOfChannelInactiveWaitingForDemand() { let embedded = EmbeddedChannel() var maybeTestUtils: HTTP1TestTools? XCTAssertNoThrow(maybeTestUtils = try embedded.setupHTTP1Connection()) guard let testUtils = maybeTestUtils else { return XCTFail("Expected connection setup works") } var maybeRequest: HTTPClient.Request? XCTAssertNoThrow(maybeRequest = try HTTPClient.Request(url: "http://localhost/")) guard let request = maybeRequest else { return XCTFail("Expected to be able to create a request") } let delegate = ResponseBackpressureDelegate(eventLoop: embedded.eventLoop) var maybeRequestBag: RequestBag? XCTAssertNoThrow( maybeRequestBag = try RequestBag( request: request, eventLoopPreference: .delegate(on: embedded.eventLoop), task: .init(eventLoop: embedded.eventLoop, logger: testUtils.logger), redirectHandler: nil, connectionDeadline: .now() + .seconds(30), requestOptions: .forTests(), delegate: delegate ) ) guard let requestBag = maybeRequestBag else { return XCTFail("Expected to be able to create a request bag") } testUtils.connection.executeRequest(requestBag) XCTAssertNoThrow( try embedded.receiveHeadAndVerify { XCTAssertEqual($0.method, .GET) XCTAssertEqual($0.uri, "/") XCTAssertEqual($0.headers.first(name: "host"), "localhost") } ) XCTAssertNoThrow(try embedded.receiveEnd()) let responseHead = HTTPResponseHead( version: .http1_1, status: .ok, headers: HTTPHeaders([("content-length", "50")]) ) XCTAssertEqual(testUtils.readEventHandler.readHitCounter, 0) embedded.read() XCTAssertEqual(testUtils.readEventHandler.readHitCounter, 1) XCTAssertNoThrow(try embedded.writeInbound(HTTPClientResponsePart.head(responseHead))) // not sending anything after the head should lead to request fail and connection close embedded.pipeline.fireChannelReadComplete() embedded.pipeline.read() XCTAssertEqual(testUtils.readEventHandler.readHitCounter, 2) XCTAssertNoThrow(try embedded.writeInbound(HTTPClientResponsePart.body(ByteBuffer(string: "foo bar")))) embedded.pipeline.fireChannelReadComplete() // We miss a `embedded.pipeline.read()` here by purpose. XCTAssertEqual(testUtils.readEventHandler.readHitCounter, 2) XCTAssertNoThrow(try embedded.writeInbound(HTTPClientResponsePart.body(ByteBuffer(string: "last bytes")))) embedded.pipeline.fireChannelReadComplete() embedded.pipeline.fireChannelInactive() XCTAssertThrowsError(try requestBag.task.futureResult.wait()) { XCTAssertEqual($0 as? HTTPClientError, .remoteConnectionClosed) } } func testWriteHTTPHeadFails() { struct WriteError: Error, Equatable {} class FailWriteHandler: ChannelOutboundHandler { typealias OutboundIn = HTTPClientRequestPart typealias OutboundOut = HTTPClientRequestPart func write(context: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise?) { let error = WriteError() promise?.fail(error) context.fireErrorCaught(error) } } let bodies: [HTTPClient.Body?] = [ .none, .some(.byteBuffer(ByteBuffer(string: "hello world"))), ] for body in bodies { let embedded = EmbeddedChannel() var maybeTestUtils: HTTP1TestTools? XCTAssertNoThrow(maybeTestUtils = try embedded.setupHTTP1Connection()) guard let testUtils = maybeTestUtils else { return XCTFail("Expected connection setup works") } XCTAssertNoThrow( try embedded.pipeline.syncOperations.addHandler( FailWriteHandler(), position: .after(testUtils.readEventHandler) ) ) let logger = Logger(label: "test") var maybeRequest: HTTPClient.Request? XCTAssertNoThrow(maybeRequest = try HTTPClient.Request(url: "http://localhost/", method: .POST, body: body)) guard let request = maybeRequest else { return XCTFail("Expected to be able to create a request") } let delegate = ResponseAccumulator(request: request) var maybeRequestBag: RequestBag? XCTAssertNoThrow( maybeRequestBag = try RequestBag( request: request, eventLoopPreference: .delegate(on: embedded.eventLoop), task: .init(eventLoop: embedded.eventLoop, logger: logger), redirectHandler: nil, connectionDeadline: .now() + .seconds(30), requestOptions: .forTests(idleReadTimeout: .milliseconds(200)), delegate: delegate ) ) guard let requestBag = maybeRequestBag else { return XCTFail("Expected to be able to create a request bag") } embedded.isWritable = false XCTAssertNoThrow(try embedded.connect(to: .makeAddressResolvingHost("localhost", port: 0)).wait()) embedded.write(requestBag, promise: nil) // the handler only writes once the channel is writable XCTAssertEqual(try embedded.readOutbound(as: HTTPClientRequestPart.self), .none) embedded.isWritable = true embedded.pipeline.fireChannelWritabilityChanged() XCTAssertThrowsError(try requestBag.task.futureResult.wait()) { XCTAssertEqual($0 as? WriteError, WriteError()) } XCTAssertEqual(embedded.isActive, false) } } func testHandlerClosesChannelIfLastActionIsSendEndAndItFails() { let embedded = EmbeddedChannel() let testWriter = TestBackpressureWriter(eventLoop: embedded.eventLoop, parts: 5) var maybeTestUtils: HTTP1TestTools? XCTAssertNoThrow(maybeTestUtils = try embedded.setupHTTP1Connection()) guard let testUtils = maybeTestUtils else { return XCTFail("Expected connection setup works") } var maybeRequest: HTTPClient.Request? XCTAssertNoThrow( maybeRequest = try HTTPClient.Request( url: "http://localhost/", method: .POST, body: .stream(contentLength: 10) { writer in testWriter.start(writer: writer) } ) ) guard let request = maybeRequest else { return XCTFail("Expected to be able to create a request") } let delegate = ResponseAccumulator(request: request) var maybeRequestBag: RequestBag? XCTAssertNoThrow( maybeRequestBag = try RequestBag( request: request, eventLoopPreference: .delegate(on: embedded.eventLoop), task: .init(eventLoop: embedded.eventLoop, logger: testUtils.logger), redirectHandler: nil, connectionDeadline: .now() + .seconds(30), requestOptions: .forTests(idleReadTimeout: .milliseconds(200)), delegate: delegate ) ) guard let requestBag = maybeRequestBag else { return XCTFail("Expected to be able to create a request bag") } XCTAssertNoThrow(try embedded.pipeline.addHandler(FailEndHandler(), position: .first).wait()) // Execute the request and we'll receive the head. testWriter.writabilityChanged(true) testUtils.connection.executeRequest(requestBag) XCTAssertNoThrow( try embedded.receiveHeadAndVerify { XCTAssertEqual($0.method, .POST) XCTAssertEqual($0.uri, "/") XCTAssertEqual($0.headers.first(name: "host"), "localhost") XCTAssertEqual($0.headers.first(name: "content-length"), "10") } ) // We're going to immediately send the response head and end. let responseHead = HTTPResponseHead(version: .http1_1, status: .ok) XCTAssertNoThrow(try embedded.writeInbound(HTTPClientResponsePart.head(responseHead))) embedded.read() // Send the end and confirm the connection is still live. XCTAssertNoThrow(try embedded.writeInbound(HTTPClientResponsePart.end(nil))) XCTAssertEqual(testUtils.connectionDelegate.hitConnectionClosed, 0) XCTAssertEqual(testUtils.connectionDelegate.hitConnectionReleased, 0) // Ok, now we can process some reads. We expect 5 reads, but we do _not_ expect an .end, because // the `FailEndHandler` is going to fail it. embedded.embeddedEventLoop.run() XCTAssertEqual(testWriter.written, 5) for _ in 0..<5 { XCTAssertNoThrow( try embedded.receiveBodyAndVerify { XCTAssertEqual($0.readableBytes, 2) } ) } embedded.embeddedEventLoop.run() XCTAssertNil(try embedded.readOutbound(as: HTTPClientRequestPart.self)) // We should have seen the connection close, and the request is complete. XCTAssertEqual(testUtils.connectionDelegate.hitConnectionClosed, 1) XCTAssertEqual(testUtils.connectionDelegate.hitConnectionReleased, 0) XCTAssertThrowsError(try requestBag.task.futureResult.wait()) { error in XCTAssertTrue(error is FailEndHandler.Error) } } func testChannelBecomesNonWritableDuringHeaderWrite() throws { final class ChangeWritabilityOnFlush: ChannelOutboundHandler { typealias OutboundIn = Any func flush(context: ChannelHandlerContext) { context.flush() (context.channel as! EmbeddedChannel).isWritable = false context.fireChannelWritabilityChanged() } } let eventLoopGroup = EmbeddedEventLoopGroup(loops: 1) let eventLoop = eventLoopGroup.next() as! EmbeddedEventLoop let handler = HTTP1ClientChannelHandler( eventLoop: eventLoop, backgroundLogger: Logger(label: "no-op", factory: SwiftLogNoOpLogHandler.init), connectionIdLoggerMetadata: "test connection" ) let channel = EmbeddedChannel( handlers: [ ChangeWritabilityOnFlush(), handler, ], loop: eventLoop ) try channel.connect(to: .init(ipAddress: "127.0.0.1", port: 80)).wait() // non empty body is important to trigger this bug as we otherwise finish the request in a single flush let request = MockHTTPExecutableRequest( framingMetadata: RequestFramingMetadata(connectionClose: false, body: .fixedSize(1)), raiseErrorIfUnimplementedMethodIsCalled: false ) channel.writeAndFlush(request, promise: nil) XCTAssertEqual(request.events.map(\.kind), [.willExecuteRequest, .requestHeadSent]) } func testIdleWriteTimeoutOutsideOfRunningState() { let embedded = EmbeddedChannel() var maybeTestUtils: HTTP1TestTools? XCTAssertNoThrow(maybeTestUtils = try embedded.setupHTTP1Connection()) print("pipeline", embedded.pipeline) guard let testUtils = maybeTestUtils else { return XCTFail("Expected connection setup works") } var maybeRequest: HTTPClient.Request? XCTAssertNoThrow(maybeRequest = try HTTPClient.Request(url: "http://localhost/")) guard var request = maybeRequest else { return XCTFail("Expected to be able to create a request") } // start a request stream we'll never write to let streamPromise = embedded.eventLoop.makePromise(of: Void.self) let streamCallback = { @Sendable (streamWriter: HTTPClient.Body.StreamWriter) -> EventLoopFuture in streamPromise.futureResult } request.body = .init(contentLength: nil, stream: streamCallback) let accumulator = ResponseAccumulator(request: request) var maybeRequestBag: RequestBag? XCTAssertNoThrow( maybeRequestBag = try RequestBag( request: request, eventLoopPreference: .delegate(on: embedded.eventLoop), task: .init(eventLoop: embedded.eventLoop, logger: testUtils.logger), redirectHandler: nil, connectionDeadline: .now() + .seconds(30), requestOptions: .forTests( idleReadTimeout: .milliseconds(10), idleWriteTimeout: .milliseconds(2) ), delegate: accumulator ) ) guard let requestBag = maybeRequestBag else { return XCTFail("Expected to be able to create a request bag") } testUtils.connection.executeRequest(requestBag) XCTAssertNoThrow( try embedded.receiveHeadAndVerify { XCTAssertEqual($0.method, .GET) XCTAssertEqual($0.uri, "/") XCTAssertEqual($0.headers.first(name: "host"), "localhost") } ) // close the pipeline to simulate a server-side close // note this happens before we write so the idle write timeout is still running try! embedded.pipeline.close().wait() // advance time to trigger the idle write timeout // and ensure that the state machine can tolerate this embedded.embeddedEventLoop.advanceTime(by: .milliseconds(250)) } func testSendingAndReceivingTrailers() async throws { let eventLoop = EmbeddedEventLoop() let handler = HTTP1ClientChannelHandler( eventLoop: eventLoop, backgroundLogger: Logger(label: "no-op", factory: SwiftLogNoOpLogHandler.init), connectionIdLoggerMetadata: "test connection" ) let channel = EmbeddedChannel(handlers: [handler], loop: eventLoop) XCTAssertNoThrow(try channel.connect(to: .init(ipAddress: "127.0.0.1", port: 80)).wait()) // non empty body is important to allow sending trailers as the request is finished in a single flush otherwise let request = MockHTTPExecutableRequest( head: .init(version: .http1_1, method: .POST, uri: "http://localhost/"), framingMetadata: RequestFramingMetadata(connectionClose: false, body: .stream), raiseErrorIfUnimplementedMethodIsCalled: false ) let executor = handler.requestExecutor request.resumeRequestBodyStreamCallback = { executor.writeRequestBodyPart(.byteBuffer(.init(string: "Hello World")), request: request, promise: nil) executor.finishRequestBodyStream(trailers: ["trailer": "foo"], request: request, promise: nil) } request.receiveResponseEndCallback = { (_, trailers) in XCTAssertEqual(trailers, ["trailer": "bar"]) } channel.write(request, promise: nil) XCTAssertEqual(try channel.readOutbound(as: HTTPClientRequestPart.self), .head(request.requestHead)) XCTAssertEqual( try channel.readOutbound(as: HTTPClientRequestPart.self), .body(.byteBuffer(.init(string: "Hello World"))) ) XCTAssertEqual(try channel.readOutbound(as: HTTPClientRequestPart.self), .end(["trailer": "foo"])) XCTAssertNoThrow(try channel.writeInbound(HTTPClientResponsePart.head(.init(version: .http1_1, status: .ok)))) XCTAssertNoThrow(try channel.writeInbound(HTTPClientResponsePart.body(.init(string: "Foo Bar")))) XCTAssertNoThrow(try channel.writeInbound(HTTPClientResponsePart.end(["trailer": "bar"]))) XCTAssertEqual( request.events.map(\.kind), [ .willExecuteRequest, .requestHeadSent, .resumeRequestBodyStream, .requestBodySent, .receiveResponseHead, .receiveResponseEnd, ] ) } func testDemandResponseBodyStreamAfterEarlyResponseDoesNotCrash() async throws { // This test reproduces a crash where `demandMoreResponseBodyParts()` was called on the // state machine after it had already transitioned to `.idle`. The scenario is: // // 1. A streaming POST request is in progress // 2. The full response (head + end) arrives before the request body is finished // 3. The response end is forwarded with `finalAction: .none` (body still uploading) // 4. The request body stream finishes -> `requestStreamFinished` -> state machine // transitions to `.idle` and returns `.sendRequestEnd(.informConnectionIsIdle)` // 5. The `.sendRequestEnd` handler writes `.end` to the channel and registers a // callback on the write promise // 6. Before the write callback fires (write hasn't completed yet), // `demandResponseBodyStream` is called (from a delegate on another event loop) // 7. Without the fix, `self.request` was still set (only nilled in the write // callback), so the guard passed and `demandMoreResponseBodyParts()` hit // `fatalError("Invalid state: idle")` // // The fix nils out `self.request` synchronously in `.sendRequestEnd` (before the // write callback), so the guard in `demandResponseBodyStream0` fails and returns early. final class DelayEndHandler: ChannelOutboundHandler { typealias OutboundIn = HTTPClientRequestPart typealias OutboundOut = HTTPClientRequestPart private(set) var endPromise: EventLoopPromise? func write(context: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise?) { if case .end = self.unwrapOutboundIn(data) { self.endPromise = promise context.write(data, promise: nil) } else { context.write(data, promise: promise) } } } let eventLoop = EmbeddedEventLoop() let delayEndHandler = DelayEndHandler() let handler = HTTP1ClientChannelHandler( eventLoop: eventLoop, backgroundLogger: Logger(label: "no-op", factory: SwiftLogNoOpLogHandler.init), connectionIdLoggerMetadata: "test connection" ) var connectionIsIdle = false handler.onConnectionIdle = { connectionIsIdle = true } let channel = EmbeddedChannel(handlers: [delayEndHandler, handler], loop: eventLoop) XCTAssertNoThrow(try channel.connect(to: .init(ipAddress: "127.0.0.1", port: 80)).wait()) let request = MockHTTPExecutableRequest( head: .init(version: .http1_1, method: .POST, uri: "http://localhost/"), framingMetadata: RequestFramingMetadata(connectionClose: false, body: .stream), raiseErrorIfUnimplementedMethodIsCalled: false ) let executor = handler.requestExecutor // When the body stream is resumed, write one part but do NOT finish the stream yet. request.resumeRequestBodyStreamCallback = { executor.writeRequestBodyPart(.byteBuffer(.init(string: "Hello")), request: request, promise: nil) } // Start the request channel.write(request, promise: nil) // Verify request head was sent XCTAssertEqual(try channel.readOutbound(as: HTTPClientRequestPart.self), .head(request.requestHead)) // Verify body part was sent XCTAssertEqual( try channel.readOutbound(as: HTTPClientRequestPart.self), .body(.byteBuffer(.init(string: "Hello"))) ) // Now send the full response while the request body stream is still open. // This causes forwardResponseEnd with finalAction: .none (body not done yet). XCTAssertNoThrow(try channel.writeInbound(HTTPClientResponsePart.head(.init(version: .http1_1, status: .ok)))) // Issue a read to advance the response stream state so it accepts the end properly. channel.read() XCTAssertNoThrow(try channel.writeInbound(HTTPClientResponsePart.end(nil))) // Finish the request body stream. This transitions the state machine to `.idle` // and writes `.end` to the channel. The DelayEndHandler intercepts the `.end` // write and holds the promise, preventing the write callback from firing. executor.finishRequestBodyStream(trailers: nil, request: request, promise: nil) // Verify the .end was written through to the channel XCTAssertEqual(try channel.readOutbound(as: HTTPClientRequestPart.self), .end(nil)) // At this point: // - The state machine has transitioned to `.idle` // - The write promise has NOT been fulfilled (held by DelayEndHandler) // - In old code: self.request is still set (only nilled in the write callback) // - In fixed code: self.request is already nil (nilled synchronously) // Now call demandResponseBodyStream, simulating a delegate on a different event // loop calling it after receiving the response end but before the write completes. // Without the fix, self.request is still set, the guard passes, and // state.demandMoreResponseBodyParts() crashes with "Invalid state: idle". // With the fix, self.request was already nilled, the guard fails, and this is a no-op. executor.demandResponseBodyStream(request) // Complete the delayed write to clean up properly. delayEndHandler.endPromise?.succeed(()) eventLoop.run() XCTAssertTrue(connectionIsIdle) XCTAssertEqual( request.events.map(\.kind), [ .willExecuteRequest, .requestHeadSent, .resumeRequestBodyStream, .receiveResponseHead, .receiveResponseEnd, .requestBodySent, ] ) } func testDefaultMaxBufferSize() { if MemoryLayout.size == 8 { XCTAssertEqual(ResponseAccumulator.maxByteBufferSize, Int(UInt32.max)) } else if MemoryLayout.size == 4 { XCTAssertEqual(ResponseAccumulator.maxByteBufferSize, Int(Int32.max)) } else { XCTFail("What platform is this?") } } } final class TestBackpressureWriter: Sendable { let eventLoop: EventLoop let parts: Int var finishFuture: EventLoopFuture { self.finishPromise.futureResult } private let finishPromise: EventLoopPromise private struct State { var written = 0 var channelIsWritable = false } var written: Int { self.state.value.written } private let state: NIOLoopBoundBox init(eventLoop: EventLoop, parts: Int) { self.eventLoop = eventLoop self.parts = parts self.state = .makeBoxSendingValue(State(), eventLoop: eventLoop) self.finishPromise = eventLoop.makePromise(of: Void.self) } func start(writer: HTTPClient.Body.StreamWriter, expectedErrors: [HTTPClientError] = []) -> EventLoopFuture { @Sendable func recursive() { XCTAssert(self.eventLoop.inEventLoop) XCTAssert(self.state.value.channelIsWritable) if self.state.value.written == self.parts { self.finishPromise.succeed(()) } else { self.eventLoop.execute { let future = writer.write(.byteBuffer(.init(bytes: [0, 1]))) self.state.value.written += 1 future.whenComplete { result in switch result { case .success: recursive() case .failure(let error): let isExpectedError = expectedErrors.contains { httpError in if let castError = error as? HTTPClientError { return castError == httpError } return false } if !isExpectedError { XCTFail("Unexpected error: \(error)") } } } } } } recursive() return self.finishFuture } func writabilityChanged(_ newValue: Bool) { self.state.value.channelIsWritable = newValue } } final class ResponseBackpressureDelegate: HTTPClientResponseDelegate { typealias Response = Void enum State: Sendable { case consuming(EventLoopPromise) case waitingForRemote(CircularBuffer>) case buffering((ByteBuffer?, EventLoopPromise)?) case done } let eventLoop: EventLoop private let state: NIOLoopBoundBox init(eventLoop: EventLoop) { self.eventLoop = eventLoop self.state = .makeBoxSendingValue(.consuming(eventLoop.makePromise(of: Void.self)), eventLoop: eventLoop) } func next() -> EventLoopFuture { switch self.state.value { case .consuming(let backpressurePromise): var promiseBuffer = CircularBuffer>() let newPromise = self.eventLoop.makePromise(of: ByteBuffer?.self) promiseBuffer.append(newPromise) self.state.value = .waitingForRemote(promiseBuffer) backpressurePromise.succeed(()) return newPromise.futureResult case .waitingForRemote(var promiseBuffer): assert( !promiseBuffer.isEmpty, "assert expected to be waiting if we have at least one promise in the buffer" ) let promise = self.eventLoop.makePromise(of: ByteBuffer?.self) promiseBuffer.append(promise) self.state.value = .waitingForRemote(promiseBuffer) return promise.futureResult case .buffering(.none): var promiseBuffer = CircularBuffer>() let promise = self.eventLoop.makePromise(of: ByteBuffer?.self) promiseBuffer.append(promise) self.state.value = .waitingForRemote(promiseBuffer) return promise.futureResult case .buffering(.some((let buffer, let promise))): self.state.value = .buffering(nil) promise.succeed(()) return self.eventLoop.makeSucceededFuture(buffer) case .done: return self.eventLoop.makeSucceededFuture(.none) } } func didReceiveHead(task: HTTPClient.Task, _ head: HTTPResponseHead) -> EventLoopFuture { switch self.state.value { case .consuming(let backpressurePromise): return backpressurePromise.futureResult case .waitingForRemote: return self.eventLoop.makeSucceededVoidFuture() case .buffering, .done: preconditionFailure("State must be either waitingForRemote or initialized") } } func didReceiveBodyPart(task: HTTPClient.Task, _ buffer: ByteBuffer) -> EventLoopFuture { switch self.state.value { case .waitingForRemote(var promiseBuffer): assert( !promiseBuffer.isEmpty, "assert expected to be waiting if we have at least one promise in the buffer" ) let promise = promiseBuffer.removeFirst() if promiseBuffer.isEmpty { let newBackpressurePromise = self.eventLoop.makePromise(of: Void.self) self.state.value = .consuming(newBackpressurePromise) promise.succeed(buffer) return newBackpressurePromise.futureResult } else { self.state.value = .waitingForRemote(promiseBuffer) promise.succeed(buffer) return self.eventLoop.makeSucceededVoidFuture() } case .buffering(.none): let promise = self.eventLoop.makePromise(of: Void.self) self.state.value = .buffering((buffer, promise)) return promise.futureResult case .buffering(.some): preconditionFailure( "Did receive response part should not be called, before the previous promise was succeeded." ) case .done, .consuming: preconditionFailure("Invalid state: \(self.state)") } } func didFinishRequest(task: HTTPClient.Task) throws { switch self.state.value { case .waitingForRemote(let promiseBuffer): for promise in promiseBuffer { promise.succeed(.none) } self.state.value = .done case .buffering(.none): self.state.value = .done case .done, .consuming: preconditionFailure("Invalid state: \(self.state)") case .buffering(.some): preconditionFailure( "Did receive response part should not be called, before the previous promise was succeeded." ) } } } class ReadEventHitHandler: ChannelOutboundHandler { public typealias OutboundIn = NIOAny private(set) var readHitCounter = 0 public init() {} public func read(context: ChannelHandlerContext) { self.readHitCounter += 1 context.read() } } final class FailEndHandler: ChannelOutboundHandler, Sendable { typealias OutboundIn = HTTPClientRequestPart typealias OutboundOut = HTTPClientRequestPart struct Error: Swift.Error {} func write(context: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise?) { if case .end = self.unwrapOutboundIn(data) { // We fail this. promise?.fail(Self.Error()) } else { context.write(data, promise: promise) } } }