Skip to content

Commit a24b7b3

Browse files
test(appsec): make server startup failures diagnosable and port reuse safe (#19674)
APPSEC-69623 All 13 quarantined tests here fail in `appsec_application_server`, not in their own bodies. The recorded error is the "Server failed to start" assertion, and the runs with a duration cluster at 22.0–22.6s against a ~13s startup budget. **The failures are undebuggable.** The assertion interpolated `getattr(server_process, "stdout", None)` — `None` for a `multiprocessing.Process`, a stream rather than text for a `Popen` — so every one reports a literal `None`. It now reports what is *not* already in the captured output: exit code (`None` = too slow, non-zero = died), whether the port is still taken, and the command. **Port 8050 is shared by 31 tests**, these suites run serially, and teardown is best effort (SIGTERM to the process group, `join(timeout=5)`, each step in `except/pass`). A gunicorn worker outliving that leaves the port taken and the next test can't bind. Now waits for the port both before starting and after tearing down. **The suites never ran.** The first push was green with zero test events for any of the 13 tests: `tests/appsec/appsec_utils.py` matched 15 suites but none of the `appsec_integrations_*` ones that import it, because `@appsec` covers `ddtrace/appsec/*` source only and each suite lists just its own test directory. The second commit adds the file to the six suites whose tests import it (grep-verified: `flask_tests` 7 files, `fastapi_tests` 2, `django_tests` 1, plus `iast_packages` and `iast_tdd_propagation`). It now matches 21 suites; `suitespec-check` passes both gates. Notes: - The port wait tests whether the port can be **bound**, not whether it accepts connections. `connect()` reports a port free once a bound server's listen backlog fills — exactly the wedged state worth catching — and opens real connections to a live server. My first version did use `connect()` and wrongly reported "released" after 0.3s against a still-bound socket. - Warns rather than raises when the port never frees; raising in teardown would turn one leak into a failure across every appsec server suite. - No per-worker port offsets: these suites have no `-n` in the riotfile, so the collision is sequential, not concurrent. - The newly triggered suites also hold quarantined tests this branch does not fix — the SCA reachability pair (fixed in #19671) and `test_django_insecure_cookie_secure` (an `index_aspect` bug). Deliberately left unkeyed rather than un-quarantined on a commit that would not fix them. Blast radius: `appsec_application_server` backs all five server context managers, and the suitespec change widens what CI runs on such edits — both intended. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: christophe.papazian <christophe.papazian@datadoghq.com>
1 parent 37d2e79 commit a24b7b3

2 files changed

Lines changed: 63 additions & 12 deletions

File tree

tests/appsec/appsec_utils.py

Lines changed: 57 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,50 @@
2222
FILE_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
2670
def 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

439484
def _mp_target(_cmd: list[str], _env: dict) -> None:

tests/appsec/suitespec.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ suites:
7474
paths:
7575
- '@appsec_iast'
7676
- tests/appsec/iast_packages/*
77+
- tests/appsec/appsec_utils.py
7778
timeout: 50m
7879
iast_tdd_propagation:
7980
venvs_per_job: 1
@@ -84,6 +85,7 @@ suites:
8485
- '@appsec_iast'
8586
- '@remoteconfig'
8687
- tests/appsec/iast_tdd_propagation/*
88+
- tests/appsec/appsec_utils.py
8789
retry: 2
8890
snapshot: true
8991
appsec_integrations_pygoat:
@@ -148,6 +150,7 @@ suites:
148150
- '@appsec_iast'
149151
- tests/appsec/integrations/flask_tests/test_iast_flask.py
150152
- tests/appsec/integrations/flask_tests/test_appsec_flask_telemetry.py
153+
- tests/appsec/appsec_utils.py
151154
retry: 2
152155
# test_appsec_flask_telemetry.py asserts on payloads received by the test agent.
153156
snapshot: true
@@ -162,6 +165,7 @@ suites:
162165
- '@appsec_iast'
163166
- '@remoteconfig'
164167
- tests/appsec/integrations/flask_tests/*
168+
- tests/appsec/appsec_utils.py
165169
retry: 2
166170
services:
167171
- testagent
@@ -176,6 +180,7 @@ suites:
176180
- '@appsec_iast'
177181
- '@remoteconfig'
178182
- tests/appsec/integrations/django_tests/*
183+
- tests/appsec/appsec_utils.py
179184
retry: 2
180185
services:
181186
- testagent
@@ -190,6 +195,7 @@ suites:
190195
- '@appsec_iast'
191196
- '@remoteconfig'
192197
- tests/appsec/integrations/fastapi_tests/*
198+
- tests/appsec/appsec_utils.py
193199
retry: 2
194200
services:
195201
- testagent

0 commit comments

Comments
 (0)