diff --git a/test/helper.rb b/test/helper.rb index 97b0f64917..9aca1b06a7 100644 --- a/test/helper.rb +++ b/test/helper.rb @@ -84,16 +84,43 @@ def unused_port(num = 1, protocol:, bind: "0.0.0.0") end end -def unused_port_tcp_udp(num = 1) +# 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 +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 +192,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