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

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.

Closes GH-24056.

Co-authored-by: ndossche <7771979+ndossche@users.noreply.github.com>
This commit is contained in:
Ian P. Christianandndossche authored and Jakub Zelenka committed 2026-10-27 17:59:55 +01:00
1 parent 98043f3a1c
commit cd161267c0
3 files changed
+68

No files matched your search

+3
View File
@@ -75,6 +75,9 @@ PHP NEWS
. Fixed crash and stack memory disclosure in the worker output logging with
a pool name longer than the message prefix buffer or log_limit.
(Martin Mirchev)
. Fixed bug GH-24046 (FPM worker stdio pipe fd closed twice when a child
process outlives the worker, breaking a later worker's stdio).
(Ian P. Christian, ndossche)
- Fileinfo:
. Fixed bug GH-24289 (finfo::file() reports the wrong argument number for
+8
View File
@@ -87,7 +87,11 @@ static void fpm_child_close(struct fpm_child_s *child, int in_event_loop) /* {{{
fpm_event_fire(&child->ev_stdout);
}
if (child->fd_stdout != -1) {
if (in_event_loop) {
fpm_event_del(&child->ev_stdout);
}
close(child->fd_stdout);
child->fd_stdout = -1;
}
}
@@ -97,7 +101,11 @@ static void fpm_child_close(struct fpm_child_s *child, int in_event_loop) /* {{{
fpm_event_fire(&child->ev_stderr);
}
if (child->fd_stderr != -1) {
if (in_event_loop) {
fpm_event_del(&child->ev_stderr);
}
close(child->fd_stderr);
child->fd_stderr = -1;
}
}
@@ -0,0 +1,57 @@
--TEST--
GH-24046 (FPM: worker stdio pipe fd closed twice when a child process outlives the worker)
--SKIPIF--
<?php include "skipif.inc"; ?>
--FILE--
<?php
require_once "tester.inc";
$cfg = <<<EOT
[global]
error_log = {{FILE:LOG}}
[unconfined]
listen = {{ADDR}}
pm = static
pm.max_children = 1
pm.max_requests = 1
catch_workers_output = yes
decorate_workers_output = no
EOT;
$code = <<<'EOT'
<?php
/* dup() of the worker's stderr pipe that is inherited by the background process */
$stderr = fopen('php://stderr', 'a');
if (@fwrite($stderr, "log line\n") === false) {
echo "write to stderr failed";
exit;
}
if (isset($_GET['bg'])) {
exec('sleep 3 > /dev/null 2>&1 &');
}
echo "ok";
EOT;
$tester = new FPM\Tester($cfg, $code);
$tester->start();
$tester->expectLogStartNotices();
/* The worker exits after this request while the background process still holds
* its stderr pipe open. The replacement worker gets the same pipe fd numbers. */
$tester->request('bg=1')->expectBody('ok');
/* Wait for the postponed free of the exited worker. */
usleep(1500000);
$tester->request()->expectBody('ok');
$tester->terminate();
$tester->expectNoLogPattern('/unable to (remove|read)/');
$tester->close();
?>
Done
--EXPECT--
Done
--CLEAN--
<?php
require_once "tester.inc";
FPM\Tester::clean();
?>