Skip to content

fix(ci): stop FastCGI web job hangs and swoole/frankenphp sidecar-log flakes - #4201

Draft
Leiyks wants to merge 4 commits into
masterfrom
leiyks/fix-ci-fastcgi-web-harness
Draft

Leiyks wants to merge 4 commits into
masterfrom
leiyks/fix-ci-fastcgi-web-harness

Conversation

@Leiyks

@Leiyks Leiyks commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Why FastCGI web jobs hang for an hour after the tests pass

run_tests_debug adds 3>&1, which duplicates the job's stdout pipe onto fd 3. proc_open only rewires fds 0/1/2, so every server the harness spawns inherits fd 3 — a live write end of the runner's log pipe. nginx runs daemon off; worker_processes 1, and stop(0) SIGKILLs the master ~1ms after SIGTERM, before it can reap its worker. The orphaned worker survives the job, keeps the log pipe from reaching EOF, tee never returns, and the job burns its full hour until the runner kills it.

Measured with lsof on the pipe inode: nginx master, nginx worker and php-cgi all hold the runner's fd-1 pipe. Closing fd 3 once in phpunit leaves none of them holding it — so the fix is one line in the Makefile, and the nginx/php-cgi stop() grace bumps here are purely defence-in-depth (the orphan is still alive, it just no longer holds the job's pipe).

Fleet rate: 58 hangs / 14,318 attempts across 10 pipelines, spread over 13 frameworks.

PhpCgi readiness gate

PhpCgi was the only isFastCgi() SAPI without a readiness gate. php-cgi binds its port only after MINIT, and nginx turns an unbound upstream into a successful transfer carrying a 502 — indistinguishable, to the existing request retry loop, from a real response. So startup races surfaced as assertion failures with every log line reading attempt #0, rather than as retries.

Why swoole/frankenphp PHPUnit jobs flake at ~50%

Second, unrelated fix in the same area. The PHPUnit runner process is itself traced, but no agent was configured for it, so it used the compiled-in default localhost:8126 where nothing listens. Remote config polled it every 5s and logged each failure at error level, alongside agent_info, send_with_retry and trace_flusher. The runner and the web server share one sidecar, and the sidecar's log sink is a fan-out writer that appends every line to every log file any session has registered — so once a post-fork or new-ZTS-thread connection registered the app's dd_php_error.log, the runner's own errors landed in it and WebServer::checkErrors() failed the class. That is why swoole (forks workers) and frankenphp (ZTS) were hit at ~50% while test_web_* jobs emitting far more of these errors stayed green: they connect once at MINIT, before the error log exists.

Fix: set DD_AGENT_HOST/DD_TRACE_AGENT_PORT on .cli_integration_test, mirroring what Makefile:1339-1341 already does for test_integration. Not on .base_test: that reaches 1256 jobs, 89 of which have no test-agent service (the host would not resolve), and also reaches the .phpt jobs, where request_replayer.inc derives the replayer's own endpoint from those two variables and two tests would break. .cli_integration_test covers the 104 PHPUnit targets only, all of which already run the service.

Verified locally on swoole: 14/15 iterations red before, 15/15 green after, with zero [error|warn] lines in dd_php_error.log on every iteration and the localhost:8126 sessions gone. The runner emits no trace payloads (510 agent requests, all /v0.7/config and telemetry), so snapshot sessions cannot be contaminated, and check_test_agent.sh returns 200 before and after. frankenphp was not verified locally — CI runs it on debug-zts-asan and the local container is NTS, so the suite self-skips; confirmation is expected from CI.

Why the frankenphp job hangs for its full hour

Third fix, again independent of the two above. .cli_integration_test sets KUBERNETES_CPU_REQUEST: 2 with no KUBERNETES_CPU_LIMIT, so automaxprocs finds no cgroup quota, logs Leaving GOMAXPROCS=87: CPU quota undefined, and FrankenPHP starts 2*GOMAXPROCS+1 threads — measured at 175 on an 87-core node and 153 on a 76-core node, inside a 2-CPU/4GiB pod. The thread count therefore depended on which node the job landed on, which is why test_integrations_frankenphp hit the 1h job timeout in ~40% of attempts with PHPUnit still running and no summary printed. Pinning num_threads with max_threads unset also disables autoscaling, so the count is now constant and independent of GOMAXPROCS; num_threads must stay above the worker num or FrankenPHP refuses to start outright, which is why both are set together.

max_wait_time was 0, meaning a request with no free thread waited on a nil channel forever — that is what turned a stuck server into a job timeout rather than a failed test. It bounds only the queue wait, never execution (measured: a 30s request still returns 200), so a slow ASAN request cannot be rejected.

FrankenphpServer also had no readiness wait, one of only two SAPIs missing one. Caddy logs server running both before and after FrankenPHP's own startup line, so the probe gates on that line and the port together.

Verified against the pinned v1.12.7 binary with the real rendered Caddyfile: starts with num_threads=4/max_threads=4, /simple 200, /error 500, 5 sequential 200s. Capacity measured at 2 against a suite demand of 1 (all requests are sequential, one PHPUnit process). The PHPUnit frankenphp suite itself was not run locally — CI runs it on debug-zts-asan and the local container is NTS, so the suite self-skips.

Caveat: pinning to 2 worker threads deliberately gives up the incidental multi-thread stress coverage that 153 threads provided.

Verification (fd-3 leak + readiness gate)

  • fd-3 repro: exit=124 (hung 25s) → exit=0 (0.9s) with the Makefile change alone.
  • 3s artificial bind delay: 10/10 502s before the gate; a 200 on attempt #0 after.
  • test_web_custom over cgi-fcgi unchanged.

Not fixed here

Pre-existing issues, distinct from the three fixes above (each of the first two observed across at least 4 SHAs with no transition point — no commit caused them):

  • GracefulShutdownTest passing on a SIGSEGV (exit 139).
  • 15 fleet hangs in System Tests / PHP Language Tests, which do not run through run_tests_debug and so are unexplained by this fix.
  • The sidecar log fan-out itself — one session's errors being appended to every other session's log file — is the durable underlying defect behind the flake above. It lives in libdatadog and cannot ship from this repo; the agent variables are the correct fix on this side regardless.

Follow-ups, not fixed here:

  • sendRequest() still has no CURLOPT_TIMEOUT, so a wedged server stalls a test rather than failing it.
  • install-frankenphp.sh runs an unbounded cold go build inside the first test that needs it.

run_tests_debug adds `3>&1`, duplicating the job's stdout onto fd 3. proc_open
rewires only fds 0/1/2, so every server the harness spawns inherits fd 3. nginx
runs with `daemon off; worker_processes 1`, and `stop(0)` SIGKILLs the master
~1ms after SIGTERM, before it can reap its worker -- the orphan keeps the job's
log pipe from reaching EOF, `tee` blocks forever, and the job burns an hour
until the runner kills it. Measured: nginx master, worker and php-cgi all hold
the runner's fd-1 pipe inode; closing fd 3 once in phpunit leaves none holding
it. Fleet rate was 58 hangs / 14,318 attempts across 10 pipelines, spread over
13 frameworks.

Also add the readiness gate PhpCgi was the only isFastCgi() SAPI missing.
php-cgi binds its port only after MINIT, and nginx turns an unbound upstream
into a *successful* transfer carrying a 502, which the request retry loop
cannot tell from a real response -- so the 502s surfaced as assertion failures
with every log line reading `attempt #0`.

Verified: the fd-3 repro goes from exit=124 (hung 25s) to exit=0 (0.9s) with
the Makefile change alone; a 3s bind delay yields 10/10 502s before the gate
and a 200 on attempt #0 after. test_web_custom over cgi-fcgi is unchanged.
@datadog-datadog-us1-prod

datadog-datadog-us1-prod Bot commented Sep 16, 2026

Copy link
Copy Markdown

Pipelines  Tests

Unblock PR with BitsAI

❌ Errors

Your PR has failed checks. Please review the issues below and take necessary action before merging.

🚦 7 Pipeline jobs failed

DataDog/apm-reliability/dd-trace-php | check libxml2 version — 🔧 Needs a code fix, caused by this PR

View more details · View in GitLab

DataDog/apm-reliability/dd-trace-php | test_extension_ci: [7.3, valgrind] — 🔧 Needs a code fix, caused by this PR

View more details · View in GitLab

DataDog/apm-reliability/dd-trace-php | test_extension_ci: [8.4] — 🔧 Needs a code fix, caused by this PR

View more details · View in GitLab

View all 7 failed jobs.

ℹ️ Info

No other issues found (see more)

🧪 All tests passed
❄️ No new flaky tests detected

🎯 Code Coverage (details)
Patch Coverage: 100.00%
Overall Coverage: 67.44% (-0.05%)

Useful? React with 👍 / 👎

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: ed222b6 | Docs | View more details | Give us feedback!

…op flaking

The runner process is itself traced but had no agent configured, so it used the
compiled-in default localhost:8126 where nothing listens. Remote config polled
it every 5s and each failure was logged at error level, alongside agent_info,
send_with_retry and trace_flusher. The runner and the web server share one
sidecar, whose log sink is a fan-out writer that appends every line to every log
file any session registered -- so once a post-fork or new-ZTS-thread connection
registered the app's dd_php_error.log, the runner's errors landed there and
WebServer::checkErrors() failed the class. That is why swoole (forks workers)
and frankenphp (ZTS) were hit at ~50% while test_web_* jobs emitting far more of
these errors stayed green: they connect once at MINIT, before the error log
exists.

Placed on .cli_integration_test, not .base_test: the latter reaches 1256 jobs,
89 of which have no test-agent service (unresolvable host), and also reaches the
.phpt jobs, where request_replayer.inc derives the replayer's own endpoint from
DD_AGENT_HOST/DD_TRACE_AGENT_PORT and would break two of them. This hits the 104
PHPUnit targets only, all of which already run the service. Mirrors what
Makefile:1339-1341 already does for test_integration.

Verified locally on swoole: 14/15 red before, 15/15 green after, with zero
[error|warn] lines in dd_php_error.log on every iteration and the localhost:8126
sessions gone. The runner emits no trace payloads (510 agent requests, all
/v0.7/config and telemetry), so snapshot sessions cannot be contaminated, and
check_test_agent.sh returns 200 before and after.
@Leiyks Leiyks changed the title fix(tests): stop FastCGI web jobs hanging for an hour after tests pass fix(ci): stop FastCGI web job hangs and swoole/frankenphp sidecar-log flakes Sep 17, 2026
Leiyks and others added 2 commits September 17, 2026 16:05
.cli_integration_test sets KUBERNETES_CPU_REQUEST: 2 with no
KUBERNETES_CPU_LIMIT, so automaxprocs finds no cgroup quota, logs "Leaving
GOMAXPROCS=87: CPU quota undefined" and FrankenPHP starts 2*GOMAXPROCS+1
threads -- measured at 175 on an 87-core node and 153 on a 76-core node, inside
a 2-CPU/4GiB pod. The thread count therefore depended on which node the job
landed on, which is why test_integrations_frankenphp hit the 1h job timeout in
~40% of attempts with PHPUnit still running and no summary printed.

Pinning num_threads with max_threads unset also disables autoscaling, so the
count is now constant and independent of GOMAXPROCS. num_threads must stay
above the worker num or FrankenPHP refuses to start outright -- both are set
together for that reason.

max_wait_time was 0, meaning a request with no free thread waited on a nil
channel forever, which is what turned a stuck server into a job timeout rather
than a failed test. It bounds only the queue wait, never execution (measured: a
30s request still returns 200), so a slow ASAN request cannot be rejected.

FrankenphpServer also had no readiness wait -- one of only two SAPIs missing
one -- and Caddy logs "server running" both before and after FrankenPHP's own
startup line, so the probe gates on that line and the port together.

Verified against the pinned v1.12.7 binary with the real rendered Caddyfile:
starts with num_threads=4/max_threads=4, /simple 200, /error 500, 5 sequential
200s. Capacity measured at 2 against a suite demand of 1 (all requests
sequential, one PHPUnit process). The PHPUnit suite itself was not run locally:
CI uses debug-zts-asan and the local container is NTS, so it self-skips.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant