Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 35 additions & 8 deletions test/helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
104 changes: 104 additions & 0 deletions test/test_unused_port.rb
Original file line number Diff line number Diff line change
@@ -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
Loading