3153b7e8a3
Fixes interface auto-detection failures on Pi Zero 2W where wlan0 doesn't exist at boot. Implementation: - Added detect_interface_with_retry() function with 3x retry fallback chain - Config-supplied interface preferred, then /proc/net/route, then UP interface - Exponential backoff: 5s, 10s, 20s delays between retries - Filters excluded prefixes: lo, tailscale*, wg*, tun*, docker*, veth*, br-* - Used in Engine.__init__(), PacketCapture.start(), MacManager.start() - Full test coverage: 10 tests for detection, backoff, filtering Addresses issue where network stack initializes after boot and interfaces become available after 5-10 second delay.
120 lines
5.8 KiB
Python
120 lines
5.8 KiB
Python
"""Tests for interface detection with retry fallback chain."""
|
|
|
|
import time
|
|
from pathlib import Path
|
|
from unittest.mock import patch, MagicMock
|
|
from tempfile import TemporaryDirectory
|
|
|
|
import pytest
|
|
|
|
from utils.networking import get_primary_interface, detect_interface_with_retry
|
|
|
|
|
|
class TestInterfaceDetection:
|
|
"""Test robust interface detection with retry fallback."""
|
|
|
|
def test_get_primary_interface_from_route_table(self):
|
|
"""get_primary_interface reads default route from /proc/net/route."""
|
|
route_content = """Iface Destination Gateway Flags RefCnt Use Metric Mask MTU Window IRTT
|
|
eth0 00000000 0101010A 0003 0 0 100 00000000 0 0 0
|
|
eth0 0101010A 00000000 0001 0 0 100 FFFFFFFF 0 0 0
|
|
"""
|
|
with patch("builtins.open", create=True) as mock_open:
|
|
mock_open.return_value.__enter__.return_value.readlines.return_value = route_content.split("\n")
|
|
result = get_primary_interface()
|
|
assert result == "eth0"
|
|
|
|
def test_get_primary_interface_fallback_eth(self):
|
|
"""get_primary_interface falls back to eth* interfaces if /proc/net/route is empty."""
|
|
with patch("builtins.open", side_effect=IOError):
|
|
with patch("utils.networking.get_all_interfaces", return_value=["eth0", "eth1"]):
|
|
result = get_primary_interface()
|
|
assert result == "eth0"
|
|
|
|
def test_get_primary_interface_fallback_wlan(self):
|
|
"""get_primary_interface falls back to first interface if no eth/en."""
|
|
with patch("builtins.open", side_effect=IOError):
|
|
with patch("utils.networking.get_all_interfaces", return_value=["wlan0"]):
|
|
result = get_primary_interface()
|
|
assert result == "wlan0"
|
|
|
|
def test_get_primary_interface_no_interfaces(self):
|
|
"""get_primary_interface returns None if no interfaces available."""
|
|
with patch("builtins.open", side_effect=IOError):
|
|
with patch("utils.networking.get_all_interfaces", return_value=[]):
|
|
result = get_primary_interface()
|
|
assert result is None
|
|
|
|
def test_detect_interface_with_retry_immediate_success(self):
|
|
"""detect_interface_with_retry returns interface immediately if found."""
|
|
with patch("utils.networking.get_primary_interface", return_value="eth0"):
|
|
result = detect_interface_with_retry(max_retries=3, retry_delay=1)
|
|
assert result == "eth0"
|
|
|
|
def test_detect_interface_with_retry_eventual_success(self):
|
|
"""detect_interface_with_retry retries and succeeds after N attempts."""
|
|
call_count = [0]
|
|
|
|
def get_iface_side_effect():
|
|
call_count[0] += 1
|
|
if call_count[0] <= 2:
|
|
return None # Fail first 2 times
|
|
return "wlan0" # Succeed on 3rd call
|
|
|
|
with patch("utils.networking.get_primary_interface", side_effect=get_iface_side_effect):
|
|
with patch("utils.networking._get_up_interface", return_value=None):
|
|
result = detect_interface_with_retry(max_retries=3, retry_delay=0.01)
|
|
assert result == "wlan0"
|
|
assert call_count[0] == 3
|
|
|
|
def test_detect_interface_with_retry_exhausted(self):
|
|
"""detect_interface_with_retry returns None if all retries exhausted."""
|
|
with patch("utils.networking.get_primary_interface", return_value=None):
|
|
with patch("utils.networking._get_up_interface", return_value=None):
|
|
result = detect_interface_with_retry(max_retries=2, retry_delay=0.01)
|
|
assert result is None
|
|
|
|
def test_detect_interface_with_retry_uses_config_fallback(self):
|
|
"""detect_interface_with_retry accepts config-set interface as ultimate fallback."""
|
|
with patch("utils.networking.get_primary_interface", return_value=None):
|
|
with patch("utils.networking._get_up_interface", return_value=None):
|
|
result = detect_interface_with_retry(
|
|
max_retries=1,
|
|
retry_delay=0.01,
|
|
config_interface="configured_iface"
|
|
)
|
|
# Should use config fallback when detection fails
|
|
assert result == "configured_iface"
|
|
|
|
def test_detect_interface_exponential_backoff(self):
|
|
"""detect_interface_with_retry uses exponential backoff (5s, 10s, 20s)."""
|
|
call_times = []
|
|
|
|
def get_iface_with_timing():
|
|
call_times.append(time.time())
|
|
return None
|
|
|
|
with patch("utils.networking.get_primary_interface", side_effect=get_iface_with_timing):
|
|
with patch("utils.networking._get_up_interface", return_value=None):
|
|
with patch("time.sleep") as mock_sleep:
|
|
result = detect_interface_with_retry(
|
|
max_retries=3,
|
|
retry_delay=5, # Base delay
|
|
exponential=True
|
|
)
|
|
# Should have called sleep with 5, 10, 20
|
|
assert mock_sleep.call_count == 3
|
|
# Check exponential backoff values (5 * 2^0, 5 * 2^1, 5 * 2^2)
|
|
sleep_calls = [call[0][0] for call in mock_sleep.call_args_list]
|
|
assert sleep_calls == [5, 10, 20]
|
|
|
|
def test_detect_interface_filters_excluded(self):
|
|
"""detect_interface_with_retry excludes lo, tailscale*, wg*, tun*, docker*, veth*, br-*."""
|
|
excluded_ifaces = ["lo", "tailscale0", "wg0", "tun0", "docker0", "veth123", "br-abc"]
|
|
|
|
with patch("utils.networking.get_primary_interface", return_value=None):
|
|
# Mock get_all_interfaces to return only excluded ones
|
|
with patch("utils.networking.get_all_interfaces", return_value=excluded_ifaces):
|
|
result = detect_interface_with_retry(max_retries=1, retry_delay=0.01)
|
|
assert result is None
|