2222FILE_PATH = Path (__file__ ).resolve ().parent
2323
2424
25+ def _port_is_available (port : int ) -> bool :
26+ """Whether a server could bind the port right now.
27+
28+ Binding is the question that matters, since it is what the next server does. Probing with
29+ connect() instead reports a port as free once a bound server's listen backlog fills, and
30+ opens real connections to a live server.
31+ """
32+ with socket .socket (socket .AF_INET , socket .SOCK_STREAM ) as sock :
33+ sock .setsockopt (socket .SOL_SOCKET , socket .SO_REUSEADDR , 1 )
34+ try :
35+ sock .bind (("0.0.0.0" , int (port )))
36+ return True
37+ except OSError :
38+ return False
39+
40+
41+ def _wait_for_port_release (port : int , timeout : float = 10.0 ) -> bool :
42+ """Wait until the port can be bound again, returning False if it never can.
43+
44+ Server teardown is best effort and gunicorn workers can outlive it, so without this the
45+ next test to use the same port fails to bind. 31 tests share port 8050.
46+ """
47+ deadline = time .monotonic () + timeout
48+ while time .monotonic () < deadline :
49+ if _port_is_available (port ):
50+ return True
51+ time .sleep (0.1 )
52+ return _port_is_available (port )
53+
54+
55+ def _server_diagnostics (server_process , port : int , cmd : list ) -> str :
56+ """Facts that are not in the captured server output but explain most startup failures."""
57+ if isinstance (server_process , multiprocessing .Process ):
58+ exit_code = server_process .exitcode
59+ else :
60+ exit_code = server_process .poll ()
61+ return (
62+ f"port={ port } port_still_bound={ not _port_is_available (port )} pid={ server_process .pid } "
63+ f"exit_code={ exit_code } (None means it was still running, so it was too slow rather "
64+ f"than dead; a non-zero code with the port bound means another server still holds it)\n "
65+ f"command={ cmd } "
66+ )
67+
68+
2569@contextmanager
2670def gunicorn_flask_server (
2771 use_ddtrace_cmd : bool = True ,
@@ -335,6 +379,10 @@ def appsec_application_server(
335379 if preexec is not None :
336380 subprocess_kwargs ["preexec_fn" ] = preexec # type: ignore[assignment]
337381
382+ # A previous test's server may still hold the port, which would make this one fail to bind.
383+ if not _wait_for_port_release (port ):
384+ print (f"WARNING: port { port } was still bound when starting the server" )
385+
338386 if use_multiprocess :
339387 # Run the server command by replacing the child Python process with the target binary (exec),
340388 # ensuring signals/termination behave like the subprocess.Popen path.
@@ -374,17 +422,13 @@ def appsec_application_server(
374422 print ("Server started" )
375423 except RetryError :
376424 raise AssertionError (
377- "Server failed to start, see stdout and stderr logs"
378- "\n === Captured STDOUT ===\n %s=== End of captured STDOUT ==="
379- "\n === Captured STDERR ===\n %s=== End of captured STDERR ==="
380- % (getattr (server_process , "stdout" , None ), getattr (server_process , "stderr" , None ))
425+ "Server failed to start; its output is in the captured stdout/stderr above.\n "
426+ + _server_diagnostics (server_process , port , cmd )
381427 )
382428 except Exception :
383429 raise AssertionError (
384- "Server FAILED, see stdout and stderr logs"
385- "\n === Captured STDOUT ===\n %s=== End of captured STDOUT ==="
386- "\n === Captured STDERR ===\n %s=== End of captured STDERR ==="
387- % (getattr (server_process , "stdout" , None ), getattr (server_process , "stderr" , None ))
430+ "Server FAILED; its output is in the captured stdout/stderr above.\n "
431+ + _server_diagnostics (server_process , port , cmd )
388432 )
389433
390434 # If we run a Gunicorn application, we want to get the child's pid, see test_flask_remoteconfig.py
@@ -399,9 +443,8 @@ def appsec_application_server(
399443 pass
400444 except Exception :
401445 raise AssertionError (
402- "\n === Captured STDOUT ===\n %s=== End of captured STDOUT ==="
403- "\n === Captured STDERR ===\n %s=== End of captured STDERR ==="
404- % (getattr (server_process , "stdout" , None ), getattr (server_process , "stderr" , None ))
446+ "Server shutdown request failed; its output is in the captured stdout/stderr above.\n "
447+ + _server_diagnostics (server_process , port , cmd )
405448 )
406449 finally :
407450 try :
@@ -433,7 +476,9 @@ def appsec_application_server(
433476 assert "Return value is tainted" in stderr_output
434477 assert "Tainted arguments:" in stderr_output
435478 finally :
436- pass
479+ # Do not hand the port to the next test while a worker still holds it.
480+ if not _wait_for_port_release (port ):
481+ print (f"WARNING: port { port } still bound after server teardown" )
437482
438483
439484def _mp_target (_cmd : list [str ], _env : dict ) -> None :
0 commit comments