diff --git a/scripts/smoke-fixture-server.py b/scripts/smoke-fixture-server.py index 279dcffad..70bf6c295 100755 --- a/scripts/smoke-fixture-server.py +++ b/scripts/smoke-fixture-server.py @@ -8,9 +8,29 @@ import functools import http.server import os import pathlib +import socketserver import tempfile +class FixtureHTTPServer(http.server.ThreadingHTTPServer): + """Bind without the stdlib's reverse-DNS lookup. + + http.server.HTTPServer.server_bind() resolves socket.getfqdn(host). On a + host whose resolver does not answer for the bind address that call blocks + for the resolver timeout, so the process stays alive and never reaches + publish_port -- observed on macOS Intel runners as waited=30.0s, + exit_status=alive, port_file=absent, empty startup log. The FQDN only + feeds CGI-style variables this fixture never serves, so binding without + it is both sufficient and immune to resolver behaviour. + """ + + def server_bind(self) -> None: + socketserver.TCPServer.server_bind(self) + host, port = self.server_address[:2] + self.server_name = host + self.server_port = port + + def publish_port(port_file: pathlib.Path, port: int) -> None: """Atomically publish the assigned port after the listening socket exists.""" port_file.parent.mkdir(parents=True, exist_ok=True) @@ -46,7 +66,7 @@ def main() -> None: http.server.SimpleHTTPRequestHandler, directory=str(directory), ) - with http.server.ThreadingHTTPServer((args.bind, 0), handler) as server: + with FixtureHTTPServer((args.bind, 0), handler) as server: publish_port(args.port_file, server.server_port) print( f"smoke fixture server: http://{args.bind}:{server.server_port} " diff --git a/tests/test_smoke_fixture_contract.sh b/tests/test_smoke_fixture_contract.sh index fb2995061..d38282192 100755 --- a/tests/test_smoke_fixture_contract.sh +++ b/tests/test_smoke_fixture_contract.sh @@ -51,8 +51,10 @@ helper_source = read(helper_relative) # The server owns the ephemeral bind. A parent-side socket probe followed by # python -m http.server would reintroduce the close/rebind race this guards. require( - "ThreadingHTTPServer((args.bind, 0)" in helper_source, - "fixture server must bind port 0 itself and retain the listening socket", + "HTTPServer((args.bind, 0)" in helper_source + and "ThreadingHTTPServer)" in helper_source, + "fixture server must bind port 0 itself on a threading server and retain " + "the listening socket", ) require( "--port-file" in helper_source and "os.replace" in helper_source, @@ -430,6 +432,60 @@ if helper.is_file(): process.wait(timeout=3) failures.append("fixture server did not terminate promptly") +# Regression guard, by construction: binding must not depend on reverse DNS. +# http.server's default server_bind() resolves socket.getfqdn(), which blocks +# for the resolver timeout on hosts that do not answer for the bind address -- +# the server stays alive and never publishes its port (macOS Intel). Force the +# resolver to hang and require the port anyway, so the dependency cannot +# return without turning this gate red on every platform. +if helper.is_file(): + with tempfile.TemporaryDirectory(prefix="cbm-fixture-dns-") as dns_temp: + dns_root = pathlib.Path(dns_temp) + dns_fixture = dns_root / "fixture" + dns_fixture.mkdir() + (dns_fixture / "expected-artifact.txt").write_bytes(b"fixture-ok\n") + dns_port_file = dns_root / "port" + dns_wrapper = dns_root / "hang_reverse_dns.py" + dns_wrapper.write_text( + "import runpy, socket, sys, time\n" + "socket.getfqdn = lambda *a, **k: time.sleep(300)\n" + "sys.argv = ['smoke-fixture-server.py', '--directory', " + f"{str(dns_fixture)!r}, '--port-file', {str(dns_port_file)!r}]\n" + f"runpy.run_path({str(helper)!r}, run_name='__main__')\n", + encoding="utf-8", + ) + dns_log = dns_root / "server.log" + with dns_log.open("wb") as dns_handle: + dns_process = subprocess.Popen( + [sys.executable, str(dns_wrapper)], + stdout=dns_handle, + stderr=subprocess.STDOUT, + ) + try: + dns_port = 0 + dns_deadline = time.monotonic() + 20 + while time.monotonic() < dns_deadline: + if dns_port_file.is_file(): + dns_text = dns_port_file.read_text(encoding="ascii").strip() + if dns_text: + dns_port = int(dns_text) + break + if dns_process.poll() is not None: + break + time.sleep(0.02) + require( + dns_port > 0, + "fixture server must bind without a reverse-DNS lookup " + "(hanging socket.getfqdn must not block port publication)", + ) + finally: + dns_process.terminate() + try: + dns_process.wait(timeout=3) + except subprocess.TimeoutExpired: + dns_process.kill() + dns_process.wait(timeout=3) + if failures: print("smoke fixture contract: FAIL", file=sys.stderr) for failure in failures: