Skip to content

[Bug]: sidecar killed by SIGKILL right after execve under FrankenPHP, leaving one zombie per retry until the PID cgroup is full #4099

Description

@NikitaCOEUR

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions