fix: add basic tests for listener

This commit is contained in:
Akash Mondal
2025-06-12 10:03:08 +00:00
committed by lla-dane
parent 54b3055eaa
commit a3231af714
10 changed files with 892 additions and 100 deletions

View File

@ -7,10 +7,45 @@ from dataclasses import (
field,
)
import ssl
from typing import TypedDict
from libp2p.custom_types import TProtocol
class QUICTransportKwargs(TypedDict, total=False):
"""Type definition for kwargs accepted by new_transport function."""
# Connection settings
idle_timeout: float
max_datagram_size: int
local_port: int | None
# Protocol version support
enable_draft29: bool
enable_v1: bool
# TLS settings
verify_mode: ssl.VerifyMode
alpn_protocols: list[str]
# Performance settings
max_concurrent_streams: int
connection_window: int
stream_window: int
# Logging and debugging
enable_qlog: bool
qlog_dir: str | None
# Connection management
max_connections: int
connection_timeout: float
# Protocol identifiers
PROTOCOL_QUIC_V1: TProtocol
PROTOCOL_QUIC_DRAFT29: TProtocol
@dataclass
class QUICTransportConfig:
"""Configuration for QUIC transport."""
@ -47,7 +82,7 @@ class QUICTransportConfig:
PROTOCOL_QUIC_V1: TProtocol = TProtocol("quic") # RFC 9000
PROTOCOL_QUIC_DRAFT29: TProtocol = TProtocol("quic") # draft-29
def __post_init__(self):
def __post_init__(self) -> None:
"""Validate configuration after initialization."""
if not (self.enable_draft29 or self.enable_v1):
raise ValueError("At least one QUIC version must be enabled")

View File

@ -50,7 +50,7 @@ class QUICConnection(IRawConnection, IMuxedConn):
Uses aioquic's sans-IO core with trio for native async support.
QUIC natively provides stream multiplexing, so this connection acts as both
a raw connection (for transport layer) and muxed connection (for upper layers).
Updated to work properly with the QUIC listener for server-side connections.
"""
@ -92,18 +92,20 @@ class QUICConnection(IRawConnection, IMuxedConn):
self._background_tasks_started = False
self._nursery: trio.Nursery | None = None
logger.debug(f"Created QUIC connection to {peer_id} (initiator: {is_initiator})")
logger.debug(
f"Created QUIC connection to {peer_id} (initiator: {is_initiator})"
)
def _calculate_initial_stream_id(self) -> int:
"""
Calculate the initial stream ID based on QUIC specification.
QUIC stream IDs:
- Client-initiated bidirectional: 0, 4, 8, 12, ...
- Server-initiated bidirectional: 1, 5, 9, 13, ...
- Client-initiated unidirectional: 2, 6, 10, 14, ...
- Server-initiated unidirectional: 3, 7, 11, 15, ...
For libp2p, we primarily use bidirectional streams.
"""
if self.__is_initiator:
@ -118,7 +120,7 @@ class QUICConnection(IRawConnection, IMuxedConn):
async def start(self) -> None:
"""
Start the connection and its background tasks.
This method implements the IMuxedConn.start() interface.
It should be called to begin processing connection events.
"""
@ -165,7 +167,9 @@ class QUICConnection(IRawConnection, IMuxedConn):
if not self._background_tasks_started:
# We would need a nursery to start background tasks
# This is a limitation of the current design
logger.warning("Background tasks need nursery - connection may not work properly")
logger.warning(
"Background tasks need nursery - connection may not work properly"
)
except Exception as e:
logger.error(f"Failed to initiate connection: {e}")
@ -174,13 +178,15 @@ class QUICConnection(IRawConnection, IMuxedConn):
async def connect(self, nursery: trio.Nursery) -> None:
"""
Establish the QUIC connection using trio.
Args:
nursery: Trio nursery for background tasks
"""
if not self.__is_initiator:
raise QUICConnectionError("connect() should only be called by client connections")
raise QUICConnectionError(
"connect() should only be called by client connections"
)
try:
# Store nursery for background tasks
@ -321,7 +327,7 @@ class QUICConnection(IRawConnection, IMuxedConn):
def _is_incoming_stream(self, stream_id: int) -> bool:
"""
Determine if a stream ID represents an incoming stream.
For bidirectional streams:
- Even IDs are client-initiated
- Odd IDs are server-initiated
@ -463,11 +469,7 @@ class QUICConnection(IRawConnection, IMuxedConn):
self._next_stream_id += 4 # Increment by 4 for bidirectional streams
# Create stream
stream = QUICStream(
connection=self,
stream_id=stream_id,
is_initiator=True
)
stream = QUICStream(connection=self, stream_id=stream_id, is_initiator=True)
self._streams[stream_id] = stream
@ -530,9 +532,10 @@ class QUICConnection(IRawConnection, IMuxedConn):
# The certificate should contain the peer ID in a specific extension
raise NotImplementedError("Certificate peer ID extraction not implemented")
def get_stats(self) -> dict:
# TODO: Define type for stats
def get_stats(self) -> dict[str, object]:
"""Get connection statistics."""
return {
stats: dict[str, object] = {
"peer_id": str(self._peer_id),
"remote_addr": self._remote_addr,
"is_initiator": self.__is_initiator,
@ -542,10 +545,16 @@ class QUICConnection(IRawConnection, IMuxedConn):
"active_streams": len(self._streams),
"next_stream_id": self._next_stream_id,
}
return stats
def get_remote_address(self):
def get_remote_address(self) -> tuple[str, int]:
return self._remote_addr
def __str__(self) -> str:
"""String representation of the connection."""
return f"QUICConnection(peer={self._peer_id}, streams={len(self._streams)}, established={self._established}, started={self._started})"
id = self._peer_id
estb = self._established
stream_len = len(self._streams)
return f"QUICConnection(peer={id}, streams={stream_len}".__add__(
f"established={estb}, started={self._started})"
)

View File

@ -8,7 +8,7 @@ import copy
import logging
import socket
import time
from typing import TYPE_CHECKING, Dict
from typing import TYPE_CHECKING
from aioquic.quic import events
from aioquic.quic.configuration import QuicConfiguration
@ -49,7 +49,7 @@ class QUICListener(IListener):
self,
transport: "QUICTransport",
handler_function: THandler,
quic_configs: Dict[TProtocol, QuicConfiguration],
quic_configs: dict[TProtocol, QuicConfiguration],
config: QUICTransportConfig,
):
"""
@ -72,8 +72,8 @@ class QUICListener(IListener):
self._bound_addresses: list[Multiaddr] = []
# Connection management
self._connections: Dict[tuple[str, int], QUICConnection] = {}
self._pending_connections: Dict[tuple[str, int], QuicConnection] = {}
self._connections: dict[tuple[str, int], QUICConnection] = {}
self._pending_connections: dict[tuple[str, int], QuicConnection] = {}
self._connection_lock = trio.Lock()
# Listener state
@ -104,6 +104,7 @@ class QUICListener(IListener):
Raises:
QUICListenError: If failed to start listening
"""
if not is_quic_multiaddr(maddr):
raise QUICListenError(f"Invalid QUIC multiaddr: {maddr}")
@ -133,11 +134,11 @@ class QUICListener(IListener):
self._listening = True
# Start background tasks directly in the provided nursery
# This ensures proper cancellation when the nursery exits
# This e per cancellation when the nursery exits
nursery.start_soon(self._handle_incoming_packets)
nursery.start_soon(self._manage_connections)
print(f"QUIC listener started on {actual_maddr}")
logger.info(f"QUIC listener started on {actual_maddr}")
return True
except trio.Cancelled:
@ -190,7 +191,8 @@ class QUICListener(IListener):
try:
while self._listening and self._socket:
try:
# Receive UDP packet (this blocks until packet arrives or socket closes)
# Receive UDP packet
# (this blocks until packet arrives or socket closes)
data, addr = await self._socket.recvfrom(65536)
self._stats["bytes_received"] += len(data)
self._stats["packets_processed"] += 1
@ -208,10 +210,9 @@ class QUICListener(IListener):
# Continue processing other packets
await trio.sleep(0.01)
except trio.Cancelled:
print("PACKET HANDLER CANCELLED - FORCIBLY CLOSING SOCKET")
logger.info("Received Cancel, stopping handling incoming packets")
raise
finally:
print("PACKET HANDLER FINISHED")
logger.debug("Packet handling loop terminated")
async def _process_packet(self, data: bytes, addr: tuple[str, int]) -> None:
@ -456,10 +457,7 @@ class QUICListener(IListener):
except Exception as e:
logger.error(f"Error in connection management: {e}")
except trio.Cancelled:
print("CONNECTION MANAGER CANCELLED")
raise
finally:
print("CONNECTION MANAGER FINISHED")
async def _cleanup_closed_connections(self) -> None:
"""Remove closed connections from tracking."""
@ -500,20 +498,20 @@ class QUICListener(IListener):
self._closed = True
self._listening = False
print("Closing QUIC listener")
logger.debug("Closing QUIC listener")
# CRITICAL: Close socket FIRST to unblock recvfrom()
await self._cleanup_socket()
print("SOCKET CLEANUP COMPLETE")
logger.debug("SOCKET CLEANUP COMPLETE")
# Close all connections WITHOUT using the lock during shutdown
# (avoid deadlock if background tasks are cancelled while holding lock)
connections_to_close = list(self._connections.values())
pending_to_close = list(self._pending_connections.values())
print(
f"CLOSING {len(connections_to_close)} connections and {len(pending_to_close)} pending"
logger.debug(
f"CLOSING {connections_to_close} connections and {pending_to_close} pending"
)
# Close active connections
@ -533,10 +531,7 @@ class QUICListener(IListener):
# Clear the dictionaries without lock (we're shutting down)
self._connections.clear()
self._pending_connections.clear()
if self._nursery:
print("TASKS", len(self._nursery.child_tasks))
print("QUIC listener closed")
logger.debug("QUIC listener closed")
async def _cleanup_socket(self) -> None:
"""Clean up the UDP socket."""
@ -562,7 +557,7 @@ class QUICListener(IListener):
"""Check if the listener is actively listening."""
return self._listening and not self._closed
def get_stats(self) -> dict:
def get_stats(self) -> dict[str, int]:
"""Get listener statistics."""
stats = self._stats.copy()
stats.update(
@ -576,4 +571,6 @@ class QUICListener(IListener):
def __str__(self) -> str:
"""String representation of the listener."""
return f"QUICListener(addrs={self._bound_addresses}, connections={len(self._connections)})"
addr = self._bound_addresses
conn_count = len(self._connections)
return f"QUICListener(addrs={addr}, connections={conn_count})"

View File

@ -7,7 +7,6 @@ Full implementation will be in Module 5.
from dataclasses import dataclass
import os
import tempfile
from typing import Optional
from libp2p.crypto.keys import PrivateKey
from libp2p.peer.id import ID
@ -21,7 +20,7 @@ class TLSConfig:
cert_file: str
key_file: str
ca_file: Optional[str] = None
ca_file: str | None = None
def generate_libp2p_tls_config(private_key: PrivateKey, peer_id: ID) -> TLSConfig:

View File

@ -116,7 +116,8 @@ class QUICStream(IMuxedStream):
"""
Reset the stream
"""
self.handle_reset(0)
await self.handle_reset(0)
return
def get_remote_address(self) -> tuple[str, int] | None:
return self._connection._remote_addr

View File

@ -15,9 +15,9 @@ from aioquic.quic.connection import (
)
import multiaddr
import trio
from typing_extensions import Unpack
from libp2p.abc import (
IListener,
IRawConnection,
ITransport,
)
@ -28,6 +28,7 @@ from libp2p.custom_types import THandler, TProtocol
from libp2p.peer.id import (
ID,
)
from libp2p.transport.quic.config import QUICTransportKwargs
from libp2p.transport.quic.utils import (
is_quic_multiaddr,
multiaddr_to_quic_version,
@ -131,7 +132,10 @@ class QUICTransport(ITransport):
# # This follows the libp2p TLS spec for peer identity verification
# tls_config = generate_libp2p_tls_config(self._private_key, self._peer_id)
# config.load_cert_chain(certfile=tls_config.cert_file, keyfile=tls_config.key_file)
# config.load_cert_chain(
# certfile=tls_config.cert_file,
# keyfile=tls_config.key_file
# )
# if tls_config.ca_file:
# config.load_verify_locations(tls_config.ca_file)
@ -210,7 +214,7 @@ class QUICTransport(ITransport):
logger.error(f"Failed to dial QUIC connection to {maddr}: {e}")
raise QUICDialError(f"Dial failed: {e}") from e
def create_listener(self, handler_function: THandler) -> IListener:
def create_listener(self, handler_function: THandler) -> QUICListener:
"""
Create a QUIC listener.
@ -298,12 +302,18 @@ class QUICTransport(ITransport):
logger.info("QUIC transport closed")
def get_stats(self) -> dict:
def get_stats(self) -> dict[str, int | list[str] | object]:
"""Get transport statistics."""
stats = {
protocols = self.protocols()
str_protocols = []
for proto in protocols:
str_protocols.append(str(proto))
stats: dict[str, int | list[str] | object] = {
"active_connections": len(self._connections),
"active_listeners": len(self._listeners),
"supported_protocols": self.protocols(),
"supported_protocols": str_protocols,
}
# Aggregate listener stats
@ -324,7 +334,9 @@ class QUICTransport(ITransport):
def new_transport(
private_key: PrivateKey, config: QUICTransportConfig | None = None, **kwargs
private_key: PrivateKey,
config: QUICTransportConfig | None = None,
**kwargs: Unpack[QUICTransportKwargs],
) -> QUICTransport:
"""
Factory function to create a new QUIC transport.

View File

@ -3,8 +3,6 @@ Multiaddr utilities for QUIC transport.
Handles QUIC-specific multiaddr parsing and validation.
"""
from typing import Tuple
import multiaddr
from libp2p.custom_types import TProtocol
@ -54,7 +52,7 @@ def is_quic_multiaddr(maddr: multiaddr.Multiaddr) -> bool:
return False
def quic_multiaddr_to_endpoint(maddr: multiaddr.Multiaddr) -> Tuple[str, int]:
def quic_multiaddr_to_endpoint(maddr: multiaddr.Multiaddr) -> tuple[str, int]:
"""
Extract host and port from a QUIC multiaddr.
@ -78,20 +76,21 @@ def quic_multiaddr_to_endpoint(maddr: multiaddr.Multiaddr) -> Tuple[str, int]:
# Try to get IPv4 address
try:
host = maddr.value_for_protocol(multiaddr.protocols.P_IP4) # type: ignore
host = maddr.value_for_protocol(multiaddr.protocols.P_IP4) # type: ignore
except ValueError:
pass
# Try to get IPv6 address if IPv4 not found
if host is None:
try:
host = maddr.value_for_protocol(multiaddr.protocols.P_IP6) # type: ignore
host = maddr.value_for_protocol(multiaddr.protocols.P_IP6) # type: ignore
except ValueError:
pass
# Get UDP port
try:
port_str = maddr.value_for_protocol(multiaddr.protocols.P_UDP)
# The the package is exposed by types not availble
port_str = maddr.value_for_protocol(multiaddr.protocols.P_UDP) # type: ignore
port = int(port_str)
except ValueError:
pass

View File

@ -0,0 +1,765 @@
"""
Integration tests for QUIC transport that test actual networking.
These tests require network access and test real socket operations.
"""
import logging
import random
import socket
import time
import pytest
import trio
from libp2p.crypto.ed25519 import create_new_key_pair
from libp2p.transport.quic.config import QUICTransportConfig
from libp2p.transport.quic.transport import QUICTransport
from libp2p.transport.quic.utils import create_quic_multiaddr
logger = logging.getLogger(__name__)
class TestQUICNetworking:
"""Integration tests that use actual networking."""
@pytest.fixture
def server_config(self):
"""Server configuration."""
return QUICTransportConfig(
idle_timeout=10.0,
connection_timeout=5.0,
max_concurrent_streams=100,
)
@pytest.fixture
def client_config(self):
"""Client configuration."""
return QUICTransportConfig(
idle_timeout=10.0,
connection_timeout=5.0,
)
@pytest.fixture
def server_key(self):
"""Generate server key pair."""
return create_new_key_pair().private_key
@pytest.fixture
def client_key(self):
"""Generate client key pair."""
return create_new_key_pair().private_key
@pytest.mark.trio
async def test_listener_binding_real_socket(self, server_key, server_config):
"""Test that listener can bind to real socket."""
transport = QUICTransport(server_key, server_config)
async def connection_handler(connection):
logger.info(f"Received connection: {connection}")
listener = transport.create_listener(connection_handler)
listen_addr = create_quic_multiaddr("127.0.0.1", 0, "/quic")
async with trio.open_nursery() as nursery:
try:
success = await listener.listen(listen_addr, nursery)
assert success
# Verify we got a real port
addrs = listener.get_addrs()
assert len(addrs) == 1
# Port should be non-zero (was assigned)
from libp2p.transport.quic.utils import quic_multiaddr_to_endpoint
host, port = quic_multiaddr_to_endpoint(addrs[0])
assert host == "127.0.0.1"
assert port > 0
logger.info(f"Listener bound to {host}:{port}")
# Listener should be active
assert listener.is_listening()
# Test basic stats
stats = listener.get_stats()
assert stats["active_connections"] == 0
assert stats["pending_connections"] == 0
# Close listener
await listener.close()
assert not listener.is_listening()
finally:
await transport.close()
@pytest.mark.trio
async def test_multiple_listeners_different_ports(self, server_key, server_config):
"""Test multiple listeners on different ports."""
transport = QUICTransport(server_key, server_config)
async def connection_handler(connection):
pass
listeners = []
bound_ports = []
# Create multiple listeners
for i in range(3):
listener = transport.create_listener(connection_handler)
listen_addr = create_quic_multiaddr("127.0.0.1", 0, "/quic")
try:
async with trio.open_nursery() as nursery:
success = await listener.listen(listen_addr, nursery)
assert success
# Get bound port
addrs = listener.get_addrs()
from libp2p.transport.quic.utils import quic_multiaddr_to_endpoint
host, port = quic_multiaddr_to_endpoint(addrs[0])
bound_ports.append(port)
listeners.append(listener)
logger.info(f"Listener {i} bound to port {port}")
nursery.cancel_scope.cancel()
finally:
await listener.close()
# All ports should be different
assert len(set(bound_ports)) == len(bound_ports)
@pytest.mark.trio
async def test_port_already_in_use(self, server_key, server_config):
"""Test handling of port already in use."""
transport1 = QUICTransport(server_key, server_config)
transport2 = QUICTransport(server_key, server_config)
async def connection_handler(connection):
pass
listener1 = transport1.create_listener(connection_handler)
listener2 = transport2.create_listener(connection_handler)
# Bind first listener to a specific port
listen_addr = create_quic_multiaddr("127.0.0.1", 0, "/quic")
async with trio.open_nursery() as nursery:
success1 = await listener1.listen(listen_addr, nursery)
assert success1
# Get the actual bound port
addrs = listener1.get_addrs()
from libp2p.transport.quic.utils import quic_multiaddr_to_endpoint
host, port = quic_multiaddr_to_endpoint(addrs[0])
# Try to bind second listener to same port
# Should fail or get different port
same_port_addr = create_quic_multiaddr("127.0.0.1", port, "/quic")
# This might either fail or succeed with SO_REUSEPORT
# The exact behavior depends on the system
try:
success2 = await listener2.listen(same_port_addr, nursery)
if success2:
# If it succeeds, verify different behavior
logger.info("Second listener bound successfully (SO_REUSEPORT)")
except Exception as e:
logger.info(f"Second listener failed as expected: {e}")
await listener1.close()
await listener2.close()
await transport1.close()
await transport2.close()
@pytest.mark.trio
async def test_listener_connection_tracking(self, server_key, server_config):
"""Test that listener properly tracks connection state."""
transport = QUICTransport(server_key, server_config)
received_connections = []
async def connection_handler(connection):
received_connections.append(connection)
logger.info(f"Handler received connection: {connection}")
# Keep connection alive briefly
await trio.sleep(0.1)
listener = transport.create_listener(connection_handler)
listen_addr = create_quic_multiaddr("127.0.0.1", 0, "/quic")
async with trio.open_nursery() as nursery:
success = await listener.listen(listen_addr, nursery)
assert success
# Initially no connections
stats = listener.get_stats()
assert stats["active_connections"] == 0
assert stats["pending_connections"] == 0
# Simulate some packet processing
await trio.sleep(0.1)
# Verify listener is still healthy
assert listener.is_listening()
await listener.close()
await transport.close()
@pytest.mark.trio
async def test_listener_error_recovery(self, server_key, server_config):
"""Test listener error handling and recovery."""
transport = QUICTransport(server_key, server_config)
# Handler that raises an exception
async def failing_handler(connection):
raise ValueError("Simulated handler error")
listener = transport.create_listener(failing_handler)
listen_addr = create_quic_multiaddr("127.0.0.1", 0, "/quic")
try:
async with trio.open_nursery() as nursery:
success = await listener.listen(listen_addr, nursery)
assert success
# Even with failing handler, listener should remain stable
await trio.sleep(0.1)
assert listener.is_listening()
# Test complete, stop listening
nursery.cancel_scope.cancel()
finally:
await listener.close()
await transport.close()
@pytest.mark.trio
async def test_transport_resource_cleanup_v1(self, server_key, server_config):
"""Test with single parent nursery managing all listeners."""
transport = QUICTransport(server_key, server_config)
async def connection_handler(connection):
pass
listeners = []
try:
async with trio.open_nursery() as parent_nursery:
# Start all listeners in parallel within the same nursery
for i in range(3):
listener = transport.create_listener(connection_handler)
listen_addr = create_quic_multiaddr("127.0.0.1", 0, "/quic")
listeners.append(listener)
parent_nursery.start_soon(
listener.listen, listen_addr, parent_nursery
)
# Give listeners time to start
await trio.sleep(0.2)
# Verify all listeners are active
for i, listener in enumerate(listeners):
assert listener.is_listening()
# Close transport should close all listeners
await transport.close()
# The nursery will exit cleanly because listeners are closed
finally:
# Cleanup verification outside nursery
assert transport._closed
assert len(transport._listeners) == 0
# All listeners should be closed
for listener in listeners:
assert not listener.is_listening()
@pytest.mark.trio
async def test_concurrent_listener_operations(self, server_key, server_config):
"""Test concurrent listener operations."""
transport = QUICTransport(server_key, server_config)
async def connection_handler(connection):
await trio.sleep(0.01) # Simulate some work
async def create_and_run_listener(listener_id):
"""Create, run, and close a listener."""
listener = transport.create_listener(connection_handler)
listen_addr = create_quic_multiaddr("127.0.0.1", 0, "/quic")
async with trio.open_nursery() as nursery:
success = await listener.listen(listen_addr, nursery)
assert success
logger.info(f"Listener {listener_id} started")
# Run for a short time
await trio.sleep(0.1)
await listener.close()
logger.info(f"Listener {listener_id} closed")
try:
# Run multiple listeners concurrently
async with trio.open_nursery() as nursery:
for i in range(5):
nursery.start_soon(create_and_run_listener, i)
finally:
await transport.close()
class TestQUICConcurrency:
"""Fixed tests with proper nursery management."""
@pytest.fixture
def server_key(self):
"""Generate server key pair."""
return create_new_key_pair().private_key
@pytest.fixture
def server_config(self):
"""Server configuration."""
return QUICTransportConfig(
idle_timeout=10.0,
connection_timeout=5.0,
max_concurrent_streams=100,
)
@pytest.mark.trio
async def test_concurrent_listener_operations(self, server_key, server_config):
"""Test concurrent listener operations - FIXED VERSION."""
transport = QUICTransport(server_key, server_config)
async def connection_handler(connection):
await trio.sleep(0.01) # Simulate some work
listeners = []
async def create_and_run_listener(listener_id):
"""Create and run a listener - fixed to avoid deadlock."""
listener = transport.create_listener(connection_handler)
listen_addr = create_quic_multiaddr("127.0.0.1", 0, "/quic")
listeners.append(listener)
try:
async with trio.open_nursery() as nursery:
success = await listener.listen(listen_addr, nursery)
assert success
logger.info(f"Listener {listener_id} started")
# Run for a short time
await trio.sleep(0.1)
# Close INSIDE the nursery scope to allow clean exit
await listener.close()
logger.info(f"Listener {listener_id} closed")
except Exception as e:
logger.error(f"Listener {listener_id} error: {e}")
if not listener._closed:
await listener.close()
raise
try:
# Run multiple listeners concurrently
async with trio.open_nursery() as nursery:
for i in range(5):
nursery.start_soon(create_and_run_listener, i)
# Verify all listeners were created and closed properly
assert len(listeners) == 5
for listener in listeners:
assert not listener.is_listening() # Should all be closed
finally:
await transport.close()
@pytest.mark.trio
@pytest.mark.slow
async def test_listener_under_simulated_load(self, server_key, server_config):
"""REAL load test with actual packet simulation."""
print("=== REAL LOAD TEST ===")
config = QUICTransportConfig(
idle_timeout=30.0,
connection_timeout=10.0,
max_concurrent_streams=1000,
max_connections=500,
)
transport = QUICTransport(server_key, config)
connection_count = 0
async def connection_handler(connection):
nonlocal connection_count
# TODO: Remove type ignore when pyrefly fixes nonlocal bug
connection_count += 1 # type: ignore
print(f"Real connection established: {connection_count}")
# Simulate connection work
await trio.sleep(0.01)
listener = transport.create_listener(connection_handler)
listen_addr = create_quic_multiaddr("127.0.0.1", 0, "/quic")
async def generate_udp_traffic(target_host, target_port, num_packets=100):
"""Generate fake UDP traffic to simulate load."""
print(
f"Generating {num_packets} UDP packets to {target_host}:{target_port}"
)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
for i in range(num_packets):
# Send random UDP packets
# (Won't be valid QUIC, but will exercise packet handler)
fake_packet = (
f"FAKE_PACKET_{i}_{random.randint(1000, 9999)}".encode()
)
sock.sendto(fake_packet, (target_host, int(target_port)))
# Small delay between packets
await trio.sleep(0.001)
if i % 20 == 0:
print(f"Sent {i + 1}/{num_packets} packets")
except Exception as e:
print(f"Error sending packets: {e}")
finally:
sock.close()
print(f"Finished sending {num_packets} packets")
try:
async with trio.open_nursery() as nursery:
success = await listener.listen(listen_addr, nursery)
assert success
# Get the actual bound port
bound_addrs = listener.get_addrs()
bound_addr = bound_addrs[0]
print(bound_addr)
host, port = (
bound_addr.value_for_protocol("ip4"),
bound_addr.value_for_protocol("udp"),
)
print(f"Listener bound to {host}:{port}")
# Start load generation
nursery.start_soon(generate_udp_traffic, host, port, 50)
# Let the load test run
start_time = time.time()
await trio.sleep(2.0) # Let traffic flow for 2 seconds
end_time = time.time()
# Check that listener handled the load
stats = listener.get_stats()
print(f"Final stats: {stats}")
# Should have received packets (even if they're invalid QUIC)
assert stats["packets_processed"] > 0
assert stats["bytes_received"] > 0
duration = end_time - start_time
print(f"Load test ran for {duration:.2f}s")
print(f"Processed {stats['packets_processed']} packets")
print(f"Received {stats['bytes_received']} bytes")
await listener.close()
finally:
if not listener._closed:
await listener.close()
await transport.close()
class TestQUICRealWorldScenarios:
"""Test real-world usage scenarios - FIXED VERSIONS."""
@pytest.mark.trio
async def test_echo_server_pattern(self):
"""Test a basic echo server pattern - FIXED VERSION."""
server_key = create_new_key_pair().private_key
config = QUICTransportConfig(idle_timeout=5.0)
transport = QUICTransport(server_key, config)
echo_data = []
async def echo_connection_handler(connection):
"""Echo server that handles one connection."""
logger.info(f"Echo server got connection: {connection}")
async def stream_handler(stream):
try:
# Read data and echo it back
while True:
data = await stream.read(1024)
if not data:
break
echo_data.append(data)
await stream.write(b"ECHO: " + data)
except Exception as e:
logger.error(f"Stream error: {e}")
finally:
await stream.close()
connection.set_stream_handler(stream_handler)
# Keep connection alive until closed
while not connection.is_closed:
await trio.sleep(0.1)
listener = transport.create_listener(echo_connection_handler)
listen_addr = create_quic_multiaddr("127.0.0.1", 0, "/quic")
try:
async with trio.open_nursery() as nursery:
success = await listener.listen(listen_addr, nursery)
assert success
# Let server initialize
await trio.sleep(0.1)
# Verify server is ready
assert listener.is_listening()
# Run server for a bit
await trio.sleep(0.5)
# Close inside nursery for clean exit
await listener.close()
finally:
# Ensure cleanup
if not listener._closed:
await listener.close()
await transport.close()
@pytest.mark.trio
async def test_connection_lifecycle_monitoring(self):
"""Test monitoring connection lifecycle events - FIXED VERSION."""
server_key = create_new_key_pair().private_key
config = QUICTransportConfig(idle_timeout=5.0)
transport = QUICTransport(server_key, config)
lifecycle_events = []
async def monitoring_handler(connection):
lifecycle_events.append(("connection_started", connection.get_stats()))
try:
# Monitor connection
while not connection.is_closed:
stats = connection.get_stats()
lifecycle_events.append(("connection_stats", stats))
await trio.sleep(0.1)
except Exception as e:
lifecycle_events.append(("connection_error", str(e)))
finally:
lifecycle_events.append(("connection_ended", connection.get_stats()))
listener = transport.create_listener(monitoring_handler)
listen_addr = create_quic_multiaddr("127.0.0.1", 0, "/quic")
try:
async with trio.open_nursery() as nursery:
success = await listener.listen(listen_addr, nursery)
assert success
# Run monitoring for a bit
await trio.sleep(0.5)
# Check that monitoring infrastructure is working
assert listener.is_listening()
# Close inside nursery
await listener.close()
finally:
# Ensure cleanup
if not listener._closed:
await listener.close()
await transport.close()
# Should have some lifecycle events from setup
logger.info(f"Recorded {len(lifecycle_events)} lifecycle events")
@pytest.mark.trio
async def test_multi_listener_echo_servers(self):
"""Test multiple echo servers running in parallel."""
server_key = create_new_key_pair().private_key
config = QUICTransportConfig(idle_timeout=5.0)
transport = QUICTransport(server_key, config)
all_echo_data = {}
listeners = []
async def create_echo_server(server_id):
"""Create and run one echo server."""
echo_data = []
all_echo_data[server_id] = echo_data
async def echo_handler(connection):
logger.info(f"Echo server {server_id} got connection")
async def stream_handler(stream):
try:
while True:
data = await stream.read(1024)
if not data:
break
echo_data.append(data)
await stream.write(f"ECHO-{server_id}: ".encode() + data)
except Exception as e:
logger.error(f"Stream error in server {server_id}: {e}")
finally:
await stream.close()
connection.set_stream_handler(stream_handler)
while not connection.is_closed:
await trio.sleep(0.1)
listener = transport.create_listener(echo_handler)
listen_addr = create_quic_multiaddr("127.0.0.1", 0, "/quic")
listeners.append(listener)
async with trio.open_nursery() as nursery:
success = await listener.listen(listen_addr, nursery)
assert success
logger.info(f"Echo server {server_id} started")
# Run for a bit
await trio.sleep(0.3)
# Close this server
await listener.close()
logger.info(f"Echo server {server_id} closed")
try:
# Run multiple echo servers in parallel
async with trio.open_nursery() as nursery:
for i in range(3):
nursery.start_soon(create_echo_server, i)
# Verify all servers ran
assert len(listeners) == 3
assert len(all_echo_data) == 3
for listener in listeners:
assert not listener.is_listening() # Should all be closed
finally:
await transport.close()
@pytest.mark.trio
async def test_graceful_shutdown_sequence(self):
"""Test graceful shutdown of multiple components."""
server_key = create_new_key_pair().private_key
config = QUICTransportConfig(idle_timeout=5.0)
transport = QUICTransport(server_key, config)
shutdown_events = []
listeners = []
async def tracked_connection_handler(connection):
"""Connection handler that tracks shutdown."""
try:
while not connection.is_closed:
await trio.sleep(0.1)
finally:
shutdown_events.append(f"connection_closed_{id(connection)}")
async def create_tracked_listener(listener_id):
"""Create a listener that tracks its lifecycle."""
try:
listener = transport.create_listener(tracked_connection_handler)
listen_addr = create_quic_multiaddr("127.0.0.1", 0, "/quic")
listeners.append(listener)
async with trio.open_nursery() as nursery:
success = await listener.listen(listen_addr, nursery)
assert success
shutdown_events.append(f"listener_{listener_id}_started")
# Run for a bit
await trio.sleep(0.2)
# Graceful close
await listener.close()
shutdown_events.append(f"listener_{listener_id}_closed")
except Exception as e:
shutdown_events.append(f"listener_{listener_id}_error_{e}")
raise
try:
# Start multiple listeners
async with trio.open_nursery() as nursery:
for i in range(3):
nursery.start_soon(create_tracked_listener, i)
# Verify shutdown sequence
start_events = [e for e in shutdown_events if "started" in e]
close_events = [e for e in shutdown_events if "closed" in e]
assert len(start_events) == 3
assert len(close_events) == 3
logger.info(f"Shutdown sequence: {shutdown_events}")
finally:
shutdown_events.append("transport_closing")
await transport.close()
shutdown_events.append("transport_closed")
# HELPER FUNCTIONS FOR CLEANER TESTS
async def run_listener_for_duration(transport, handler, duration=0.5):
"""Helper to run a single listener for a specific duration."""
listener = transport.create_listener(handler)
listen_addr = create_quic_multiaddr("127.0.0.1", 0, "/quic")
async with trio.open_nursery() as nursery:
success = await listener.listen(listen_addr, nursery)
assert success
# Run for specified duration
await trio.sleep(duration)
# Clean close
await listener.close()
return listener
async def run_multiple_listeners_parallel(transport, handler, count=3, duration=0.5):
"""Helper to run multiple listeners in parallel."""
listeners = []
async def single_listener_task(listener_id):
listener = await run_listener_for_duration(transport, handler, duration)
listeners.append(listener)
logger.info(f"Listener {listener_id} completed")
async with trio.open_nursery() as nursery:
for i in range(count):
nursery.start_soon(single_listener_task, i)
return listeners
if __name__ == "__main__":
pytest.main([__file__, "-v", "-s"])

View File

@ -17,7 +17,6 @@ from libp2p.transport.quic.transport import (
)
from libp2p.transport.quic.utils import (
create_quic_multiaddr,
quic_multiaddr_to_endpoint,
)
@ -89,71 +88,51 @@ class TestQUICListener:
assert stats["active_connections"] == 0
assert stats["pending_connections"] == 0
# Close listener
await listener.close()
assert not listener.is_listening()
# Sender Cancel Signal
nursery.cancel_scope.cancel()
await listener.close()
assert not listener.is_listening()
@pytest.mark.trio
async def test_listener_double_listen(self, listener: QUICListener):
"""Test that double listen raises error."""
listen_addr = create_quic_multiaddr("127.0.0.1", 9001, "/quic")
# The nursery is the outer context
async with trio.open_nursery() as nursery:
# The try/finally is now INSIDE the nursery scope
try:
# The listen method creates the socket and starts background tasks
try:
async with trio.open_nursery() as nursery:
success = await listener.listen(listen_addr, nursery)
assert success
await trio.sleep(0.01)
addrs = listener.get_addrs()
assert len(addrs) > 0
print("ADDRS 1: ", len(addrs))
print("TEST LOGIC FINISHED")
async with trio.open_nursery() as nursery2:
with pytest.raises(QUICListenError, match="Already listening"):
await listener.listen(listen_addr, nursery2)
finally:
# This block runs BEFORE the 'async with nursery' exits.
print("INNER FINALLY: Closing listener to release socket...")
nursery2.cancel_scope.cancel()
# This closes the socket and sets self._listening = False,
# which helps the background tasks terminate cleanly.
await listener.close()
print("INNER FINALLY: Listener closed.")
# By the time we get here, the listener and its tasks have been fully
# shut down, allowing the nursery to exit without hanging.
print("TEST COMPLETED SUCCESSFULLY.")
nursery.cancel_scope.cancel()
finally:
await listener.close()
@pytest.mark.trio
async def test_listener_port_binding(self, listener: QUICListener):
"""Test listener port binding and cleanup."""
listen_addr = create_quic_multiaddr("127.0.0.1", 0, "/quic")
# The nursery is the outer context
async with trio.open_nursery() as nursery:
# The try/finally is now INSIDE the nursery scope
try:
# The listen method creates the socket and starts background tasks
try:
async with trio.open_nursery() as nursery:
success = await listener.listen(listen_addr, nursery)
assert success
await trio.sleep(0.5)
addrs = listener.get_addrs()
assert len(addrs) > 0
print("TEST LOGIC FINISHED")
finally:
# This block runs BEFORE the 'async with nursery' exits.
print("INNER FINALLY: Closing listener to release socket...")
# This closes the socket and sets self._listening = False,
# which helps the background tasks terminate cleanly.
await listener.close()
print("INNER FINALLY: Listener closed.")
nursery.cancel_scope.cancel()
finally:
await listener.close()
# By the time we get here, the listener and its tasks have been fully
# shut down, allowing the nursery to exit without hanging.

View File

@ -24,18 +24,14 @@ class TestQUICUtils:
Multiaddr(
f"/ip4/192.168.1.1/udp/8080/{QUICTransportConfig.PROTOCOL_QUIC_DRAFT29}"
),
Multiaddr(
f"/ip6/::1/udp/4001/{QUICTransportConfig.PROTOCOL_QUIC_DRAFT29}"
),
Multiaddr(f"/ip6/::1/udp/4001/{QUICTransportConfig.PROTOCOL_QUIC_DRAFT29}"),
Multiaddr(
f"/ip4/127.0.0.1/udp/4001/{QUICTransportConfig.PROTOCOL_QUIC_V1}"
),
Multiaddr(
f"/ip4/192.168.1.1/udp/8080/{QUICTransportConfig.PROTOCOL_QUIC_V1}"
),
Multiaddr(
f"/ip6/::1/udp/4001/{QUICTransportConfig.PROTOCOL_QUIC_V1}"
),
Multiaddr(f"/ip6/::1/udp/4001/{QUICTransportConfig.PROTOCOL_QUIC_V1}"),
]
for addr in valid: