Bug report
Under FrankenPHP, the sidecar is sometimes killed by SIGKILL immediately after its execve, before it can bind its socket. The tracer then reconnects, which respawns a sidecar that dies the same way. Each attempt leaves a zombie that FrankenPHP (a Go binary as PID 1) never reaps, so the pod's PID cgroup fills up.
On one pod we measured 86 spawn attempts and 86 SIGKILLs in 25 seconds, with bind() on the sidecar socket never succeeding once.
The impact reaches beyond the container: RLIMIT_NPROC is accounted per real UID at host scope, so a leaking pod exhausted the counter for every pod running as uid 1000 on the same Kubernetes node, across namespaces. fork/execve then fail with EAGAIN node-wide.
Environment
- dd-trace-php 1.23.3 (injected via
dd-lib-php-init, single step instrumentation)
- libdatadog v36.0.0-14-g6a6d4a535 (the submodule commit referenced by tag 1.23.3)
- FrankenPHP 1.12.3, 1.12.4 and 1.12.6, PHP 8.5.6 to 8.5.9 ZTS, non-worker mode,
num_threads: 2
- Linux 6.18 (Talos), containerd 2.1.6, Kubernetes 1.35
opcache.preload enabled, opcache.jit disabled
What we observed
strace -f on frankenphp while a pod was leaking:
bind(17, {sa_family=AF_UNIX, sun_path=@"libdatadog/1.23.3@1000.sock"}, 30) = 0
execve("/lib64/ld-linux-x86-64.so.2", ["datadog-ipc-helper", ".../libdatadog_php.so"], …)
+++ killed by SIGKILL +++
--- SIGCHLD {si_code=CLD_KILLED, si_pid=752, si_status=SIGKILL} ---
connect(17, {sa_family=AF_UNIX, sun_path=@"libdatadog/1.23.3@1000.sock"}, 30) = -1 ECONNREFUSED
→ repeats roughly every 70 ms, indefinitely
The last syscalls made by the spawned process before it dies:
prctl(PR_SET_PDEATHSIG, SIGKILL) = 0
getppid() = 1434
execve("/lib64/ld-linux-x86-64.so.2", ["datadog-ipc-helper", …]) = ?
+++ killed by SIGKILL +++
Where the two sides meet
That prctl is not libdatadog's — it comes from FrankenPHP's pthread_atfork child handler,
frankenphp.c#L171-L178,
registered at frankenphp.c#L203:
static void frankenphp_fork_child(void) {
is_forked_child = true;
#if defined(__linux__)
// if the parent process dies between fork() and this prctl()
if (prctl(PR_SET_PDEATHSIG, SIGKILL, 0, 0, 0) != 0 ||
getppid() != fork_parent_pid) {
_exit(1);
}
Because it is registered with pthread_atfork, it runs in the child of every fork() in the
process — including the one spawn_worker performs for the sidecar, which FrankenPHP knows nothing
about. It was added to stop orphaned children from accumulating (php/frankenphp#2331, fixed by #2332).
spawn_worker then does what a daemon launcher normally does — the intermediate process exits so
the daemon is reparented to PID 1
(spawn_worker/src/unix/spawn.rs#L687-L704):
if self.daemonize {
if let Fork::Parent(_) = do_fork()? {
…
libc::_exit(0); // L699 — intermediate parent exits
}
}
spawn(); // L704 — child reaches execve, possibly too late
The two behaviours are individually reasonable and mutually exclusive: one side installs "kill me if
my parent dies", the other deliberately kills the parent.
FrankenPHP does guard the race — it re-checks getppid() after the prctl. But the check happens
before execve, and in our traces the execve takes about 8 ms while the intermediate parent
exits 0.3 ms into it. The window stays wide open.
Not fixed by upgrading
We checked both sides at their current master (2026-08-07):
spawn_worker is unchanged between the v36.0.0 commit we run and libdatadog master (v39.0.0+8)
— the only diff in the whole crate is Cargo.toml.
- The
pthread_atfork handler is present in FrankenPHP 1.12.3, 1.12.4 and 1.12.6 alike; we
reproduced the leak on 1.12.6 and confirmed the prctl is compiled into all three binaries.
So this is not something a version bump resolves on either side.
Why it is intermittent
It is a race, so timing decides. On a healthy pod we captured the same SIGKILL on the first
attempt, but the daemon had already forked its final process, which survived and bound the socket.
On a leaking pod, the kill lands before that fork — 86 times out of 86.
Once it starts, it never recovers: the @libdatadog/<version>@<uid>.sock lock stays held by
FrankenPHP, which never exits.
We could not explain why the outcome is systematic within a given pod rather than random. We saw
a correlation with PHP 8.5.8 (four leaking pods on 8.5.8, six healthy ones on 8.5.6/8.5.7/8.5.9),
but a pod recreated on 8.5.8 did not leak, so we treat it as a timing influence rather than a cause.
Workaround
DD_TRACE_SIDECAR_CONNECTION_MODE=thread fixes it completely: no subprocess, no fork, no race.
Verified on several pod recreations across three nodes — zero reconnects, zero zombies, tracing
still working (spans encoded and flushed). The documented pcntl_fork() incompatibility does not
apply to us since the extension is not loaded.
Question
Would it be possible for spawn_worker to keep the intermediate parent alive until the daemon has
finished its execve and published its socket, or to clear PDEATHSIG in the child before
execve? The problem seems inherent to the combination rather than to any particular version — we
reproduced it on three FrankenPHP releases and four PHP versions.
Bug report
Under FrankenPHP, the sidecar is sometimes killed by
SIGKILLimmediately after itsexecve, before it can bind its socket. The tracer then reconnects, which respawns a sidecar that dies the same way. Each attempt leaves a zombie that FrankenPHP (a Go binary as PID 1) never reaps, so the pod's PID cgroup fills up.On one pod we measured 86 spawn attempts and 86 SIGKILLs in 25 seconds, with
bind()on the sidecar socket never succeeding once.The impact reaches beyond the container:
RLIMIT_NPROCis accounted per real UID at host scope, so a leaking pod exhausted the counter for every pod running as uid 1000 on the same Kubernetes node, across namespaces.fork/execvethen fail withEAGAINnode-wide.Environment
dd-lib-php-init, single step instrumentation)num_threads: 2opcache.preloadenabled,opcache.jitdisabledWhat we observed
strace -fonfrankenphpwhile a pod was leaking:The last syscalls made by the spawned process before it dies:
Where the two sides meet
That
prctlis not libdatadog's — it comes from FrankenPHP'spthread_atforkchild handler,frankenphp.c#L171-L178,registered at
frankenphp.c#L203:Because it is registered with
pthread_atfork, it runs in the child of everyfork()in theprocess — including the one
spawn_workerperforms for the sidecar, which FrankenPHP knows nothingabout. It was added to stop orphaned children from accumulating (php/frankenphp#2331, fixed by #2332).
spawn_workerthen does what a daemon launcher normally does — the intermediate process exits sothe daemon is reparented to PID 1
(
spawn_worker/src/unix/spawn.rs#L687-L704):The two behaviours are individually reasonable and mutually exclusive: one side installs "kill me if
my parent dies", the other deliberately kills the parent.
FrankenPHP does guard the race — it re-checks
getppid()after theprctl. But the check happensbefore
execve, and in our traces theexecvetakes about 8 ms while the intermediate parentexits 0.3 ms into it. The window stays wide open.
Not fixed by upgrading
We checked both sides at their current
master(2026-08-07):spawn_workeris unchanged between the v36.0.0 commit we run and libdatadogmaster(v39.0.0+8)— the only diff in the whole crate is
Cargo.toml.pthread_atforkhandler is present in FrankenPHP 1.12.3, 1.12.4 and 1.12.6 alike; wereproduced the leak on 1.12.6 and confirmed the
prctlis compiled into all three binaries.So this is not something a version bump resolves on either side.
Why it is intermittent
It is a race, so timing decides. On a healthy pod we captured the same
SIGKILLon the firstattempt, but the daemon had already forked its final process, which survived and bound the socket.
On a leaking pod, the kill lands before that fork — 86 times out of 86.
Once it starts, it never recovers: the
@libdatadog/<version>@<uid>.socklock stays held byFrankenPHP, which never exits.
We could not explain why the outcome is systematic within a given pod rather than random. We saw
a correlation with PHP 8.5.8 (four leaking pods on 8.5.8, six healthy ones on 8.5.6/8.5.7/8.5.9),
but a pod recreated on 8.5.8 did not leak, so we treat it as a timing influence rather than a cause.
Workaround
DD_TRACE_SIDECAR_CONNECTION_MODE=threadfixes it completely: no subprocess, no fork, no race.Verified on several pod recreations across three nodes — zero reconnects, zero zombies, tracing
still working (spans encoded and flushed). The documented
pcntl_fork()incompatibility does notapply to us since the extension is not loaded.
Question
Would it be possible for
spawn_workerto keep the intermediate parent alive until the daemon hasfinished its
execveand published its socket, or to clearPDEATHSIGin the child beforeexecve? The problem seems inherent to the combination rather than to any particular version — wereproduced it on three FrankenPHP releases and four PHP versions.