From 562bdc1e12edbbde75f4efb3804673d682bd7886 Mon Sep 17 00:00:00 2001 From: Tommy Pauly Date: Thu, 17 Sep 2026 12:50:24 -0700 Subject: [PATCH 1/3] Update flow control credit when inbound stream data is abandoned --- Sources/SwiftNetwork/QUIC/FlowControl.swift | 44 +++ .../SwiftNetwork/QUIC/QUICConnection.swift | 48 +++- Sources/SwiftNetwork/QUIC/QUICStream.swift | 47 +++ Tests/QUICTests/FlowControlTests.swift | 207 +++++++++++++ Tests/SwiftNetworkTests/QUICTestHarness.swift | 272 ++++++++++++++++++ .../SwiftNetworkQUICHarnessTests.swift | 42 +++ 6 files changed, 659 insertions(+), 1 deletion(-) diff --git a/Sources/SwiftNetwork/QUIC/FlowControl.swift b/Sources/SwiftNetwork/QUIC/FlowControl.swift index 5369712f..5f1486e3 100644 --- a/Sources/SwiftNetwork/QUIC/FlowControl.swift +++ b/Sources/SwiftNetwork/QUIC/FlowControl.swift @@ -216,6 +216,31 @@ struct FlowControlState: ~Copyable { return true } + // Accounts for inbound bytes that the peer sent, and that therefore consumed + // receive window, but that will never be delivered to the application because + // the stream carrying them was closed. + // + // `inboundMaxData` is anchored on `totalInboundBytesDelivered`, so unless + // discarded bytes are counted as consumed, the credit they used is never + // returned to the peer: the usable receive window shrinks by that amount for + // the remaining life of the connection, and enough discarded bytes stall it + // outright. + // + // The caller must have already added these bytes to + // `totalInOrderInboundBytesRead`; this only advances the delivered counter to + // match, which is what moves the MAX_DATA anchor. + fileprivate mutating func creditDiscardedInboundBytes(_ bytes: UInt64) { + guard bytes > 0 else { return } + + let (newDelivered, deliveredOverflow) = totalInboundBytesDelivered.addingReportingOverflow(bytes) + guard !deliveredOverflow else { return } + + // Delivered can never exceed the in-order total: the difference between + // them is what remains buffered awaiting the application. + totalInboundBytesDelivered = min(newDelivered, totalInOrderInboundBytesRead) + inboundBytesDeliveredSinceLastUpdate += bytes + } + // Outbound values (sending): // Maximum number of bytes allowed to be sent to the peer, as @@ -342,9 +367,28 @@ extension QUICConnection { log.datapath( "Zombie adjusted in-order inbound bytes changed from \(oldTotalInbound) to \(newValue))" ) + // The stream is already gone, so these bytes can never be delivered. + // Count them as consumed to release the credit they used. + flowControlState.creditDiscardedInboundBytes(delta) } } + // Releases the receive-window credit used by inbound bytes that arrived on a + // stream but that the application will never read, because the stream was + // closed with those bytes still buffered. + // + // The caller must have already accounted for `bytes` in the connection's + // in-order inbound total. + func creditDiscardedInboundBytes(_ bytes: UInt64) { + guard bytes > 0 else { return } + flowControlState.creditDiscardedInboundBytes(bytes) + log.datapath( + "Credited \(bytes) discarded inbound bytes; connection MAX_DATA anchor is now " + + "\(self.flowControlState.totalInOrderInboundBytesRead)" + ) + sendInboundFlowControlCredit() + } + func updateLastReceivedOffsetForZombie(lastOffsetDelta: UInt64) { let connectionMaxData = flowControlState.inboundMaxData let connectionCurrentLargestData = flowControlState.largestInboundByteOffsetReceived diff --git a/Sources/SwiftNetwork/QUIC/QUICConnection.swift b/Sources/SwiftNetwork/QUIC/QUICConnection.swift index 6d4cb82f..61a0935b 100644 --- a/Sources/SwiftNetwork/QUIC/QUICConnection.swift +++ b/Sources/SwiftNetwork/QUIC/QUICConnection.swift @@ -387,6 +387,18 @@ public final class QUICConnection: ManyToManyApplicationStreamProtocol, private var pendOutboundData = false // Don't immediately process application sends + // Set while `recovery` is exclusively borrowed for ACK processing. + // + // Acknowledging a packet can close a stream (a fully-ACKed RESET_STREAM or + // FIN), and closing a stream wants to flush frames. The no-argument + // `sendFrames()` passes `&recovery` inout, so doing that from inside the ACK + // walk would be a second overlapping modification of `recovery` and traps + // under exclusivity enforcement. While this is set, `sendFrames()` records + // the request instead of performing it, and the ACK path flushes once the + // borrow ends. + private var isProcessingAcks = false + private var deferredSendFramesRequested = false + // false == IPv6, true == IPv4 private(set) var initialAddressIsIPv4 = false @@ -3046,7 +3058,15 @@ public final class QUICConnection: ManyToManyApplicationStreamProtocol, // Adds recovery and applicationPendingItems to avoid extra begin/end acccess checking overhead @discardableResult func sendFrames(ignoreCongestionWindow: Bool = false, delayedACK: Bool = false) -> Bool { - sendFrames( + // ACK processing holds `recovery` exclusively, and acknowledging a packet + // can close a stream, which in turn wants to flush frames. Passing + // `&recovery` again here would overlap that borrow and trap, so record + // the request and let the ACK path flush once its borrow has ended. + if isProcessingAcks { + deferredSendFramesRequested = true + return false + } + return sendFrames( ignoreCongestionWindow: ignoreCongestionWindow, delayedACK: delayedACK, sentPackets: &sentPackets, @@ -4131,6 +4151,12 @@ public final class QUICConnection: ManyToManyApplicationStreamProtocol, localMaxStreamData: stream.flowControlState.inboundMaxData ) + // The application has given up on reading, so any bytes still + // buffered are discarded here. Return the receive-window credit they + // consumed; the zombie's final-size handling covers only the bytes + // still in flight beyond what we have already received. + stream.discardUnreadInboundBytes(connection: self) + // Do we delete this 'stream' somehow, now that it's a zombie? // Once it's got no more references it will automatically taken care of // with ARC. It may have references in pendingStartStreams (removed above) @@ -4667,6 +4693,14 @@ public final class QUICConnection: ManyToManyApplicationStreamProtocol, log.error("Error sending frames on stream close: \(error)") } } + + // Anything the application did not read above is now unreachable: the + // flow is about to be torn down. Those bytes consumed connection receive + // window when they arrived, so hand that credit back before the buffers + // are dropped, otherwise the window shrinks for the rest of the + // connection's life. + stream.discardUnreadInboundBytes(connection: self) + stream.closed = true deliverDisconnectedEvent(flow: flowID, error: error) knownFlows.removeValue(forKey: streamID) @@ -5222,6 +5256,11 @@ extension QUICConnection { return false } + // Acknowledging a packet can close a stream, and closing a stream wants + // to flush frames. Suppress those nested flushes for the duration of the + // `recovery` borrow below, then perform one flush afterwards if any were + // requested. + isProcessingAcks = true recovery.receivedAck( ack: frame, ackedPath: path, @@ -5232,6 +5271,13 @@ extension QUICConnection { var sentPackets = NetworkUniqueDeque() path.pmtudState.tryToSend(on: path, sentPackets: &sentPackets) recovery.recordSentPackets(&sentPackets, connection: self) + + // The borrow of `recovery` has ended, so it is safe to flush again. + isProcessingAcks = false + if deferredSendFramesRequested { + deferredSendFramesRequested = false + sendFrames() + } return true } diff --git a/Sources/SwiftNetwork/QUIC/QUICStream.swift b/Sources/SwiftNetwork/QUIC/QUICStream.swift index 289da0aa..c84708ac 100644 --- a/Sources/SwiftNetwork/QUIC/QUICStream.swift +++ b/Sources/SwiftNetwork/QUIC/QUICStream.swift @@ -875,6 +875,53 @@ public final class QUICStreamInstance: MultiplexedStreamFlow, self.sendInboundFlowControlCreditIfNeeded(connection: connection) } + // Discards every inbound byte still buffered for this stream and returns the + // flow control credit those bytes consumed. + // + // Called when the stream is closed with data the application never read, + // either still sitting in the reassembly queue or already dequeued into the + // upper receive queue awaiting a read. Those bytes counted against the + // connection's receive window when they arrived; without this the credit is + // never given back and the usable window shrinks permanently. + func discardUnreadInboundBytes(connection: QUICConnection) { + // Bytes handed to the upper layer but not yet read by the application. + // Dequeuing already advanced the reassembly queue's `currentOffset` past + // these, but flow control only counts them once the application reads, + // so they are still missing from the in-order total. + let pendingDelivery = UInt64(upperReceiveQueue.unclaimedLength) + // Contiguous bytes reassembled but not yet dequeued. + let pendingDequeue = UInt64(max(reassemblyQueue.availableToDequeue, 0)) + + // Anything the reassembly queue holds beyond the contiguous run is not + // yet part of the in-order total, so it has no credit to return here; + // the RESET_STREAM and zombie final-size paths cover those gaps. + let discardedBytes = pendingDelivery + pendingDequeue + guard discardedBytes > 0 else { return } + + log.datapath( + "Discarding \(discardedBytes) unread inbound bytes on close " + + "(\(pendingDelivery) awaiting read, \(pendingDequeue) awaiting dequeue)" + ) + + // Release the frames themselves before crediting, so the buffers are + // freed even if the connection is already tearing down. + upperReceiveQueue.finalizeAllFramesAsFailed() + + // Advance the in-order total to cover everything the queue holds + // contiguously, which is what the application could have read. This adds + // the same delta to the connection-wide total. + let newInOrderTotal = UInt64(reassemblyQueue.currentOffset) + pendingDequeue + reassemblyQueue.dequeueAll() + updateFlowControlWithTotalInOrderInboundBytesRead( + newInOrderTotal, + connection: connection + ) + + // Both components are now part of the in-order total, so credit them as + // consumed to move the MAX_DATA anchor past them. + connection.creditDiscardedInboundBytes(discardedBytes) + } + @_optimize(speed) func dequeueReassembledData(connection: QUICConnection) -> FrameArray? { let totalLength = reassemblyQueue.availableToDequeue diff --git a/Tests/QUICTests/FlowControlTests.swift b/Tests/QUICTests/FlowControlTests.swift index 746ffdaf..940b140e 100644 --- a/Tests/QUICTests/FlowControlTests.swift +++ b/Tests/QUICTests/FlowControlTests.swift @@ -173,6 +173,213 @@ final class FlowControlTests: XCTestCase { XCTAssertEqual(connection.flowControlState.totalInOrderInboundBytesRead, finalSize * 4) } + + // MARK: Credit for inbound bytes the application never reads + + // Builds a connection with a single inbound stream that has already + // received `byteCount` bytes, sitting unread in the upper receive queue. + // The stream and connection each advertise `window` bytes of credit. + private func makeStreamWithUnreadInboundBytes( + byteCount: Int, + window: UInt64, + connection: QUICConnection + ) -> QUICStreamInstance { + let logPrefixer = LogPrefixer("[FlowControlTests]") + let path = QUICPath(parent: connection) + path.mss = 1200 + connection.currentPath = path + connection.flowControlState.initializeMaxDataValues( + remoteMaxData: window, + localMaxData: window + ) + + var stream = QUICStreamInstance(parent: connection, inbound: true) + stream.setup(streamID: QUICStreamID(0), logPrefixer: logPrefixer) + stream.flowControlState.initializeMaxDataValues( + remoteMaxData: window, + localMaxData: window + ) + stream.receiveState.change(logIDString: "FlowControlTests", to: .receive) + + _ = stream.processIncomingStream( + connection: connection, + frame: FrameStreamReceived( + id: 0, + offset: 0, + data: [UInt8](repeating: 0x41, count: byteCount), + isFinal: false + ) + ) + + // Move the bytes out of the reassembly queue and into the upper receive + // queue, which is what the datapath does before the application reads. + if let frames = stream.dequeueReassembledData(connection: connection) { + try? stream.addToUpperReceiveQueue(frames) + } + return stream + } + + // When the application reads the bytes, MAX_DATA advances past them: the + // credit consumed by those bytes is returned to the peer. This is the + // baseline that the "dropped" cases below are compared against. + func testInboundCreditReturnedWhenApplicationReadsBytes() { + let byteCount = 4000 + let window: UInt64 = 100_000 + let context = NetworkContext(identifier: "test context") + context.activate() + + let done = XCTestExpectation(description: "read-all accounting complete") + context.async { + let connection = QUICConnection(context: context) + let stream = self.makeStreamWithUnreadInboundBytes( + byteCount: byteCount, + window: window, + connection: connection + ) + + // The application reads every byte. + stream.deliveredInboundBytes(consumedLength: byteCount, connection: connection) + stream.upperReceiveQueue.finalizeAllFramesAsFailed() + + XCTAssertEqual( + connection.flowControlState.totalInOrderInboundBytesRead, + UInt64(byteCount), + "Connection should account for all in-order bytes that were read" + ) + // MAX_DATA is anchored on the bytes delivered to the application, so + // reading the data must push the advertised limit beyond them. + XCTAssertGreaterThan( + connection.flowControlState.inboundMaxData, + UInt64(byteCount), + "MAX_DATA should move past bytes the application has consumed" + ) + connection.currentPath = nil + done.fulfill() + } + wait(for: [done], timeout: 5.0) + } + + // The application closes the read side while inbound bytes are still buffered + // and unread. Those bytes consumed receive window when they arrived, so + // closing must return their credit; otherwise QUIC never gets it back and the + // usable window shrinks for the rest of the connection. + func testInboundCreditReturnedWhenUnreadBytesDroppedOnClose() { + let byteCount = 4000 + let window: UInt64 = 100_000 + let context = NetworkContext(identifier: "test context") + context.activate() + + let done = XCTestExpectation(description: "drop accounting complete") + context.async { + let connection = QUICConnection(context: context) + let stream = self.makeStreamWithUnreadInboundBytes( + byteCount: byteCount, + window: window, + connection: connection + ) + XCTAssertEqual( + stream.upperReceiveQueue.unclaimedLength, + byteCount, + "Bytes should be pending in the upper receive queue, unread" + ) + let maxDataBeforeClose = connection.flowControlState.inboundMaxData + + // The application closes the read side without reading anything. + connection.fromExternal { + connection.handleStopRead(for: stream) + } + stream.readClosed = true + + // Closing the read side discards those buffered bytes, so their + // credit must be returned immediately rather than waiting for the + // peer's RESET_STREAM: the application is never going to read them. + XCTAssertEqual( + connection.flowControlState.totalInOrderInboundBytesRead, + UInt64(byteCount), + "Connection must account for inbound bytes dropped without being read" + ) + XCTAssertEqual( + stream.upperReceiveQueue.unclaimedLength, + 0, + "Discarded frames should be released from the upper receive queue" + ) + // MAX_DATA is anchored on consumed bytes, so it must now advance past + // the discarded ones, handing the credit back to the peer. + XCTAssertGreaterThan( + connection.flowControlState.inboundMaxData, + maxDataBeforeClose, + "MAX_DATA must advance so the discarded bytes' credit is returned" + ) + XCTAssertGreaterThan( + connection.flowControlState.inboundMaxData, + UInt64(byteCount), + "MAX_DATA must move past the bytes that were dropped" + ) + connection.currentPath = nil + done.fulfill() + } + wait(for: [done], timeout: 5.0) + } + + // A stream torn down before its final size is known becomes a zombie. When + // the final size finally arrives, the bytes that were in flight but never + // read must be credited at the connection level. + func testInboundCreditReturnedForZombieStreamFinalSize() { + let context = NetworkContext(identifier: "test context") + context.activate() + + let done = XCTestExpectation(description: "zombie accounting complete") + context.async { + let connection = QUICConnection(context: context) + connection.flowControlState.initializeMaxDataValues( + remoteMaxData: 100_000, + localMaxData: 100_000 + ) + var zombies = QUICStreamZombieList() + let streamID: QUICStreamID = QUICStreamID(0) + + // 4000 bytes had arrived when the application tore the stream down + // unread; the peer later reports the stream really ended at 6000, + // so 2000 further bytes were in flight and will never be read. + let lastSize: UInt64 = 4000 + let finalSize: UInt64 = 6000 + + connection.fromExternal { + zombies.append( + logIDString: "[FlowControlTests]", + streamID: streamID, + lastSize: lastSize, + localMaxStreamData: 100_000 + ) + } + XCTAssertNotNil(zombies.find(streamID: streamID)) + XCTAssertEqual(connection.flowControlState.totalInOrderInboundBytesRead, 0) + + connection.fromExternal { + zombies.finalSizeReceived( + logIDString: "[FlowControlTests]", + streamID: streamID, + finalSize: finalSize, + connection: connection + ) + } + + // The gap between the last size we saw and the final size is the + // set of bytes that were dropped, and must be credited back. + XCTAssertEqual( + connection.flowControlState.totalInOrderInboundBytesRead, + finalSize - lastSize - 1, + "Connection must credit the in-flight bytes a zombie stream never delivered" + ) + XCTAssertNil( + zombies.find(streamID: streamID), + "Zombie should be retired once its final size is known" + ) + connection.currentPath = nil + done.fulfill() + } + wait(for: [done], timeout: 5.0) + } } #endif diff --git a/Tests/SwiftNetworkTests/QUICTestHarness.swift b/Tests/SwiftNetworkTests/QUICTestHarness.swift index afcada53..dc02b83e 100644 --- a/Tests/SwiftNetworkTests/QUICTestHarness.swift +++ b/Tests/SwiftNetworkTests/QUICTestHarness.swift @@ -1449,6 +1449,278 @@ class QUICTestHarness { stop() } + /// Closes a stream that still has unread inbound bytes using `stop()`, which + /// tears down both directions at once and so emits a `RESET_STREAM`. + /// + /// When the peer ACKs that `RESET_STREAM`, the ACK walk closes the stream, and + /// closing a stream flushes frames. That flush re-enters `sendFrames()` while + /// `recovery` is still exclusively borrowed for ACK processing, which traps + /// under Swift's exclusivity enforcement. The trap fires on the ACK, well + /// after `stop()` has returned, so this waits for the round trip to complete. + /// + /// - Parameter readBeforeStop: When `true`, drain the bytes first. The trap + /// does not depend on unread data, so both variants must survive. + func runQUICStopStreamAfterPeerWrite( + readBeforeStop: Bool = false, + payloadSize: Int = 4000, + timeout: TimeInterval = 5.0 + ) { + do { + try quicHandshake(timeout: timeout) + } catch { + XCTFail("Handshake failed: \(error)") + return + } + + guard let clientStream = createNewStream(identifier: "C1") else { + XCTFail("Failed to create client stream") + return + } + + let serverFlowExpectation = XCTestExpectation(description: "Server sees new flow") + var serverStream: StreamUpperHarness? + context.async { + self.state?.serverHarness.waitForNewFlow { + serverStream = self.state?.serverHarness.upperHarnesses.last + serverFlowExpectation.fulfill() + } + } + context.async { + let wrote = clientStream.write([UInt8](repeating: 0x41, count: payloadSize)) + XCTAssertTrue(wrote, "Client failed to write payload") + } + wait(for: [serverFlowExpectation], timeout: timeout) + XCTAssertNotNil(serverStream, "Server flow missing") + + let stopExpectation = XCTestExpectation(description: "Server stops the stream") + context.async { + if readBeforeStop { + while serverStream?.read() != nil {} + } + // Tears down both directions, so a RESET_STREAM goes out. + serverStream?.stop() + stopExpectation.fulfill() + } + wait(for: [stopExpectation], timeout: timeout) + + // The trap happens when the ACK for the RESET_STREAM comes back, so give + // the round trip time to land before checking the connection is alive. + let settleExpectation = XCTestExpectation(description: "RESET_STREAM ack round trip") + _ = XCTWaiter.wait(for: [settleExpectation], timeout: 1.0) + + // Reaching this point at all means the ACK was processed without trapping. + // Confirm both endpoints are still usable rather than wedged. + let stateExpectation = XCTestExpectation(description: "Connections still healthy") + context.async { + if let server = self.state?.serverInstance { + XCTAssertNil( + server.closeError, + "Server connection should not have closed with an error" + ) + } else { + XCTFail("Server connection missing") + } + if let client = self.state?.clientInstance { + XCTAssertNil( + client.closeError, + "Client connection should not have closed with an error" + ) + } else { + XCTFail("Client connection missing") + } + stateExpectation.fulfill() + } + wait(for: [stateExpectation], timeout: timeout) + + // A fresh stream must still work end to end, proving the deferred frame + // flush after ACK processing was not simply dropped. + guard let followUpStream = createNewStream(identifier: "C2") else { + XCTFail("Could not open a stream after stopping the first one") + return + } + let followUpPayload = Array("after-stop".utf8) + let followUpExpectation = XCTestExpectation(description: "Follow-up stream delivers data") + context.async { + self.state?.serverHarness.waitForNewFlow { + guard let stream = self.state?.serverHarness.upperHarnesses.last else { + XCTFail("Follow-up server flow missing") + followUpExpectation.fulfill() + return + } + XCTAssertEqual( + stream.read(), + followUpPayload, + "Follow-up stream should deliver its payload intact" + ) + followUpExpectation.fulfill() + } + } + context.async { + let wrote = followUpStream.write(followUpPayload) + XCTAssertTrue(wrote, "Failed to write on the follow-up stream") + } + wait(for: [followUpExpectation], timeout: timeout) + + Logger.test.debug("Test phase: Termination") + stop() + } + + /// Repeatedly opens a stream, has the peer send `chunkSize` bytes on it, and + /// then aborts both directions from the receiving side *without ever reading + /// the inbound bytes*. + /// + /// Every one of those dropped bytes consumed connection-level flow control + /// credit. If that credit is not returned, the connection-level receive + /// window is permanently consumed and, after enough rounds, the peer can no + /// longer send anything at all — the connection stalls even though both + /// endpoints are healthy and no stream limit has been reached. + /// + /// The server advertises a deliberately small initial `MAX_DATA` (and a + /// generous stream limit) so that connection-level flow control, rather than + /// the concurrent-stream cap, is what runs out first. + /// + /// - Parameter readBeforeAbort: When `true`, the receiving side drains the + /// bytes before aborting. That is the control case: credit is returned via + /// the normal read path, and the loop must not stall. When `false`, the + /// bytes are dropped unread, which is the case under test. + func runQUICDropUnreadInboundBytesLoop( + rounds: Int = 20, + chunkSize: Int = 4000, + initialMaxData: UInt64 = 40_000, + readBeforeAbort: Bool = false, + timeout: TimeInterval = 4.0 + ) { + let serverOptions = QUICProtocol.options() + serverOptions.connectionOptions.initialMaxData = initialMaxData + serverOptions.connectionOptions.initialMaxStreamDataBidirectionalRemote = initialMaxData + serverOptions.connectionOptions.initialMaxStreamDataBidirectionalLocal = initialMaxData + // Keep the stream limits well clear of `rounds` so that a stall can only + // be caused by connection-level flow control. + serverOptions.connectionOptions.initialMaxStreamsBidirectional = UInt64(rounds * 2 + 10) + serverOptions.connectionOptions.maximumConcurrentBidirectionalStreams = rounds * 2 + 10 + + do { + try quicHandshake(timeout: timeout, serverOptions: serverOptions) + } catch { + XCTFail("Handshake failed: \(error)") + return + } + + // Total bytes dropped must exceed the advertised window several times + // over, so a missing credit update is guaranteed to exhaust it. + XCTAssertGreaterThan( + UInt64(rounds * chunkSize), + initialMaxData, + "Test must send more than the initial window to exercise credit return" + ) + + for round in 0.. Date: Mon, 21 Sep 2026 12:45:10 -0700 Subject: [PATCH 2/3] Testable import --- Tests/SwiftNetworkTests/QUICTestHarness.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/SwiftNetworkTests/QUICTestHarness.swift b/Tests/SwiftNetworkTests/QUICTestHarness.swift index dc02b83e..b0c20b87 100644 --- a/Tests/SwiftNetworkTests/QUICTestHarness.swift +++ b/Tests/SwiftNetworkTests/QUICTestHarness.swift @@ -19,7 +19,7 @@ import XCTest #if canImport(SwiftNetwork) @_spi(Essentials) @_spi(ProtocolProvider) @testable import SwiftNetwork #elseif canImport(Network) -@_spi(Essentials) @_spi(ProtocolProvider) import Network +@_spi(Essentials) @_spi(ProtocolProvider) @testable import Network #endif #if IMPORT_SWIFTTLS From be63e0dbf2c5c428e3bb81ae55b34f4489802c99 Mon Sep 17 00:00:00 2001 From: Tommy Pauly Date: Mon, 21 Sep 2026 14:24:02 -0700 Subject: [PATCH 3/3] Guard update --- Tests/SwiftNetworkTests/QUICTestHarness.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Tests/SwiftNetworkTests/QUICTestHarness.swift b/Tests/SwiftNetworkTests/QUICTestHarness.swift index b0c20b87..5634d68f 100644 --- a/Tests/SwiftNetworkTests/QUICTestHarness.swift +++ b/Tests/SwiftNetworkTests/QUICTestHarness.swift @@ -19,7 +19,7 @@ import XCTest #if canImport(SwiftNetwork) @_spi(Essentials) @_spi(ProtocolProvider) @testable import SwiftNetwork #elseif canImport(Network) -@_spi(Essentials) @_spi(ProtocolProvider) @testable import Network +@_spi(Essentials) @_spi(ProtocolProvider) import Network #endif #if IMPORT_SWIFTTLS @@ -1639,6 +1639,7 @@ class QUICTestHarness { // A stalled connection shows up here: the bytes never arrive because // the client has no send credit left. guard XCTWaiter.wait(for: [serverFlowExpectation], timeout: timeout) == .completed else { + #if canImport(SwiftNetwork) var diagnostics = "" let diagnosticsExpectation = XCTestExpectation(description: "Collect stall state") context.async { @@ -1654,6 +1655,9 @@ class QUICTestHarness { diagnosticsExpectation.fulfill() } wait(for: [diagnosticsExpectation], timeout: timeout) + #else + let diagnostics = "flow control state unavailable" + #endif XCTFail( "Connection stalled at round \(round) after dropping \(round * chunkSize) unread " @@ -1699,6 +1703,7 @@ class QUICTestHarness { // Having completed every round, confirm the peer still has room to send: // the credit for all the dropped bytes was genuinely returned. + #if canImport(SwiftNetwork) let finalExpectation = XCTestExpectation(description: "Final credit check") context.async { if let client = self.state?.clientInstance { @@ -1716,6 +1721,7 @@ class QUICTestHarness { finalExpectation.fulfill() } wait(for: [finalExpectation], timeout: timeout) + #endif Logger.test.debug("Test phase: Termination") stop()