From ea2be42ba63e5c5071eb56e85877aaa3c9da0a0f Mon Sep 17 00:00:00 2001 From: Shizuo Fujita Date: Sun, 12 Jul 2026 19:02:52 +0900 Subject: [PATCH 1/3] test: rework unused_port_tcp_udp to try random candidate ports unused_port_tcp_udp() collected 200 TCP ephemeral ports at once and returned the first one that could also be bound for UDP. On Windows, Hyper-V/WinNAT/HNS reserve "excluded port ranges" inside the ephemeral port range (49152-65535), the reservations are managed separately for TCP and UDP, and binding a port inside such a range fails with EACCES instead of EADDRINUSE. The TCP ephemeral allocator assigns ports sequentially and knows nothing about UDP exclusions, so the 200 candidates form a contiguous block that can land entirely inside a UDP-only excluded range. When that happens, every candidate fails the UDP bind check and test setup fails with: RuntimeError: can't find unused port test/helper.rb:94:in `unused_port_tcp_udp' test/helper.rb:81:in `unused_port' test/plugin/test_in_forward.rb:22:in `setup' This was captured on a windows-2025 GitHub Actions runner whose UDP exclusion ranges (netsh interface ipv4 show excludedportrange protocol=udp) were 54315-54414 and 54415-54514: exactly 200 consecutive ports, while the TCP exclusions were elsewhere (49691-49890). Instead, draw random candidate ports from a fixed high range and verify that each can be bound for both TCP and UDP, trying another candidate on failure. Because the candidates are independent draws, reserved or in-use ports only matter through their overall fraction of the range: contiguous reservation blocks cannot defeat the search, and the chance of exhausting the retry limit is negligible. The range is kept wide (10001 ports) so that reservation blocks, which come in units of about 100-200 ports, can only ever cover a small fraction of it. This also removes the need to hold 200 sockets at once, which the old implementation capped because of file descriptor limits, and the checks now bind both protocols explicitly on the same IPv4 wildcard address. Only the protocol: :all path changes; unused_port_tcp() and unused_port_udp() used by the :tcp/:tls/:udp paths are unchanged. Signed-off-by: Shizuo Fujita --- test/helper.rb | 38 +++++++++++--- test/test_unused_port.rb | 104 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 8 deletions(-) create mode 100644 test/test_unused_port.rb diff --git a/test/helper.rb b/test/helper.rb index 97b0f64917..35844862ce 100644 --- a/test/helper.rb +++ b/test/helper.rb @@ -84,16 +84,38 @@ def unused_port(num = 1, protocol:, bind: "0.0.0.0") end end -def unused_port_tcp_udp(num = 1) +# Excluded port ranges reserved by Hyper-V/WinNAT/HNS on Windows come in +# blocks of about 100-200 ports and can accumulate, so the range must be +# wide enough that reservations can only ever cover a small fraction of it. +PORT_RANGE_TCP_UDP = (55000..65000) + +def unused_port_tcp_udp(num = 1, retries: 1000) raise "not support num > 1" if num > 1 - # The default maximum number of file descriptors in macOS is 256. - # It might need to set num to a smaller value than that. - tcp_ports = unused_port_tcp(200) - port = unused_port_udp(1, port_list: tcp_ports) - raise "can't find unused port" unless port + # Try random candidate ports until one can be bound for both TCP and UDP. + retries.times do + port = rand(PORT_RANGE_TCP_UDP) + return port if port_bindable_udp?(port) && port_bindable_tcp?(port) + end + + raise "can't find unused port" +end - port +def port_bindable_udp?(port) + u = UDPSocket.new(::Socket::AF_INET) + u.bind("0.0.0.0", port) + true +rescue SystemCallError + false +ensure + u.close +end + +def port_bindable_tcp?(port) + TCPServer.open("0.0.0.0", port).close + true +rescue SystemCallError + false end def unused_port_tcp(num = 1) @@ -165,7 +187,7 @@ def ipv6_enabled? sock = Socket.new(Socket::AF_INET6, Socket::SOCK_STREAM, 0) sock.bind(Socket.sockaddr_in(0, '::1')) sock.close - + # Also test that we can resolve IPv6 addresses # This is needed because some systems can bind but can't connect Socket.getaddrinfo('::1', nil, Socket::AF_INET6) diff --git a/test/test_unused_port.rb b/test/test_unused_port.rb new file mode 100644 index 0000000000..3c1d658d81 --- /dev/null +++ b/test/test_unused_port.rb @@ -0,0 +1,104 @@ +require_relative 'helper' + +class UnusedPortTest < Test::Unit::TestCase + test "unused_port(protocol: :all) returns a port bindable for both TCP and UDP" do + port = unused_port(protocol: :all) + assert_kind_of(Integer, port) + assert_include(PORT_RANGE_TCP_UDP, port) + tcp = TCPServer.open("0.0.0.0", port) + tcp.close + udp = UDPSocket.new(::Socket::AF_INET) + udp.bind("0.0.0.0", port) + udp.close + end + + test "unused_port_tcp_udp does not support num > 1" do + assert_raise(RuntimeError.new("not support num > 1")) do + unused_port_tcp_udp(2) + end + end + + sub_test_case "port_bindable_tcp?" do + test "returns false while the port is held and true after release" do + held = TCPServer.open("0.0.0.0", 0) + port = held.addr[1] + assert_false(port_bindable_tcp?(port)) + held.close + assert_true(port_bindable_tcp?(port)) + end + end + + sub_test_case "port_bindable_udp?" do + test "returns false while the port is held and true after release" do + held = UDPSocket.new(::Socket::AF_INET) + held.bind("0.0.0.0", 0) + port = held.addr[1] + assert_false(port_bindable_udp?(port)) + held.close + assert_true(port_bindable_udp?(port)) + end + end + + sub_test_case "when candidate ports fail the checks" do + teardown do + [:port_bindable_udp?, :port_bindable_tcp?].each do |name| + if singleton_class.method_defined?(name, false) + singleton_class.send(:remove_method, name) + end + end + end + + test "tries another candidate and succeeds" do + calls = 0 + candidates = [] + define_singleton_method(:port_bindable_udp?) { |_port| true } + define_singleton_method(:port_bindable_tcp?) do |port| + calls += 1 + candidates << port + calls >= 3 + end + + port = unused_port_tcp_udp + assert_equal(3, calls) + assert_equal(candidates.last, port) + candidates.each do |candidate| + assert_include(PORT_RANGE_TCP_UDP, candidate) + end + end + + test "raises after exhausting retries when the TCP side never succeeds" do + calls = 0 + define_singleton_method(:port_bindable_udp?) { |_port| true } + define_singleton_method(:port_bindable_tcp?) do |_port| + calls += 1 + false + end + + error = assert_raise(RuntimeError) do + unused_port_tcp_udp(retries: 3) + end + assert_equal("can't find unused port", error.message) + assert_equal(3, calls) + end + + test "raises after exhausting retries when the UDP side never succeeds" do + udp_calls = 0 + tcp_calls = 0 + define_singleton_method(:port_bindable_udp?) do |_port| + udp_calls += 1 + false + end + define_singleton_method(:port_bindable_tcp?) do |_port| + tcp_calls += 1 + true + end + + error = assert_raise(RuntimeError) do + unused_port_tcp_udp(retries: 3) + end + assert_equal("can't find unused port", error.message) + assert_equal(3, udp_calls) + assert_equal(0, tcp_calls) + end + end +end From f3e9e162f717e5b2693df3f43714e542bb419323 Mon Sep 17 00:00:00 2001 From: Shizuo Fujita Date: Mon, 13 Jul 2026 11:47:41 +0900 Subject: [PATCH 2/3] Update comments Co-authored-by: Kentaro Hayashi Signed-off-by: Shizuo Fujita --- test/helper.rb | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/test/helper.rb b/test/helper.rb index 35844862ce..3a49fcc708 100644 --- a/test/helper.rb +++ b/test/helper.rb @@ -88,7 +88,14 @@ def unused_port(num = 1, protocol:, bind: "0.0.0.0") # blocks of about 100-200 ports and can accumulate, so the range must be # wide enough that reservations can only ever cover a small fraction of it. PORT_RANGE_TCP_UDP = (55000..65000) - +# Windows components such as Hyper-V, WinNAT, and HNS may reserve dynamic +# excluded port ranges. These exclusions are commonly observed in blocks of +# roughly 100 ports and multiple ranges may coexist, so use a sufficiently +# wide candidate range to reduce the probability that all candidates are +# excluded. +# About dynamic excluded port ranges, see: +# > netsh interface ipv4 show excludedportrange protocol=tcp +# > netsh interface ipv4 show excludedportrange protocol=ucp def unused_port_tcp_udp(num = 1, retries: 1000) raise "not support num > 1" if num > 1 From 2baf0f3e1add4e91244f44eff478bbe4c8260e4e Mon Sep 17 00:00:00 2001 From: Shizuo Fujita Date: Mon, 13 Jul 2026 11:51:21 +0900 Subject: [PATCH 3/3] Remove duplicated comments Signed-off-by: Shizuo Fujita --- test/helper.rb | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/test/helper.rb b/test/helper.rb index 3a49fcc708..9aca1b06a7 100644 --- a/test/helper.rb +++ b/test/helper.rb @@ -84,10 +84,6 @@ def unused_port(num = 1, protocol:, bind: "0.0.0.0") end end -# Excluded port ranges reserved by Hyper-V/WinNAT/HNS on Windows come in -# blocks of about 100-200 ports and can accumulate, so the range must be -# wide enough that reservations can only ever cover a small fraction of it. -PORT_RANGE_TCP_UDP = (55000..65000) # Windows components such as Hyper-V, WinNAT, and HNS may reserve dynamic # excluded port ranges. These exclusions are commonly observed in blocks of # roughly 100 ports and multiple ranges may coexist, so use a sufficiently @@ -96,6 +92,8 @@ def unused_port(num = 1, protocol:, bind: "0.0.0.0") # About dynamic excluded port ranges, see: # > netsh interface ipv4 show excludedportrange protocol=tcp # > netsh interface ipv4 show excludedportrange protocol=ucp +PORT_RANGE_TCP_UDP = (55000..65000) + def unused_port_tcp_udp(num = 1, retries: 1000) raise "not support num > 1" if num > 1