Fix port-reservation race in libtest::Server::start()#472
Conversation
get_free_port() released its reservation socket immediately, before even returning the port number to the caller, so the "reservation" only protected a few lines inside get_free_port() itself. The release_port() call in Server::start() (just before fork/exec) was consequently a no-op, since the socket was already closed by then — leaving the port free for any other concurrently-running test process's get_free_port() to grab during the fork/exec/bind window. Keep the reservation socket bound until the caller explicitly releases it, and move that release in Server::start() from right before _app.run() to right after the ping loop confirms the child is listening (or has definitively failed to start). This works because both the reservation socket and gearmand's real listening socket set SO_REUSEADDR, and the reservation socket never calls listen(), so Linux allows the real bind to the exact port to succeed while still blocking other processes from picking the same port via get_free_port(). Fixes gearman#471 Signed-off-by: Alexei Pastuchov <info@maximka.de>
Review: PR #472 — Fix port-reservation race in
|
|
@esabol, unfortunately, I wan't be able to get to this until Sunday. |
That's OK. I'd like to include this in 2.0.0. I think one more week won't make a big difference. Thank you for your contributions! This all sounds very promising. It would explain the intermittent test failures we've seen for over a decade, and I hope it will eliminate the need to retry the test stage in the CI. |
release_port(_port) was only called after the ping loop succeeded or timed out, so any earlier throw (Application::run() failure, pidfile wait failure) skipped it entirely and leaked the reservation socket for the rest of the test process. Replace the single explicit call with an RAII guard so the reservation is released on every exit path. Signed-off-by: Alexei Pastuchov <info@maximka.de>
esabol
left a comment
There was a problem hiding this comment.
Solution makes sense. Looks good to me!
Since #472, get_free_port() leaves its reservation socket bound until release_port() is called, and Server::start() only releases its own _port. Several call sites reserve a port for something other than a Server's primary port and never released it, leaking the reservation socket for the rest of the test process: - Context (tests/context.h) and cycle_context_st (tests/cycle.cc) and round_robin.cc's Context overwrite their stored port in reset() without releasing the old one first, and never release the final port on destruction. - httpd_test.cc reserves an http_port passed only as --http-port= to a server whose own primary port is default_port(). - gearmand.cc's long_keepalive_start_TEST and config_file_SIMPLE_TEST reserve a port for --check-args validation runs that never bind it. - unittest.cc's get_free_port_TEST exercises get_free_port() directly without ever releasing what it reserves. default_port()'s single process-wide reservation is intentionally left untouched, since callers rely on it staying valid for the process's whole lifetime. Fixes #474 Signed-off-by: Alexei Pastuchov <info@maximka.de>
Summary
get_free_port()inlibtest/port.ccreleased its reservation socket immediately, before even returning the port number to the caller — so the "reservation" only protected a handful of lines insideget_free_port()itself, not the much longer window until the server actually binds.release_port(_port)call inServer::start()(previously right before_app.run()) was consequently a no-op, since the socket had already been closed byget_free_port()well before that point.get_free_port()to grab during the fork/exec/bind window, causing the intermittent, victim-of-the-week CI failures described in Intermittent CI test failures due to port-reservation race in libtest::Server::start() #471 (t/vector,t/protocol,t/worker, etc., all unrelated to round_robin/Intermittent t/round_robin test failure during 2.0.0 release testing #469/Fix data race in round_robin --job-retries test #470).Fix
get_free_port()no longer releases its own reservation — it stays bound (notlisten()ing) until the caller explicitly callsrelease_port().Server::start()moved therelease_port(_port)call from right before_app.run()to right after the ping loop confirms the child is listening (or has definitively failed to start), closing the real race window.This works because both the reservation socket and the real server's listening socket (e.g. gearmand, see
libgearman-server/gearmand.cc) setSO_REUSEADDR, and the reservation socket never callslisten()— so Linux allows the real bind to the exact port to succeed while the reservation is still held, while still blocking other processes'get_free_port()autoassignment from picking the same port during the vulnerable startup window.Fixes #471
Test plan
makebuilds cleant/unittestpasses (includingget_free_port()/default_port()checks)t/cyclepasses, including the explicit bind-conflict test (still correctly detects a real port conflict)t/protocol,t/round_robin,t/worker,t/vectorpass individuallyt/protocolruns, and 16 concurrent mixed runs (t/protocol/t/worker/t/round_robin/t/vector) to simulatemake check -jcontention — no failures🤖 Generated with Claude Code