gns3-server/tests/stress/telnet_race_condition_test.py
YueGuobin e4faf673af fix(telnet): handle connection race condition during broadcast
Fixes two critical issues in telnet server when clients rapidly
   connect/disconnect during broadcast operations:

   1. **OSError [Errno 107]**: Transport endpoint not connected
      - Root cause: getpeername() called outside try block
      - Fix: Move getpeername() inside try block and catch OSError
      - Expand exception handling to include OSError and TimeoutError

   2. **KeyError**: Double deletion from connections dictionary
      - Root cause: Connection deleted in broadcast loop, then deleted
        again in top-level exception handler
      - Fix: Use dict.pop(key, None) instead of del dict[key]

   **Changes**:
   - Line 216: Add OSError to top-level exception handler
   - Line 227-228: Use pop() to avoid KeyError on double deletion
   - Line 305-316: Move getpeername() inside try block, expand
     exception types, use pop() for safe deletion

   **Impact**:
   - Prevents unhandled exceptions from propagating to asyncio event loop
   - Ensures proper resource cleanup even with race conditions
   - No resource leaks or zombie connections
   - Long-lived clients unaffected by rapid client disconnects

   **Test Script**:
   - Added stress test script to reproduce the issue
   - Supports IOU-L3, VPCS, and generic device types
   - Rapid clients send broadcast-triggering commands (show/run/OSPF)

   **Related**: Fixes bug documented in docs/bugs/telnet-server-connection-race-condition.md
2026-03-15 00:19:51 +08:00

475 lines
15 KiB
Python

#!/usr/bin/env python3
"""
Telnet Server Race Condition Stress Test
This script reproduces the OSError: [Errno 107] bug by rapidly
connecting and disconnecting clients to trigger the race condition
during broadcast operations.
Usage:
python telnet_race_condition_test.py --host 127.0.0.1 --port 2000 --connections 10
"""
import asyncio
import argparse
import logging
import time
from typing import List, Optional
import sys
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
log = logging.getLogger(__name__)
class TelnetClient:
"""A simple telnet client for stress testing."""
def __init__(self, client_id: int, host: str, port: int):
self.client_id = client_id
self.host = host
self.port = port
self.reader: Optional[asyncio.StreamReader] = None
self.writer: Optional[asyncio.StreamWriter] = None
self.connected = False
async def connect(self) -> bool:
"""Establish telnet connection."""
try:
log.debug(f"Client {self.client_id}: Connecting to {self.host}:{self.port}...")
self.reader, self.writer = await asyncio.wait_for(
asyncio.open_connection(self.host, self.port),
timeout=5.0
)
self.connected = True
log.debug(f"Client {self.client_id}: Connected successfully")
return True
except Exception as e:
log.warning(f"Client {self.client_id}: Connection failed: {e}")
return False
async def send_command(self, command: str) -> bool:
"""Send a command to the telnet server."""
if not self.writer or not self.connected:
return False
try:
self.writer.write(command.encode() + b'\r\n')
await asyncio.wait_for(self.writer.drain(), timeout=2.0)
log.debug(f"Client {self.client_id}: Sent command: {command.strip()}")
return True
except Exception as e:
log.warning(f"Client {self.client_id}: Send failed: {e}")
return False
async def receive_response(self, timeout: float = 1.0) -> Optional[str]:
"""Receive response from server (optional)."""
if not self.reader or not self.connected:
return None
try:
data = await asyncio.wait_for(self.reader.read(1024), timeout=timeout)
if data:
response = data.decode('utf-8', errors='ignore')
log.debug(f"Client {self.client_id}: Received: {response[:50]}...")
return response
except asyncio.TimeoutError:
log.debug(f"Client {self.client_id}: No response (timeout)")
except Exception as e:
log.debug(f"Client {self.client_id}: Receive error: {e}")
return None
async def disconnect(self, immediate: bool = False):
"""
Disconnect from telnet server.
Args:
immediate: If True, close immediately without graceful shutdown.
This simulates abrupt disconnection (TCP FIN/RST).
"""
if not self.writer:
return
try:
if immediate:
# Abrupt close - doesn't wait for flush
self.writer.close()
# Don't wait for close to complete
log.debug(f"Client {self.client_id}: Abruptly disconnected")
else:
# Graceful close
self.writer.close()
await asyncio.wait_for(self.writer.wait_closed(), timeout=1.0)
log.debug(f"Client {self.client_id}: Gracefully disconnected")
except Exception as e:
log.debug(f"Client {self.client_id}: Disconnect error: {e}")
finally:
self.connected = False
self.writer = None
self.reader = None
async def rapid_fire_client(
client_id: int,
host: str,
port: int,
iterations: int,
min_delay: float = 0.001,
max_delay: float = 0.01,
receive_before_disconnect: bool = False,
immediate_disconnect: bool = True,
device_type: str = "iou-l3"
):
"""
A client that rapidly connects, sends commands, and disconnects.
This is designed to trigger the race condition by disconnecting
quickly while the server might be broadcasting data.
Args:
client_id: Unique identifier for this client
host: Telnet server host
port: Telnet server port
iterations: Number of connect/disconnect cycles
min_delay: Minimum delay before disconnect (seconds)
max_delay: Maximum delay before disconnect (seconds)
receive_before_disconnect: Whether to wait for response before disconnect
immediate_disconnect: Use abrupt close instead of graceful close
device_type: Type of device (iou-l3, vpcs, etc.)
"""
success_count = 0
fail_count = 0
# Cisco IOS commands for IOU-L3 that trigger broadcast output
ios_commands = [
"show ip interface brief",
"show ip route",
"show running-config",
"show version",
"show protocols",
"debug ip ospf events",
"undebug ip ospf events",
"clear ip ospf process",
]
# Commands that generate significant output
broadcast_trigger_commands = [
"show running-config",
"show ip ospf neighbor",
"show ip ospf database",
"show ip protocols",
"show ip route",
"write memory", # This generates "Building configuration..." output
]
for i in range(iterations):
client = TelnetClient(client_id, host, port)
# Connect
if not await client.connect():
fail_count += 1
await asyncio.sleep(0.1)
continue
# Wait a bit for the telnet session to stabilize
await asyncio.sleep(0.05)
# Send commands based on device type
if device_type == "iou-l3":
# Use IOS commands that trigger broadcasts
cmd_index = i % len(broadcast_trigger_commands)
cmd = broadcast_trigger_commands[cmd_index]
# Send the command
await client.send_command(cmd)
# Small delay to let server start broadcasting
await asyncio.sleep(0.01)
# Send another command quickly to increase broadcast chance
await client.send_command("show ip ospf")
# Very short delay to increase race condition likelihood
delay = min_delay + (max_delay - min_delay) * (i % 10) / 10.0
await asyncio.sleep(delay)
else:
# Generic test commands for other devices
test_commands = ["?", "version", "list"]
for cmd in test_commands:
await client.send_command(cmd)
if receive_before_disconnect:
await client.receive_response(timeout=0.1)
delay = min_delay + (max_delay - min_delay) * (i % 10) / 10.0
await asyncio.sleep(delay)
# Disconnect - this is where the race condition triggers
await client.disconnect(immediate=immediate_disconnect)
success_count += 1
# Small delay between iterations
await asyncio.sleep(0.05)
log.info(f"Client {client_id}: Completed {success_count}/{iterations} cycles ({fail_count} failures)")
return success_count, fail_count
async def long_lived_client(
client_id: int,
host: str,
port: str,
duration: float,
send_interval: float = 1.0
):
"""
A long-lived client that stays connected and periodically sends commands.
This simulates a web console user and should NOT experience issues
when other clients disconnect rapidly.
Args:
client_id: Unique identifier
host: Telnet server host
port: Telnet server port
duration: How long to stay connected (seconds)
send_interval: Interval between commands (seconds)
"""
client = TelnetClient(client_id, host, port)
if not await client.connect():
log.error(f"Long-lived client {client_id}: Failed to connect")
return
log.info(f"Long-lived client {client_id}: Connected for {duration}s")
start_time = time.time()
commands_sent = 0
while time.time() - start_time < duration:
await asyncio.sleep(send_interval)
# Send periodic commands to keep connection active
test_commands = ["?", "help", "status"]
cmd = test_commands[commands_sent % len(test_commands)]
if await client.send_command(cmd):
commands_sent += 1
await client.receive_response(timeout=0.5)
await client.disconnect(immediate=False)
log.info(f"Long-lived client {client_id}: Sent {commands_sent} commands over {duration}s")
async def run_stress_test(
host: str,
port: int,
rapid_clients: int,
long_lived_clients: int,
iterations_per_client: int,
test_duration: float,
device_type: str = "iou-l3"
):
"""
Run the stress test with multiple concurrent clients.
This creates:
1. Rapid-fire clients that connect/disconnect quickly (triggers bug)
2. Long-lived clients that stay connected (should not be affected)
Args:
host: Telnet server host
port: Telnet server port
rapid_clients: Number of rapid connect/disconnect clients
long_lived_clients: Number of long-lived clients
iterations_per_client: Iterations per rapid client
test_duration: Test duration in seconds
device_type: Type of device (iou-l3, vpcs, etc.)
"""
log.info("=" * 70)
log.info("Telnet Server Race Condition Stress Test")
log.info("=" * 70)
log.info(f"Target: {host}:{port}")
log.info(f"Device Type: {device_type}")
log.info(f"Rapid clients: {rapid_clients} (each {iterations_per_client} iterations)")
log.info(f"Long-lived clients: {long_lived_clients} (duration: {test_duration}s)")
log.info(f"Expected behavior: Rapid clients disconnect, long-lived clients unaffected")
log.info("=" * 70)
tasks: List[asyncio.Task] = []
# Start long-lived clients first (simulate web console users)
for i in range(long_lived_clients):
task = asyncio.create_task(
long_lived_client(
client_id=1000 + i,
host=host,
port=port,
duration=test_duration,
send_interval=2.0
)
)
tasks.append(task)
await asyncio.sleep(0.1) # Stagger connections
# Give long-lived clients time to establish
await asyncio.sleep(1.0)
# Start rapid-fire clients (simulate automated scripts)
log.info("Starting rapid-fire clients...")
rapid_tasks: List[asyncio.Task] = []
for i in range(rapid_clients):
task = asyncio.create_task(
rapid_fire_client(
client_id=i,
host=host,
port=port,
iterations=iterations_per_client,
min_delay=0.001, # 1ms - very fast
max_delay=0.01, # 10ms - still fast
receive_before_disconnect=False, # Don't wait for response
immediate_disconnect=True, # Abrupt close
device_type=device_type
)
)
rapid_tasks.append(task)
await asyncio.sleep(0.05) # Slightly stagger starts
# Wait for all rapid clients to complete
log.info("Waiting for rapid-fire clients to complete...")
rapid_results = await asyncio.gather(*rapid_tasks, return_exceptions=True)
# Calculate statistics
total_success = 0
total_failures = 0
for result in rapid_results:
if isinstance(result, Exception):
log.error(f"Rapid client failed with exception: {result}")
elif isinstance(result, tuple):
success, failures = result
total_success += success
total_failures += failures
log.info(f"Rapid clients completed: {total_success} success, {total_failures} failures")
# Wait for long-lived clients to finish
log.info("Waiting for long-lived clients to complete...")
await asyncio.gather(*tasks)
log.info("=" * 70)
log.info("Stress test completed!")
log.info("=" * 70)
log.info("Check GNS3 server logs for:")
log.info(" - ❌ 'OSError: [Errno 107] Transport endpoint is not connected'")
log.info(" - ✅ 'Error sending data to client None: ...' (properly handled)")
log.info("=" * 70)
def main():
parser = argparse.ArgumentParser(
description="Stress test for telnet server race condition",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Basic test with IOU-L3 device (default)
python telnet_race_condition_test.py --port 2000
# Test with IOU-L3 using OSPF/show commands (triggers broadcast)
python telnet_race_condition_test.py --port 2000 --device-type iou-l3 --rapid-clients 20
# Heavy load with many clients
python telnet_race_condition_test.py --port 2000 --rapid-clients 50 --iterations 100
# Test with VPCS device
python telnet_race_condition_test.py --port 2000 --device-type vpcs
Device Types:
iou-l3 - Cisco IOS L3 router (uses show/run/write commands that trigger broadcast)
vpcs - VPCS simulator (simple commands)
generic - Generic device (basic test commands)
"""
)
parser.add_argument(
'--host',
default='127.0.0.1',
help='Telnet server host (default: 127.0.0.1)'
)
parser.add_argument(
'--port',
type=int,
default=2000,
help='Telnet server port (default: 2000)'
)
parser.add_argument(
'--rapid-clients',
type=int,
default=10,
help='Number of rapid connect/disconnect clients (default: 10)'
)
parser.add_argument(
'--long-lived',
type=int,
default=2,
help='Number of long-lived clients (default: 2)'
)
parser.add_argument(
'--iterations',
type=int,
default=50,
help='Iterations per rapid client (default: 50)'
)
parser.add_argument(
'--duration',
type=float,
default=30.0,
help='Test duration in seconds (default: 30.0)'
)
parser.add_argument(
'--device-type',
default='iou-l3',
choices=['iou-l3', 'vpcs', 'generic'],
help='Device type for commands (default: iou-l3)'
)
parser.add_argument(
'--verbose',
action='store_true',
help='Enable verbose logging'
)
args = parser.parse_args()
if args.verbose:
logging.getLogger().setLevel(logging.DEBUG)
try:
asyncio.run(run_stress_test(
host=args.host,
port=args.port,
rapid_clients=args.rapid_clients,
long_lived_clients=args.long_lived,
iterations_per_client=args.iterations,
test_duration=args.duration,
device_type=args.device_type
))
except KeyboardInterrupt:
log.info("Test interrupted by user")
sys.exit(0)
if __name__ == '__main__':
main()