From 572cfb3bc4fa5193990b4fb269fb6a43956b6b90 Mon Sep 17 00:00:00 2001 From: Adam Budde Date: Mon, 22 Jun 2026 12:43:17 -0700 Subject: [PATCH] HTTPBodyOutputStream should handle multiple terminal events --- .../HTTPBodyOutputStreamBridge.swift | 15 ++++++-- .../HTTPBodyOutputStreamTests.swift | 38 +++++++++++++++++++ 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/Sources/OpenAPIURLSession/URLSessionBidirectionalStreaming/HTTPBodyOutputStreamBridge.swift b/Sources/OpenAPIURLSession/URLSessionBidirectionalStreaming/HTTPBodyOutputStreamBridge.swift index f542958..6e24c0b 100644 --- a/Sources/OpenAPIURLSession/URLSessionBidirectionalStreaming/HTTPBodyOutputStreamBridge.swift +++ b/Sources/OpenAPIURLSession/URLSessionBidirectionalStreaming/HTTPBodyOutputStreamBridge.swift @@ -207,7 +207,9 @@ extension HTTPBodyOutputStreamBridge { case .needBytes(_, let producerContinuation): self = .closed(error) return .cancelProducerAndCloseStream(producerContinuation) - case .closed: preconditionFailure("\(#function) called in invalid state: \(self)") + case .closed: + debug("Ignoring \(#function) event in closed state") + return .none } } @@ -216,8 +218,10 @@ extension HTTPBodyOutputStreamBridge { case .waitingForBytes(_): self = .closed(nil) return .closeStream - case .initial, .haveBytes, .needBytes, .closed: - preconditionFailure("\(#function) called in invalid state: \(self)") + case .closed: + debug("Ignoring \(#function) event in closed state") + return .none + case .initial, .haveBytes, .needBytes: preconditionFailure("\(#function) called in invalid state: \(self)") } } @@ -232,7 +236,10 @@ extension HTTPBodyOutputStreamBridge { case .needBytes(_, let producerContinuation): self = .closed(nil) return .cancelProducerAndCloseStream(producerContinuation) - case .initial, .closed: preconditionFailure("\(#function) called in invalid state: \(self)") + case .closed: + debug("Ignoring \(#function) event in closed state") + return .none + case .initial: preconditionFailure("\(#function) called in invalid state: \(self)") } } diff --git a/Tests/OpenAPIURLSessionTests/URLSessionBidirectionalStreamingTests/HTTPBodyOutputStreamTests.swift b/Tests/OpenAPIURLSessionTests/URLSessionBidirectionalStreamingTests/HTTPBodyOutputStreamTests.swift index 631ee65..a2d5263 100644 --- a/Tests/OpenAPIURLSessionTests/URLSessionBidirectionalStreamingTests/HTTPBodyOutputStreamTests.swift +++ b/Tests/OpenAPIURLSessionTests/URLSessionBidirectionalStreamingTests/HTTPBodyOutputStreamTests.swift @@ -376,6 +376,44 @@ class HTTPBodyOutputStreamBridgeTests: XCTestCase { } + func testWroteFinalChunkIsNoOpWhenAlreadyClosed() { + var state = HTTPBodyOutputStreamBridge.State.closed(URLError(.cannotFindHost)) + guard case .none = state.wroteFinalChunk() else { + XCTFail("Expected .none action from wroteFinalChunk() in .closed state") + return + } + guard case .closed = state else { + XCTFail("Expected state to remain .closed, got \(state)") + return + } + } + + func testEndEncounteredIsNoOpWhenAlreadyClosed() { + var state = HTTPBodyOutputStreamBridge.State.closed(URLError(.cannotFindHost)) + guard case .none = state.endEncountered() else { + XCTFail("Expected .none action from endEncountered() in .closed state") + return + } + guard case .closed = state else { + XCTFail("Expected state to remain .closed, got \(state)") + return + } + } + + func testErrorOccurredIsNoOpWhenAlreadyClosed() { + let firstError = URLError(.cannotFindHost) + var state = HTTPBodyOutputStreamBridge.State.closed(firstError) + guard case .none = state.errorOccurred(URLError(.timedOut)) else { + XCTFail("Expected .none action from errorOccurred() in .closed state") + return + } + guard case .closed(let storedError) = state else { + XCTFail("Expected state to remain .closed, got \(state)") + return + } + XCTAssertEqual((storedError as? URLError)?.code, firstError.code, "First error should win") + } + // Wait for given condition to be true within wait duration. private func waitForCondition(_ label: String, within waitDuration: TimeInterval = 1.0, condition: () -> Bool) async throws