import errno import socket from collections.abc import Generator from types import SimpleNamespace from unittest.mock import Mock import psutil import pytest from akkudoktoreos.server import server @pytest.fixture def port_environment(monkeypatch: pytest.MonkeyPatch) -> SimpleNamespace: """Provide socket probes, optional process diagnostics, and a deterministic clock.""" probe = Mock() probe.__enter__ = Mock(return_value=probe) probe.__exit__ = Mock(return_value=False) factory = Mock(return_value=probe) connections = Mock(side_effect=psutil.AccessDenied(1)) clock = [0.0] def sleep(seconds: float) -> None: clock[0] += seconds sleep_mock = Mock(side_effect=sleep) monkeypatch.setattr(server.socket, "socket", factory) monkeypatch.setattr(server.socket, "has_ipv6", False) monkeypatch.setattr(server.psutil, "net_if_addrs", Mock(return_value={})) monkeypatch.setattr(server.psutil, "net_connections", connections) monkeypatch.setattr(server.time, "monotonic", lambda: clock[0]) monkeypatch.setattr(server.time, "sleep", sleep_mock) return SimpleNamespace( probe=probe, factory=factory, connections=connections, clock=clock, sleep=sleep_mock ) def test_free_port_does_not_require_system_process_inspection( port_environment: SimpleNamespace, ) -> None: env = port_environment assert server.wait_for_port_free(8503) env.connections.assert_not_called() env.sleep.assert_not_called() env.probe.__exit__.assert_called_once() @pytest.mark.parametrize("error", [psutil.AccessDenied(1), PermissionError(1, "Denied")]) def test_occupied_port_remains_occupied_when_diagnostics_are_denied( port_environment: SimpleNamespace, error: Exception ) -> None: env = port_environment env.probe.bind.side_effect = OSError(errno.EADDRINUSE, "Already bound") env.connections.side_effect = error assert not server.wait_for_port_free(8503) env.connections.assert_called_once_with(kind="inet") env.sleep.assert_not_called() @pytest.mark.parametrize("timeout", [1, 5, 6]) def test_port_wait_respects_timeout_with_denied_diagnostics( port_environment: SimpleNamespace, timeout: int ) -> None: env = port_environment env.probe.bind.side_effect = OSError(errno.EADDRINUSE, "Already bound") assert not server.wait_for_port_free(8503, timeout=timeout) assert env.clock[0] == timeout assert all(call.args[0] <= 3 for call in env.sleep.call_args_list) env.connections.assert_called_once_with(kind="inet") def test_port_becomes_available_during_wait(port_environment: SimpleNamespace) -> None: env = port_environment env.probe.bind.side_effect = [OSError(errno.EADDRINUSE, "Already bound"), None] assert server.wait_for_port_free(8503, timeout=5) assert env.clock[0] == 3 env.connections.assert_not_called() @pytest.mark.parametrize("error", [psutil.AccessDenied(101), psutil.NoSuchProcess(101)]) def test_diagnostic_process_errors_do_not_change_occupied_result( port_environment: SimpleNamespace, monkeypatch: pytest.MonkeyPatch, error: Exception ) -> None: env = port_environment env.probe.bind.side_effect = OSError(errno.EADDRINUSE, "Already bound") env.connections.side_effect = None env.connections.return_value = [ SimpleNamespace(pid=101, laddr=SimpleNamespace(port=8503)), SimpleNamespace(pid=102, laddr=SimpleNamespace(port=8503)), SimpleNamespace(pid=102, laddr=SimpleNamespace(port=8503)), SimpleNamespace(pid=103, laddr=SimpleNamespace(port=9000)), SimpleNamespace(pid=None, laddr=SimpleNamespace(port=8503)), ] inaccessible = Mock() inaccessible.cmdline.side_effect = error accessible = Mock() accessible.cmdline.return_value = ["python", "-m", "akkudoktoreos.server.eos"] process = Mock(side_effect=[inaccessible, accessible]) monkeypatch.setattr(server.psutil, "Process", process) assert not server.wait_for_port_free(8503) assert [call.args[0] for call in process.call_args_list] == [101, 102] accessible.cmdline.assert_called_once_with() def test_port_with_unknown_owner_is_still_occupied(port_environment: SimpleNamespace) -> None: env = port_environment env.probe.bind.side_effect = OSError(errno.EADDRINUSE, "Already bound") env.connections.side_effect = None env.connections.return_value = [SimpleNamespace(pid=None, laddr=SimpleNamespace(port=8503))] assert not server.wait_for_port_free(8503) def test_ipv6_listener_is_detected_after_free_ipv4_probe( port_environment: SimpleNamespace, monkeypatch: pytest.MonkeyPatch ) -> None: env = port_environment monkeypatch.setattr(server.socket, "has_ipv6", True) env.probe.bind.side_effect = [None, OSError(errno.EADDRINUSE, "IPv6 listener")] assert not server.wait_for_port_free(8503) assert [call.args[0] for call in env.factory.call_args_list] == [ socket.AF_INET, socket.AF_INET6, ] @pytest.mark.parametrize("scope", ["eth0", "3"]) @pytest.mark.parametrize("occupied", [False, True]) def test_scoped_ipv6_port_probe_preserves_interface_id( port_environment: SimpleNamespace, monkeypatch: pytest.MonkeyPatch, scope: str, occupied: bool, ) -> None: """Linux requires an explicit scope ID when binding link-local IPv6 addresses.""" env = port_environment address = f"fe80::1%{scope}" scoped_sockaddr = ("fe80::1", 8503, 0, 3) monkeypatch.setattr(server.socket, "has_ipv6", True) monkeypatch.setattr( server.psutil, "net_if_addrs", Mock(return_value={"eth0": [SimpleNamespace(family=socket.AF_INET6, address=address)]}), ) resolver = Mock( side_effect=[ [(socket.AF_INET6, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", ("::", 8503, 0, 0))], [(socket.AF_INET6, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", scoped_sockaddr)], ] ) monkeypatch.setattr(server.socket, "getaddrinfo", resolver) def bind(sockaddr: tuple[str, int] | tuple[str, int, int, int]) -> None: if sockaddr[0].startswith("fe80:"): if len(sockaddr) != 4 or sockaddr[3] != 3: raise OSError(errno.EINVAL, "Missing IPv6 scope ID") if occupied: raise OSError(errno.EADDRINUSE, "IPv6 listener") env.probe.bind.side_effect = bind assert server.wait_for_port_free(8503) is not occupied env.probe.bind.assert_any_call(scoped_sockaddr) resolver.assert_called_with( address, 8503, socket.AF_INET6, socket.SOCK_STREAM, 0, socket.AI_NUMERICHOST ) @pytest.mark.parametrize( "error_number", [errno.EAFNOSUPPORT, errno.EPROTONOSUPPORT, errno.EADDRNOTAVAIL] ) def test_disabled_ipv6_does_not_block_free_ipv4_port( port_environment: SimpleNamespace, monkeypatch: pytest.MonkeyPatch, error_number: int ) -> None: env = port_environment monkeypatch.setattr(server.socket, "has_ipv6", True) env.factory.side_effect = [env.probe, OSError(error_number, "IPv6 unavailable")] assert server.wait_for_port_free(8503) def test_socket_resource_error_is_not_reported_as_free(port_environment: SimpleNamespace) -> None: port_environment.factory.side_effect = OSError(errno.EMFILE, "No file descriptors") with pytest.raises(OSError) as error: server.wait_for_port_free(8503) assert error.value.errno == errno.EMFILE def test_bind_permission_denied_is_not_reported_as_free(port_environment: SimpleNamespace) -> None: port_environment.probe.bind.side_effect = OSError(errno.EACCES, "Cannot bind") assert not server.wait_for_port_free(8503) @pytest.mark.parametrize("port,timeout", [(-1, 0), (65536, 0), (8503, -1)]) def test_port_wait_validates_arguments( port_environment: SimpleNamespace, port: int, timeout: int ) -> None: with pytest.raises(ValueError): server.wait_for_port_free(port, timeout=timeout) port_environment.factory.assert_not_called() @pytest.fixture(params=[socket.AF_INET, socket.AF_INET6]) def listener(request: pytest.FixtureRequest) -> Generator[socket.socket, None, None]: """Use real sockets to verify both protocol families as an ordinary user.""" family = request.param try: sock = socket.socket(family, socket.SOCK_STREAM) except OSError as error: if family == socket.AF_INET6 and error.errno in (errno.EAFNOSUPPORT, errno.EPROTONOSUPPORT): pytest.skip("IPv6 unavailable") raise with sock: if family == socket.AF_INET6: sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) try: sock.bind(("::1" if family == socket.AF_INET6 else "127.0.0.1", 0)) except OSError as error: if family == socket.AF_INET6 and error.errno == errno.EADDRNOTAVAIL: pytest.skip("IPv6 loopback unavailable") raise sock.listen() yield sock def test_real_port_availability_with_denied_enumeration( listener: socket.socket, monkeypatch: pytest.MonkeyPatch ) -> None: port = listener.getsockname()[1] monkeypatch.setattr(server.psutil, "net_connections", Mock(side_effect=psutil.AccessDenied(1))) assert not server.wait_for_port_free(port) # The probe must leave the existing listener open and untouched. assert listener.getsockname()[1] == port listener.close() assert server.wait_for_port_free(port) def test_closed_tcp_connection_does_not_delay_port_reuse( listener: socket.socket, monkeypatch: pytest.MonkeyPatch ) -> None: """A server can restart while its previous connections remain in TIME_WAIT.""" if server.os.name == "nt": pytest.skip("Windows uses exclusive server sockets") port = listener.getsockname()[1] monkeypatch.setattr(server.psutil, "net_connections", Mock(side_effect=psutil.AccessDenied(1))) with socket.socket(listener.family, socket.SOCK_STREAM) as client: client.connect(listener.getsockname()) accepted, _ = listener.accept() accepted.close() assert client.recv(1) == b"" listener.close() assert server.wait_for_port_free(port) def test_missing_interface_details_use_exclusive_probe( port_environment: SimpleNamespace, monkeypatch: pytest.MonkeyPatch ) -> None: env = port_environment monkeypatch.setattr(server.psutil, "net_if_addrs", Mock(side_effect=psutil.AccessDenied())) env.probe.bind.side_effect = OSError(errno.EADDRINUSE, "Already bound") assert not server.wait_for_port_free(8503) assert all(call.args[1] != socket.SO_REUSEADDR for call in env.probe.setsockopt.call_args_list)