Skip to content

Fix GH-24046: FPM worker stdio pipe fd closed twice - #24056

Open
ipc-zpg wants to merge 1 commit into
php:PHP-8.4from
ipc-zpg:fix/gh-24046-fpm-stdio-double-close
Open

ipc-zpg wants to merge 1 commit into
php:PHP-8.4from
ipc-zpg:fix/gh-24046-fpm-stdio-double-close

Conversation

@ipc-zpg

@ipc-zpg ipc-zpg commented Oct 1, 2026

Copy link
Copy Markdown

Fixes #24046

Problem

With catch_workers_output = yes, a worker can exit while one of its child processes still holds its stdout/stderr pipe open. For example, a fopen('php://stderr') dup gets inherited by a background exec(). In that case the drain in fpm_child_close() gets EAGAIN instead of EOF, so fpm_stdio_child_said() leaves the fd open. fpm_child_close() then closes the fd but keeps the number in child->fd_stdout / child->fd_stderr. One second later, fpm_postponed_child_free() (added in the GH-10461 fix) calls fpm_event_del() and close() on that same number again:

  • If the number hasn't been reused, fpm logs epoll: unable to remove fd N.
  • If a new worker's pipe was given the number in the meantime, which is common because the master forks the replacement straight away, the new worker's event is removed and its read end is closed. From then on, every stdio write in that worker fails with EPIPE, and nothing is logged.

Fix

When fpm_child_close() closes the fd, it now also removes the event and sets the fd to -1. This is the same thing fpm_stdio_child_said() already does when it hits EOF or an error, and it stops the postponed free from acting on the fd again.

The event is only removed on the in_event_loop path, which runs in the master. fpm_children_free() (the in_event_loop = 0 path) also runs during cleanup in forked workers, where the epoll/kqueue instance is shared with the master. Calling fpm_event_del() there could remove the master's registrations, so that path still only closes the fd and resets it.

Test

sapi/fpm/tests/gh24046-stdio-pipe-double-close.phpt uses pm = static, pm.max_children = 1 and pm.max_requests = 1. The first request writes to a dup of stderr and starts a background sleep, then the worker exits. The test waits for the postponed free and sends a second request, which must still be able to write to stderr.

  • Without the fix: the second request returns write to stderr failed.
  • With the fix: it passes (8/8 runs, Debian bookworm, epoll).

I ran the rest of sapi/fpm/tests with and without the fix and got the same results. gh-11086-daemonized-logs-duplicated and socket-uds-too-long-filename-test fail in my root-in-container setup either way.

🤖 Generated with Claude Code

When a worker exits while one of its child processes still holds the
worker's stdout/stderr pipe open, the drain in fpm_child_close() gets
EAGAIN instead of EOF and leaves the fd open. fpm_child_close() then
closed the fd but kept its number in the child struct, so the postponed
free closed it (and removed its event) a second time. If a new worker
had been given the same fd number in the meantime, its pipe was closed
and every later write it made to stdio failed with EPIPE.

Remove the event and reset the fd to -1 when closing it in
fpm_child_close() so the postponed free cannot act on it again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ipc-zpg
ipc-zpg requested a review from bukka as a code owner October 1, 2026 19:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant