Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions Sources/SwiftNetwork/QUIC/QUICConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3827,11 +3827,21 @@ public final class QUICConnection: ManyToManyApplicationStreamProtocol,
}

if !state.isTerminal {
// Send a FIN if we are closing a stream without an error.
// Otherwise, send RESET_STREAM.
// Technically, 0 can be valid application error code, but
// it isn't for HTTP/3, so we can assume it's not set.
if stream.sendState == .send && stream.outboundApplicationError == nil {
// A clean close (no application error) is a FIN, regardless of whether
// any data was sent. A send side still in `.ready` (nothing ever sent)
// advances to `.send` so the servicing path emits a zero-length STREAM
// frame with the FIN bit set (RFC 9000 §3.1 / §19.8). Only send
// RESET_STREAM when an application error is set (e.g. a received
// STOP_SENDING) or a partially buffered stream is aborted.
if stream.outboundApplicationError == nil
&& (stream.sendState == .send || stream.sendState == .ready)
{
if stream.sendState == .ready {
stream.sendState.change(logIDString: stream.logPrefix, to: .send)
// Keep the write side open until the zero-length FIN has been
// serviced (and ACKed); closing now would drop the queued FIN.
closeWrite = false
}
markStreamFinished(stream: stream)
} else if stream.sendState == .ready
|| (stream.sendState == .send && !stream.sendBuffer.hasLast
Expand Down
110 changes: 110 additions & 0 deletions Tests/SwiftNetworkTests/QUICTestHarness.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1436,6 +1436,116 @@ final class QUICTestHarness {
stop()
}

/// A clean close of a send side that never wrote any data must be encoded as a
/// zero-length `STREAM` frame with the FIN bit set (RFC 9000 §3.1 / §19.8), not
/// a `RESET_STREAM`.
///
/// Reproduces the asymmetric scenario: the client opens a bidi stream and sends
/// `"ping"` + FIN — its send side went `.ready → .send`. The server reads
/// `"ping"` then closes the stream having never written, so its send side is still
/// `.ready` and the close is clean (`outboundApplicationError == nil`).
/// The client's receive side must reach a clean end-of-stream.
func runEmptyFinCleanCloseSendsFinNotReset(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 serverClosedExpectation = XCTestExpectation(
description: "Server reads ping and closes the stream without writing"
)
// Expecting a clean close, so this should not be fulfilled.
let clientAbortExpectation = XCTestExpectation(description: "Client inbound abort (must not happen)")
// The client must observe a clean end-of-stream.
let clientClosedExpectation = XCTestExpectation(description: "Client observes clean close")

var serverStream: StreamUpperHarness?
var clientResetError: String?

// The client's receive side must reach a clean end-of-stream.
clientStream.waitForInboundAborted { error in
clientResetError = error?.description ?? "no error"
clientAbortExpectation.fulfill()
}
clientStream.waitForDisconnected {
clientClosedExpectation.fulfill()
}

// Client drains its receive side so the server's FIN is consumed and the
// stream can reach a clean close.
var clientReadHandler: ((Bool) -> Void)? = nil
defer { clientReadHandler = nil }
clientReadHandler = { _ in
while clientStream.read() != nil {}
clientStream.waitForInboundDataAvailable { clientReadHandler?($0) }
}

context.async {
self.state?.serverHarness.waitForNewFlow {
guard let stream = self.state?.serverHarness.upperHarnesses.last else {
XCTFail("Server flow missing")
serverFlowExpectation.fulfill()
return
}
serverStream = stream
serverFlowExpectation.fulfill()

// Read the client's "ping" (present as of the new-flow event), then
// close the stream having never written. The send side is still
// `.ready`, so this clean close (no application error) must emit a
// zero-length STREAM+FIN, not RESET_STREAM.
while stream.read() != nil {}
stream.stop()
serverClosedExpectation.fulfill()
}
}

// Open the stream with a real payload + FIN so the server learns the flow.
context.async {
let wrote = clientStream.write(Array("ping".utf8), sendFIN: true)
XCTAssertTrue(wrote, "Client failed to write ping")
clientReadHandler?(true)
}

wait(for: [serverFlowExpectation], timeout: timeout)
wait(for: [serverClosedExpectation], timeout: timeout)
// Give other errors some time to arrive.
_ = XCTWaiter.wait(for: [clientAbortExpectation], timeout: 0.5)
XCTAssertNil(
clientResetError,
"Client received RESET_STREAM(\(clientResetError ?? "")) instead of a clean FIN — a clean close "
+ "of a send side that never wrote must be a zero-length STREAM+FIN (RFC 9000 §3.1 / §19.8)."
)
// And confirm that the client saw the FIN.
wait(for: [clientClosedExpectation], timeout: timeout)

#if canImport(SwiftNetwork)
// The server's send side must have finished via FIN, i.e. the state should be
// in .dataSent, or .dataReceived.
if let serverConnection = state?.serverInstance,
let serverFlow = serverConnection.multiplexedFlows.values.first
{
XCTAssertTrue(
serverFlow.sendState == .dataSent || serverFlow.sendState == .dataReceived,
"Server send side must reach a clean FIN terminal state, got \(serverFlow.sendState)"
)
} else {
XCTFail("Server stream flow missing; cannot verify send state")
}
#endif

_ = serverStream // silence unused-warning; harness keeps strong ref
stop()
}

func runQUICServerTestForPendingBidirectional(
identifier: String = #function,
dataBlock: [UInt8],
Expand Down
4 changes: 4 additions & 0 deletions Tests/SwiftNetworkTests/SwiftNetworkQUICHarnessTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,10 @@ final class SwiftNetworkQUICHarnessTests: NetTestCase {
QUICTestHarness().runQUICResetStreamFirstFrameOnNewStream()
}

func testQUICEmptyFinCleanCloseSendsFinNotReset() {
QUICTestHarness().runEmptyFinCleanCloseSendsFinNotReset()
}

func testQUICResetStreamDoesNotAffectOppositeDirection() {
QUICTestHarness().runQUICTest(
streamCount: 1,
Expand Down
Loading