Skip to content

Commit 00048a7

Browse files
ext/pcntl: do not drop queued signals when an exception is pending
pcntl_signal_dispatch() takes the whole queue out of PCNTL_G(head) before it starts calling handlers, and recycles every entry it walks over. Two paths let signals disappear that way. The first one is the interrupt handler. ZEND_VM_FCALL_INTERRUPT_CHECK() runs right after an internal function returns, before the pending exception is handled, so pcntl_interrupt_function() reaches the dispatcher with EG(exception) set. call_user_function() returns without calling anything in that state, the "if (EG(exception)) break" added by 296fad1 fires on the first entry, and the drain loop then recycles the entire queue without a single handler having run. Setting the exception aside for the duration of the dispatch, the way destructors are called during unwinding, lets the handlers run. zend_exception_save() is not usable for that: it goes through the single EG(prev_exception) slot, so a dispatch happening inside an autoloader called with an exception set aside would hand that exception back too early. The second one is a handler that throws while other signals are queued behind it: those were recycled too. They now go back to the queue, and with asynchronous signals the interrupt is re-armed, so that the engine delivers them on its own once the exception is handled instead of waiting for another signal to come in. This is reachable from any long blocking internal call that throws on timeout. pecl/amqp is one: AMQPQueue::consume() throws "Consumer timeout exceed" when the read timeout expires, which made a Symfony messenger worker built on it miss every SIGTERM, whatever the timeout was.
1 parent 7ef3bfe commit 00048a7

4 files changed

Lines changed: 137 additions & 10 deletions

File tree

‎NEWS‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ PHP NEWS
22
|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
33
?? ??? ????, PHP 8.4.27
44

5+
- PCNTL:
6+
. Fixed pcntl_signal_dispatch() dropping queued signals when it runs while an
7+
exception is pending. (nicolas-grekas)
8+
59

610
24 Sep 2026, PHP 8.4.26
711

‎ext/pcntl/pcntl.c‎

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
#include "ext/standard/info.h"
3232
#include "php_signal.h"
3333
#include "php_ticks.h"
34+
#include "zend_exceptions.h"
3435
#include "zend_fibers.h"
3536

3637
#if defined(HAVE_GETPRIORITY) || defined(HAVE_SETPRIORITY) || defined(HAVE_WAIT3)
@@ -1318,6 +1319,7 @@ void pcntl_signal_dispatch(void)
13181319
{
13191320
zval params[2], *handle, retval;
13201321
struct php_pcntl_pending_signal *queue, *next;
1322+
zend_object *old_exception;
13211323
sigset_t mask;
13221324
sigset_t old_mask;
13231325

@@ -1345,8 +1347,17 @@ void pcntl_signal_dispatch(void)
13451347
PCNTL_G(head) = NULL; /* simple stores are atomic */
13461348
PCNTL_G(tail) = NULL;
13471349

1350+
/* Dispatching can happen while an exception is propagating, typically from the interrupt
1351+
* check that runs right after an internal function returned with an exception pending.
1352+
* Handlers cannot be called in that state, so set the exception aside while they run,
1353+
* the way destructors are called during unwinding. */
1354+
old_exception = EG(exception);
1355+
EG(exception) = NULL;
1356+
13481357
/* Allocate */
13491358
while (queue) {
1359+
bool handler_threw = false;
1360+
13501361
if ((handle = zend_hash_index_find(&PCNTL_G(php_signal_table), queue->signo)) != NULL) {
13511362
if (Z_TYPE_P(handle) != IS_LONG) {
13521363
ZVAL_NULL(&retval);
@@ -1365,27 +1376,48 @@ void pcntl_signal_dispatch(void)
13651376
#ifdef HAVE_STRUCT_SIGINFO_T
13661377
zval_ptr_dtor(&params[1]);
13671378
#endif
1368-
if (EG(exception)) {
1369-
break;
1370-
}
1379+
handler_threw = NULL != EG(exception);
13711380
}
13721381
}
13731382

13741383
next = queue->next;
13751384
queue->next = PCNTL_G(spares);
13761385
PCNTL_G(spares) = queue;
13771386
queue = next;
1387+
1388+
/* No other handler can be called while the exception propagates */
1389+
if (handler_threw) {
1390+
break;
1391+
}
13781392
}
13791393

1380-
/* drain the remaining in case of exception thrown */
1381-
while (queue) {
1382-
next = queue->next;
1383-
queue->next = PCNTL_G(spares);
1384-
PCNTL_G(spares) = queue;
1385-
queue = next;
1394+
if (old_exception) {
1395+
if (EG(exception)) {
1396+
zend_exception_set_previous(EG(exception), old_exception);
1397+
} else {
1398+
EG(exception) = old_exception;
1399+
}
13861400
}
13871401

1388-
PCNTL_G(pending_signals) = 0;
1402+
if (UNEXPECTED(queue)) {
1403+
/* The signals a throwing handler left behind go back to the queue instead of being
1404+
* dropped, and the engine is asked to dispatch again once that exception is handled.
1405+
* Signals are still blocked here, so PCNTL_G(head) cannot have been repopulated. */
1406+
next = queue;
1407+
1408+
while (next->next) {
1409+
next = next->next;
1410+
}
1411+
1412+
PCNTL_G(head) = queue;
1413+
PCNTL_G(tail) = next;
1414+
1415+
if (PCNTL_G(async_signals)) {
1416+
zend_atomic_bool_store_ex(&EG(vm_interrupt), true);
1417+
}
1418+
} else {
1419+
PCNTL_G(pending_signals) = 0;
1420+
}
13891421

13901422
/* Re-enable queue */
13911423
PCNTL_G(processing_signal_queue) = 0;
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
--TEST--
2+
pcntl_signal_dispatch() keeps the signals left in the queue by a throwing handler
3+
--EXTENSIONS--
4+
pcntl
5+
posix
6+
--FILE--
7+
<?php
8+
9+
$called = [];
10+
11+
pcntl_signal(SIGUSR1, function ($signo) use (&$called) {
12+
$called[] = 'SIGUSR1';
13+
throw new \Exception('Exception in signal handler');
14+
});
15+
16+
pcntl_signal(SIGUSR2, function ($signo) use (&$called) {
17+
$called[] = 'SIGUSR2';
18+
});
19+
20+
pcntl_signal(SIGHUP, function ($signo) use (&$called) {
21+
$called[] = 'SIGHUP';
22+
});
23+
24+
posix_kill(posix_getpid(), SIGUSR1);
25+
posix_kill(posix_getpid(), SIGUSR2);
26+
posix_kill(posix_getpid(), SIGHUP);
27+
28+
try {
29+
pcntl_signal_dispatch();
30+
} catch (\Exception $e) {
31+
echo $e->getMessage() . "\n";
32+
}
33+
34+
echo "Handlers called: " . implode(', ', $called) . "\n";
35+
36+
pcntl_signal_dispatch();
37+
38+
echo "Handlers called: " . implode(', ', $called) . "\n";
39+
40+
?>
41+
--EXPECT--
42+
Exception in signal handler
43+
Handlers called: SIGUSR1
44+
Handlers called: SIGUSR1, SIGUSR2, SIGHUP
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
--TEST--
2+
pcntl_signal_dispatch() delivers the signals a throwing handler left behind once its exception is handled
3+
--EXTENSIONS--
4+
pcntl
5+
posix
6+
--FILE--
7+
<?php
8+
9+
$called = [];
10+
11+
pcntl_signal(SIGUSR1, function ($signo) use (&$called) {
12+
$called[] = 'SIGUSR1';
13+
throw new \Exception('Exception in signal handler');
14+
});
15+
16+
pcntl_signal(SIGUSR2, function ($signo) use (&$called) {
17+
$called[] = 'SIGUSR2';
18+
});
19+
20+
pcntl_signal(SIGHUP, function ($signo) use (&$called) {
21+
$called[] = 'SIGHUP';
22+
});
23+
24+
// Queued, not dispatched: asynchronous signals are off
25+
posix_kill(posix_getpid(), SIGUSR1);
26+
posix_kill(posix_getpid(), SIGUSR2);
27+
28+
pcntl_async_signals(true);
29+
30+
try {
31+
// Delivered asynchronously, so the whole queue is dispatched
32+
posix_kill(posix_getpid(), SIGHUP);
33+
echo "Not reached\n";
34+
} catch (\Exception $e) {
35+
echo $e->getMessage() . "\n";
36+
}
37+
38+
// No explicit dispatch: the engine delivers what the throwing handler left behind
39+
// on its own, as soon as the exception has been handled
40+
usleep(1000);
41+
42+
echo "Handlers called: " . implode(', ', $called) . "\n";
43+
44+
?>
45+
--EXPECT--
46+
Exception in signal handler
47+
Handlers called: SIGUSR1, SIGUSR2, SIGHUP

0 commit comments

Comments
 (0)