diff --git a/python/sglang/srt/utils/common.py b/python/sglang/srt/utils/common.py index 6136442fc..0206a700c 100644 --- a/python/sglang/srt/utils/common.py +++ b/python/sglang/srt/utils/common.py @@ -765,8 +765,16 @@ def _get_addrinfos_for_bind(host=None, port=0): 0, socket.AI_ADDRCONFIG | socket.AI_PASSIVE, ) - seen = set() - return [i for i in infos if i[0] not in seen and not seen.add(i[0])] + deduped = [] + seen_families = set() + for info in infos: + if info[0] not in seen_families: + seen_families.add(info[0]) + deduped.append(info) + # Prefer IPv4 so that callers without an explicit host get consistent + # behaviour across platforms (some OSes list IPv6 first). + deduped.sort(key=lambda x: (x[0] != socket.AF_INET,)) + return deduped except socket.gaierror: fallback_host = "0.0.0.0" if host is None else host return [(socket.AF_INET, socket.SOCK_STREAM, 0, "", (fallback_host, port))] @@ -794,22 +802,31 @@ def try_bind_socket(host=None, port=0, *, reuse_addr=True, listen=False): for family, socktype, proto, _, sockaddr in _get_addrinfos_for_bind(host, port): sock = socket.socket(family, socktype, proto) try: + if family == socket.AF_INET6: + sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1) if reuse_addr: sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.bind(sockaddr) if listen: sock.listen(1) return sock - except OSError: + except (OSError, OverflowError): sock.close() raise OSError(f"Could not bind port {port} on any configured address family") def is_port_available(port): - """Return whether a port is available.""" + """Return whether a port is available on all configured address families.""" try: - sock = try_bind_socket(port=port, listen=True) - sock.close() + for family, socktype, proto, _, sockaddr in _get_addrinfos_for_bind(port=port): + sock = socket.socket(family, socktype, proto) + try: + if family == socket.AF_INET6: + sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind(sockaddr) + finally: + sock.close() return True except (OSError, OverflowError): return False @@ -2695,13 +2712,10 @@ def get_open_port() -> int: if port is not None: port = int(port) while True: - try: - sock = try_bind_socket(port=port, reuse_addr=False) - sock.close() + if is_port_available(port): return port - except OSError: - logger.info("Port %d is already in use, trying port %d", port, port + 1) - port += 1 + logger.info("Port %d is already in use, trying port %d", port, port + 1) + port += 1 sock = try_bind_socket() port = sock.getsockname()[1] sock.close() diff --git a/test/registered/utils/test_socket_utils.py b/test/registered/utils/test_socket_utils.py index 62aa0f603..ae756b4c3 100644 --- a/test/registered/utils/test_socket_utils.py +++ b/test/registered/utils/test_socket_utils.py @@ -13,6 +13,7 @@ from sglang.srt.utils.common import ( ) from sglang.test.ci.ci_register import register_cpu_ci from sglang.test.test_utils import CustomTestCase +from sglang.utils import normalize_base_url, release_port, reserve_port register_cpu_ci(est_time=1, suite="stage-a-cpu-only") @@ -58,11 +59,11 @@ class TestTryBindSocket(CustomTestCase): def test_bind_occupied_port_raises(self): """try_bind_socket should raise OSError if port is occupied.""" - sock1 = try_bind_socket() + sock1 = try_bind_socket(host="127.0.0.1", reuse_addr=False) try: port = sock1.getsockname()[1] with self.assertRaises(OSError): - try_bind_socket(port=port, reuse_addr=False) + try_bind_socket(host="127.0.0.1", port=port, reuse_addr=False) finally: sock1.close() @@ -104,7 +105,7 @@ class TestSocketUtilities(CustomTestCase): def test_is_port_available_occupied(self): """is_port_available should return False for an occupied port.""" - sock = bind_port(get_free_port()) + sock = try_bind_socket(port=0, reuse_addr=False, listen=True) try: port = sock.getsockname()[1] self.assertFalse(is_port_available(port)) @@ -141,7 +142,7 @@ class TestSocketUtilities(CustomTestCase): def test_get_open_port_env_var_occupied_increments(self): """get_open_port should increment if SGLANG_PORT is occupied.""" - sock = bind_port(get_free_port()) + sock = try_bind_socket(port=0, reuse_addr=False, listen=True) try: occupied_port = sock.getsockname()[1] with patch.dict(os.environ, {"SGLANG_PORT": str(occupied_port)}): @@ -152,5 +153,72 @@ class TestSocketUtilities(CustomTestCase): sock.close() +class TestReservePort(CustomTestCase): + def test_reserve_port_returns_port_and_socket(self): + """reserve_port should return a (port, socket) tuple.""" + port, sock = reserve_port("127.0.0.1") + try: + self.assertGreaterEqual(port, 30000) + self.assertLess(port, 40000) + self.assertEqual(sock.getsockname()[1], port) + finally: + release_port(sock) + + def test_reserve_port_custom_range(self): + """reserve_port should respect custom start/end range.""" + port, sock = reserve_port("127.0.0.1", start=40000, end=41000) + try: + self.assertGreaterEqual(port, 40000) + self.assertLess(port, 41000) + finally: + release_port(sock) + + def test_reserve_port_holds_port(self): + """The reserved port should not be available until released.""" + port, sock = reserve_port("127.0.0.1") + try: + # Verify port is held by trying to bind the same family explicitly + with self.assertRaises(OSError): + s = try_bind_socket(host="127.0.0.1", port=port, reuse_addr=False) + s.close() + finally: + release_port(sock) + + def test_reserve_port_no_free_port_raises(self): + """reserve_port should raise RuntimeError if no port is available.""" + with patch( + "sglang.srt.utils.common.try_bind_socket", + side_effect=OSError("mocked"), + ): + with self.assertRaises(RuntimeError): + reserve_port("127.0.0.1", start=50000, end=50002) + + +class TestNormalizeBaseUrl(CustomTestCase): + def test_ipv4_host(self): + """normalize_base_url should produce http://host:port for IPv4.""" + url = normalize_base_url("127.0.0.1", 8080) + self.assertEqual(url, "http://127.0.0.1:8080") + + def test_ipv6_host(self): + """normalize_base_url should bracket IPv6 addresses.""" + url = normalize_base_url("::1", 8080) + self.assertEqual(url, "http://[::1]:8080") + + def test_hostname(self): + """normalize_base_url should work with hostnames.""" + url = normalize_base_url("localhost", 3000) + self.assertEqual(url, "http://localhost:3000") + + def test_deprecated_scheme_passthrough(self): + """normalize_base_url should pass through host with scheme (deprecated).""" + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + url = normalize_base_url("http://myhost", 9000) + self.assertEqual(url, "http://myhost:9000") + + if __name__ == "__main__": unittest.main()