[php-src] PR #24056: Fix GH-24046: FPM worker stdio pipe fd closed twice
| From: | ipc-zpg | Date: | Thu, 01 Oct 2026 19:43:53 +0000 |
| Subject: | [php-src] PR #24056: Fix GH-24046: FPM worker stdio pipe fd closed twice | ||
| Groups: | php.git-pulls | ||
| Request: | Send a blank email to git-pulls+get-39052@lists.php.net to get a copy of this message | ||
Pull Request: https://github.com/php/php-src/pull/24056
Author: ipc-zpg
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](https://claude.com/claude-code)