From 3671b3dab073f783d946b5b5154c128cc4fae142 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Sun, 13 Sep 2026 07:54:10 +0200 Subject: [PATCH 1/2] ext/pcntl: run signal handlers when dispatch happens with an exception pending ZEND_DO_FCALL runs its interrupt check 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 296fad10fb4 fires on the first entry, and the drain loop then recycles the whole queue without a single handler having run. The signal is destroyed rather than delayed: a later pcntl_signal_dispatch() finds nothing left. Set the exception aside while the handlers run and chain it back afterwards, the way zend_objects_destroy_object() does for destructors called during unwinding. EG(opline_before_exception) is saved along with it, since ZEND_HANDLE_EXCEPTION derives the throwing op, and from it the enclosing try block, out of it. Any long blocking internal call that throws on timeout reaches this. pecl/amqp throws "Consumer timeout exceed" out of AMQPQueue::consume(), which makes a Symfony messenger worker miss every SIGTERM whatever the timeout is. PDO/SQLite throws "database is locked" once busy_timeout expires, which kills a keepalive SIGALRM for the rest of the process's life. --- NEWS | 4 ++++ ext/pcntl/pcntl.c | 30 ++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/NEWS b/NEWS index c367688672c9..2ce1207f3d4b 100644 --- a/NEWS +++ b/NEWS @@ -15,6 +15,10 @@ PHP NEWS . Fixed bug GH-23106 (mb_strpos() reads past the end of a haystack ending in a truncated UTF-8 sequence). (Lazizbek Ergashev) +- PCNTL: + . Fixed pcntl_signal_dispatch() dropping the queued signals when it runs while + an exception is pending. (nicolas-grekas) + - Zip: . Fixed ZipArchive::extractTo() ignoring files given in a non-list array. (David Carlier) diff --git a/ext/pcntl/pcntl.c b/ext/pcntl/pcntl.c index 082bdc4ba90e..c0a66de04207 100644 --- a/ext/pcntl/pcntl.c +++ b/ext/pcntl/pcntl.c @@ -31,6 +31,7 @@ #include "ext/standard/info.h" #include "php_signal.h" #include "php_ticks.h" +#include "zend_exceptions.h" #include "zend_fibers.h" #if defined(HAVE_GETPRIORITY) || defined(HAVE_SETPRIORITY) || defined(HAVE_WAIT3) @@ -1318,6 +1319,8 @@ void pcntl_signal_dispatch(void) { zval params[2], *handle, retval; struct php_pcntl_pending_signal *queue, *next; + zend_object *old_exception; + const zend_op *old_opline_before_exception = NULL; sigset_t mask; sigset_t old_mask; @@ -1345,6 +1348,21 @@ void pcntl_signal_dispatch(void) PCNTL_G(head) = NULL; /* simple stores are atomic */ PCNTL_G(tail) = NULL; + /* Dispatching can happen while an exception is propagating, typically from the interrupt + * check ZEND_DO_FCALL runs right after an internal function returned with an exception + * pending. call_user_function() does nothing in that state, so set the exception aside + * while the handlers run, the way destructors are called during unwinding. Restoring + * EG(opline_before_exception) matters: ZEND_HANDLE_EXCEPTION derives the throwing op, and + * from it the enclosing try block, out of that pointer. */ + old_exception = EG(exception); + if (old_exception) { + if (EG(current_execute_data)) { + EG(current_execute_data)->opline = EG(opline_before_exception); + old_opline_before_exception = EG(opline_before_exception); + } + EG(exception) = NULL; + } + /* Allocate */ while (queue) { if ((handle = zend_hash_index_find(&PCNTL_G(php_signal_table), queue->signo)) != NULL) { @@ -1385,6 +1403,18 @@ void pcntl_signal_dispatch(void) queue = next; } + if (old_exception) { + if (EG(current_execute_data)) { + EG(current_execute_data)->opline = EG(exception_op); + EG(opline_before_exception) = old_opline_before_exception; + } + if (EG(exception)) { + zend_exception_set_previous(EG(exception), old_exception); + } else { + EG(exception) = old_exception; + } + } + PCNTL_G(pending_signals) = 0; /* Re-enable queue */ From f249e7c8f89022a12b1d99be3e015d63538f2c9a Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Sun, 13 Sep 2026 07:54:24 +0200 Subject: [PATCH 2/2] ext/pcntl: keep the signals a throwing handler left in the queue When a handler threw, the signals queued behind it were recycled without ever being delivered. Put them back on the queue instead, and re-arm the interrupt so that the engine dispatches them once the exception has been handled, rather than leaving them to wait for another signal to come in. Not calling further handlers while the exception propagates is unchanged. --- NEWS | 2 + ext/pcntl/pcntl.c | 37 ++++++++++----- .../pcntl_signal_dispatch_exception_2.phpt | 44 +++++++++++++++++ .../pcntl_signal_dispatch_exception_3.phpt | 47 +++++++++++++++++++ 4 files changed, 119 insertions(+), 11 deletions(-) create mode 100644 ext/pcntl/tests/pcntl_signal_dispatch_exception_2.phpt create mode 100644 ext/pcntl/tests/pcntl_signal_dispatch_exception_3.phpt diff --git a/NEWS b/NEWS index 2ce1207f3d4b..33c14039c103 100644 --- a/NEWS +++ b/NEWS @@ -18,6 +18,8 @@ PHP NEWS - PCNTL: . Fixed pcntl_signal_dispatch() dropping the queued signals when it runs while an exception is pending. (nicolas-grekas) + . Fixed pcntl_signal_dispatch() dropping the signals queued behind a handler + that throws. (nicolas-grekas) - Zip: . Fixed ZipArchive::extractTo() ignoring files given in a non-list array. diff --git a/ext/pcntl/pcntl.c b/ext/pcntl/pcntl.c index c0a66de04207..98cdd967915a 100644 --- a/ext/pcntl/pcntl.c +++ b/ext/pcntl/pcntl.c @@ -1365,6 +1365,8 @@ void pcntl_signal_dispatch(void) /* Allocate */ while (queue) { + bool handler_threw = false; + if ((handle = zend_hash_index_find(&PCNTL_G(php_signal_table), queue->signo)) != NULL) { if (Z_TYPE_P(handle) != IS_LONG) { ZVAL_NULL(&retval); @@ -1383,9 +1385,7 @@ void pcntl_signal_dispatch(void) #ifdef HAVE_STRUCT_SIGINFO_T zval_ptr_dtor(¶ms[1]); #endif - if (EG(exception)) { - break; - } + handler_threw = NULL != EG(exception); } } @@ -1393,14 +1393,11 @@ void pcntl_signal_dispatch(void) queue->next = PCNTL_G(spares); PCNTL_G(spares) = queue; queue = next; - } - /* drain the remaining in case of exception thrown */ - while (queue) { - next = queue->next; - queue->next = PCNTL_G(spares); - PCNTL_G(spares) = queue; - queue = next; + /* No other handler can be called while the exception propagates */ + if (handler_threw) { + break; + } } if (old_exception) { @@ -1415,7 +1412,25 @@ void pcntl_signal_dispatch(void) } } - PCNTL_G(pending_signals) = 0; + if (UNEXPECTED(queue)) { + /* Put back what the throwing handler did not get to, instead of dropping it, and ask + * the engine to come back once the exception has been handled. Signals are still + * blocked here, so PCNTL_G(head) cannot have been repopulated in the meantime. */ + next = queue; + + while (next->next) { + next = next->next; + } + + PCNTL_G(head) = queue; + PCNTL_G(tail) = next; + + if (PCNTL_G(async_signals)) { + zend_atomic_bool_store_ex(&EG(vm_interrupt), true); + } + } else { + PCNTL_G(pending_signals) = 0; + } /* Re-enable queue */ PCNTL_G(processing_signal_queue) = 0; diff --git a/ext/pcntl/tests/pcntl_signal_dispatch_exception_2.phpt b/ext/pcntl/tests/pcntl_signal_dispatch_exception_2.phpt new file mode 100644 index 000000000000..ebd868df5d4b --- /dev/null +++ b/ext/pcntl/tests/pcntl_signal_dispatch_exception_2.phpt @@ -0,0 +1,44 @@ +--TEST-- +pcntl_signal_dispatch() keeps the signals left in the queue by a throwing handler +--EXTENSIONS-- +pcntl +posix +--FILE-- +getMessage() . "\n"; +} + +echo "Handlers called: " . implode(', ', $called) . "\n"; + +pcntl_signal_dispatch(); + +echo "Handlers called: " . implode(', ', $called) . "\n"; + +?> +--EXPECT-- +Exception in signal handler +Handlers called: SIGUSR1 +Handlers called: SIGUSR1, SIGUSR2, SIGHUP diff --git a/ext/pcntl/tests/pcntl_signal_dispatch_exception_3.phpt b/ext/pcntl/tests/pcntl_signal_dispatch_exception_3.phpt new file mode 100644 index 000000000000..ff877e3c0400 --- /dev/null +++ b/ext/pcntl/tests/pcntl_signal_dispatch_exception_3.phpt @@ -0,0 +1,47 @@ +--TEST-- +pcntl_signal_dispatch() delivers the signals a throwing handler left behind once its exception is handled +--EXTENSIONS-- +pcntl +posix +--FILE-- +getMessage() . "\n"; +} + +// No explicit dispatch: the engine delivers what the throwing handler left behind +// on its own, as soon as the exception has been handled +usleep(1000); + +echo "Handlers called: " . implode(', ', $called) . "\n"; + +?> +--EXPECT-- +Exception in signal handler +Handlers called: SIGUSR1, SIGUSR2, SIGHUP