Skip to content

Server draining state ends early #3259

Description

@LiD0209

Server draining state ends early

Summary

The earlier static source excerpt was unrelated, but the RFC 9000 closing/draining issue is real. For a server endpoint that keeps accepting new connections, s2n-quic enters Draining on a remote close or stateless reset, then immediately finalizes the connection with no close/drain timeout.

Standard Requirement

Official reference: RFC 9000 Section 10.2, "Immediate Close" and Section 10.2.2, "Draining Connection State": https://www.rfc-editor.org/rfc/rfc9000#section-10.2

Key normative text:

Servers that retain an open socket for accepting new connections SHOULD NOT end the closing or draining state early.

Relevant standard meaning:

  • Closing/draining exists to discard delayed or reordered packets.
  • These states should normally last at least 3 * PTO.
  • Early ending is allowed only when late packets cannot induce a response, for example after closing the UDP socket.
  • In Draining, the endpoint must not send packets.

Interpretation: a server that keeps its UDP socket open for new connections may stop transmitting in Draining, but should not immediately discard the old connection state before the close/drain period expires.

Relevant Source Code

Remote close and stateless reset enter Draining:

// quic/s2n-quic-transport/src/connection/connection_impl.rs:135-149
connection::Error::Closed { .. }
| connection::Error::Transport { .. }
| connection::Error::Application { .. } => ConnectionState::Draining,
connection::Error::StatelessReset { .. } => ConnectionState::Draining,

Only the local close path arms the 3-PTO retention timer:

// quic/s2n-quic-transport/src/connection/connection_impl.rs:913-920
let timeout = 3 * self.current_pto();
self.close_sender.close(packet, timeout, timestamp);

Draining is immediately marked final and receives no timeout:

// quic/s2n-quic-transport/src/connection/connection_impl.rs:2193-2209
ConnectionState::Draining | ConnectionState::Finished => {
    interests.transmission = false;
    interests.finalization = true;
}
if interests.finalization {
    interests = ConnectionInterests { finalization: true, ..Default::default() };
} else {
    interests.timeout = self.next_expiration();
}

Finalized connections are removed from the endpoint container:

// quic/s2n-quic-transport/src/connection/connection_container.rs:957-959
for connection in self.interest_lists.done_connections.take() {
    self.remove_node(&connection);
}

The public server remains able to accept more connections:

// quic/s2n-quic/src/server.rs:91-94
// Poll::Ready(Some(connection)) once a new connection has been established.
// This function can be called again to try and accept new connections.

Implementation Behavior

Local immediate close follows the RFC retention guidance by creating a CloseSender timeout of 3 * current_pto(). Remote-initiated close and stateless reset do not create that timer. They move to ConnectionState::Draining, set transmission = false, then set finalization = true, which causes container removal.

Inconsistency Reason

The implementation satisfies the Draining no-transmission requirement, but it ends Draining immediately. That conflicts with RFC 9000 Section 10.2 for servers that retain an open socket for accepting new connections, because those servers should not use the early-ending exception.

Runtime Evidence

Temporary focused test added, run, then removed:

connection.close(connection::Error::closed(endpoint::Location::Remote), ...);
let interests = connection.interests();

assert_eq!(ConnectionState::Draining, connection.state);
assert!(interests.finalization);
assert!(interests.timeout.is_none());

Command run from implementions/s2n-quic:

cargo test -p s2n-quic-transport connection::connection_impl::tests::remote_draining_immediately_finalizes_without_timeout -- --exact --nocapture

Result:

running 1 test
test connection::connection_impl::tests::remote_draining_immediately_finalizes_without_timeout ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 354 filtered out

Saved logs:

  • reports/runtime/0172-remote-draining-finalization.stdout.log
  • reports/runtime/0172-remote-draining-finalization.stderr.log
  • reports/runtime/0172-remote-draining-finalization.exitcode.txt

Impact

This is a SHOULD NOT-level conformance issue. A late packet for the old connection can arrive after s2n-quic has already removed the draining connection state, while the server endpoint remains open for new connections.

Fix Direction

Retain remote Draining state until a close/drain deadline, normally 3 * current_pto(), without enabling packet transmission. This could be a separate draining timer or a reuse of the existing close finalization timer in a no-transmit mode.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions