Skip to content

Connection state is not fully discarded after finalization #3258

Description

@LiD0209

Connection state is not fully discarded after finalization

Summary

RFC 9000 says an endpoint should discard all connection state after closing/draining ends. s2n-quic removes the finalized connection from the endpoint container, but if an application Connection or stream-derived handle is still alive, the same Arc<ConnectionNode> keeps the full connection object alive. The original ECN evidence was unrelated.

Standard Requirement

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

Once its closing or draining state ends, an endpoint SHOULD discard all connection state.

Context: the next sentence allows stateless reset for later packets belonging to the old connection. That only makes sense after old per-connection state has been discarded.

Relevant Source Code

Closing timeout marks the connection finished:

// quic/s2n-quic-transport/src/connection/connection_impl.rs:1182-1187
if self.close_sender.on_timeout(timestamp).is_ready() {
    self.state = ConnectionState::Finished;
}

Draining and Finished request finalization:

// quic/s2n-quic-transport/src/connection/connection_impl.rs:2193-2200
ConnectionState::Draining | ConnectionState::Finished => {
    interests.transmission = false;
    interests.finalization = true;
}

Finalization removes the node 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);
}

But removal only unlinks the node from container lists/maps:

// quic/s2n-quic-transport/src/connection/connection_container.rs:1137-1145
fn remove_node(&mut self, connection: &ConnectionNode<C, L>) {
    let mut cursor = self.connection_map.find_mut(&connection.internal_connection_id);
    let remove_result = cursor.remove();
    self.interest_lists.remove_node(connection);
}

Application handles keep another Arc to the same node:

// quic/s2n-quic-transport/src/connection/api_provider.rs:26-27
pub(crate) type ConnectionApi = Arc<dyn ConnectionApiProvider>;

// quic/s2n-quic-transport/src/connection/api.rs:26-37
pub struct Connection {
    pub(super) api: ConnectionApi,
    open_token: OpenToken,
}

CID cleanup is tied to dropping the contained LocalIdRegistry, not to container finalization:

// quic/s2n-quic-transport/src/connection/local_id_registry.rs:221-231
impl Drop for LocalIdRegistry {
    fn drop(&mut self) {
        for id_info in &self.registered_ids {
            guard.local_id_map.remove(&id_info.id);
        }
        guard.initial_id_map.remove(&self.internal_id);
    }
}

Implementation Behavior

When finalization runs, s2n-quic removes endpoint reachability for the connection. However, a live application handle can keep ConnectionNode and its ConnectionImpl allocated, including protocol-owned state such as CID registry, packet spaces, path state, timers, and API/error state.

Inconsistency Reason

The implementation does not unconditionally discard all connection state when closing/draining ends. It discards the endpoint container entry, but full state destruction is delayed until all application-owned Arc handles are dropped.

Runtime Evidence

Temporary focused test added, run, then removed:

container.insert_connection(TestConnection::default(), id);
let handle = container.get_connection_handle(&id).unwrap();

container.with_connection(id, |conn| {
    conn.interests = ConnectionInterests { finalization: true, ..Default::default() };
});

assert_eq!(0, container.len());
assert!(container.get_connection_handle(&id).is_none());
assert_eq!(Ok(SocketAddress::default()), handle.remote_address());

Command run from implementions/s2n-quic:

cargo test -p s2n-quic-transport connection::connection_container::tests::finalized_connection_state_survives_while_application_handle_exists -- --exact --nocapture

Result:

running 1 test
test connection::connection_container::tests::finalized_connection_state_survives_while_application_handle_exists ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 354 filtered out; finished in 0.00s

Saved logs:

  • reports/runtime/0173-finalized-state-retained.stdout.log
  • reports/runtime/0173-finalized-state-retained.stderr.log
  • reports/runtime/0173-finalized-state-retained.exitcode.txt

Impact

This is a SHOULD-level conformance issue. It mainly affects cleanup semantics and memory/state lifetime after finalization; endpoint routing is removed, but full connection state can persist while application handles remain alive.

Fix Direction

On finalization, separate application-handle lifetime from protocol-state lifetime. Clear or move protocol-owned state immediately, leaving only minimal closed/error state needed for outstanding handles.

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