From 6fd67b74687087d4eacedee62236a4f23abc33a7 Mon Sep 17 00:00:00 2001 From: Rick Newton-Rogers Date: Thu, 11 Jun 2026 11:24:33 +0100 Subject: [PATCH 1/2] Deliver RESET_STREAM on a peer-initiated stream's first frame At the moment a reset arriving before any `STREAM` frame on a fresh peer stream ID is silently dropped. RFC 9000 Section 3.2 lists `RESET_STREAM` among the frames that create the receiving part of a peer-initiated stream, but `FrameResetStream` bails out before `createInboundStreams` when `knownFlows` has no entry - the inbound-aborted event never reaches the upper protocol and the peer's intent is lost. A second path captures the flow via `connection.flow(for:)` before `createInboundStreams` runs and never refreshes the binding, so even when the stream is created in-line the subsequent guard returns without delivering the reset. We can't naively collapse the two cases (unknown stream ID; present ID with nil flow) into a single `createInboundStreams` call because RFC 9000 Section 3.5 says `STOP_SENDING` SHOULD not be sent for a stream the peer has just reset, and `createInboundStreams`'s `isDetached` early-return enqueues `STOP_SENDING` to the peer. This change: * Restructures `FrameResetStream.process` to fetch the stream fresh from `knownFlows` / `multiplexedFlows` after `createInboundStreams` runs, replacing the captured-then-stale flow binding. * Splits the lookup so a present-but-flow-nil entry drops directly with a log, and `createInboundStreams` only runs when the stream ID is genuinely unknown. * Documents the `!inboundStreamResult.created` branch's `return true` with the reasoning (peer notification or close already handled inside `createInboundStreams`). * Adds `runQUICResetStreamFirstFrameOnNewStream` and `testQUICResetStreamFirstFrameOnNewStream` covering the end-to-end RESET-only flow. After this change a peer-initiated stream that receives `RESET_STREAM` as its first (and only) frame delivers the inbound-aborted event to the upper protocol. The change avoids spuriously emitting `STOP_SENDING` in response to a peer reset when the inbound flow handler has detached. The new test passes deterministically; pre-fix it timed out without observing the new flow or the abort. --- Sources/SwiftNetwork/QUIC/QUICFrame.swift | 42 +++++++++---- Tests/SwiftNetworkTests/QUICTestHarness.swift | 59 +++++++++++++++++++ .../SwiftNetworkQUICHarnessTests.swift | 4 ++ 3 files changed, 92 insertions(+), 13 deletions(-) diff --git a/Sources/SwiftNetwork/QUIC/QUICFrame.swift b/Sources/SwiftNetwork/QUIC/QUICFrame.swift index 2d62888..7f2a491 100644 --- a/Sources/SwiftNetwork/QUIC/QUICFrame.swift +++ b/Sources/SwiftNetwork/QUIC/QUICFrame.swift @@ -1088,13 +1088,24 @@ struct FrameResetStream: ~Copyable, QUICFrameProtocol { connection.close(with: .streamStateError, "RESET_STREAM for send-only stream") return false } - guard let flowID = connection.knownFlows[streamID] else { - return true - } - let potentialFlow = connection.flow(for: flowID) - if potentialFlow == nil { + + let stream: QUICStreamInstance + if let flowID = connection.knownFlows[streamID] { + // The stream id is known. The flow object may still be missing + // if the stream was torn down without clearing `knownFlows`; in + // that case there is no one to deliver the reset to, so drop it. + guard let existing = connection.flow(for: flowID) else { + Logger.proto.error( + "stream \(streamID.value) has known flow but no QUICStreamInstance; dropping RESET_STREAM" + ) + return true + } + stream = existing + + } else { + // RESET_STREAM may be the first frame the peer sends on a stream + // createInboundStreams will register it. let inboundStreamResult = connection.createInboundStreams(streamID: streamID) - // Use Int(exactly:) here to make sure that finalSize UInt64 can fit into Int64 if inboundStreamResult.checkZombie { connection.zombieStreamListFinalSizeReceived( streamID: streamID, @@ -1103,14 +1114,19 @@ struct FrameResetStream: ~Copyable, QUICFrameProtocol { return true } if !inboundStreamResult.created { - return false + // The stream is being ignored. createInboundStreams already + // drove any required peer notification or close, so treat the + // frame as handled. + return true } - } - guard let stream = potentialFlow else { - Logger.proto.error( - "client state for flow \(flowID.debugDescription) is not a QUICStreamInstance" - ) - return true + guard let flowID = connection.knownFlows[streamID], + let created = connection.flow(for: flowID) else { + Logger.proto.error( + "stream \(streamID.value) is not a QUICStreamInstance after createInboundStreams" + ) + return true + } + stream = created } // Set the application error code stream.streamMetadata.applicationError = self.code diff --git a/Tests/SwiftNetworkTests/QUICTestHarness.swift b/Tests/SwiftNetworkTests/QUICTestHarness.swift index 68dc568..3c386ba 100644 --- a/Tests/SwiftNetworkTests/QUICTestHarness.swift +++ b/Tests/SwiftNetworkTests/QUICTestHarness.swift @@ -1340,6 +1340,65 @@ final class QUICTestHarness { stop() } + /// `RESET_STREAM` is the very first frame the server sees for a fresh + /// peer-initiated bidi stream — no preceding `STREAM` frame. The server + /// must still create the inbound flow and deliver the inbound-aborted + /// event for the reset to be observable end-to-end. + func runQUICResetStreamFirstFrameOnNewStream( + applicationError: UInt64 = 42, + timeout: TimeInterval = 4.0 + ) { + do { + try quicHandshake() + } 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") + let serverAbortExpectation = XCTestExpectation(description: "Server sees inbound abort event") + var serverStream: StreamUpperHarness? + + context.async { + self.state?.serverHarness.waitForNewFlow { + guard let stream = self.state?.serverHarness.upperHarnesses.last else { + XCTFail("Server flow missing") + serverFlowExpectation.fulfill() + return + } + serverStream = stream + stream.waitForInboundAborted { error in + XCTAssertNotNil(error, "Server should see inbound abort from RESET_STREAM") + XCTAssertEqual( + error?.quicApplicationError, + Int64(applicationError), + "Application error code should be propagated from RESET_STREAM" + ) + serverAbortExpectation.fulfill() + } + serverFlowExpectation.fulfill() + } + } + + // Reset with no prior write so the only frame the client emits for + // this stream id is RESET_STREAM. There is no STREAM frame to + // stride-create the flow ahead of the reset. + context.async { + clientStream.abortOutbound(error: .init(quicApplicationError: applicationError)) + } + + wait(for: [serverFlowExpectation], timeout: timeout) + wait(for: [serverAbortExpectation], timeout: timeout) + + _ = serverStream // silence unused-warning; harness keeps strong ref + stop() + } + func runQUICServerTestForPendingBidirectional( identifier: String = #function, dataBlock: [UInt8], diff --git a/Tests/SwiftNetworkTests/SwiftNetworkQUICHarnessTests.swift b/Tests/SwiftNetworkTests/SwiftNetworkQUICHarnessTests.swift index 6dba7d0..0e2c2ea 100644 --- a/Tests/SwiftNetworkTests/SwiftNetworkQUICHarnessTests.swift +++ b/Tests/SwiftNetworkTests/SwiftNetworkQUICHarnessTests.swift @@ -362,6 +362,10 @@ final class SwiftNetworkQUICHarnessTests: NetTestCase { QUICTestHarness().runQUICNewStreamWithImmediateAbort(abortKind: .stopSending) } + func testQUICResetStreamFirstFrameOnNewStream() { + QUICTestHarness().runQUICResetStreamFirstFrameOnNewStream() + } + func testQUICResetStreamDoesNotAffectOppositeDirection() { QUICTestHarness().runQUICTest( streamCount: 1, From 0540267390fa4b41fc2889a99e79897175359b86 Mon Sep 17 00:00:00 2001 From: Rick Newton-Rogers Date: Thu, 11 Jun 2026 12:22:50 +0100 Subject: [PATCH 2/2] formatting --- Sources/SwiftNetwork/QUIC/QUICFrame.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/SwiftNetwork/QUIC/QUICFrame.swift b/Sources/SwiftNetwork/QUIC/QUICFrame.swift index 7f2a491..c7259f5 100644 --- a/Sources/SwiftNetwork/QUIC/QUICFrame.swift +++ b/Sources/SwiftNetwork/QUIC/QUICFrame.swift @@ -1120,7 +1120,8 @@ struct FrameResetStream: ~Copyable, QUICFrameProtocol { return true } guard let flowID = connection.knownFlows[streamID], - let created = connection.flow(for: flowID) else { + let created = connection.flow(for: flowID) + else { Logger.proto.error( "stream \(streamID.value) is not a QUICStreamInstance after createInboundStreams" )