[php-src] PR #23624: ext/pcntl: do not drop queued signals when an exception is pending
| From: | nicolas-grekas | Date: | Wed, 09 Sep 2026 06:00:42 +0000 |
| Subject: | [php-src] PR #23624: ext/pcntl: do not drop queued signals when an exception is pending | ||
| Groups: | php.git-pulls | ||
| Request: | Send a blank email to git-pulls+get-38398@lists.php.net to get a copy of this message | ||
Pull Request: https://github.com/php/php-src/pull/23624
Author: nicolas-grekas
pcntl_signal_dispatch() detaches the whole queue from PCNTL_G(head) before
it starts calling handlers, and recycles every entry it walks over. Two paths let queued signals
disappear.
### 1. Dispatching while an exception is pending
[ZEND_VM_FCALL_INTERRUPT_CHECK()](https://github.com/php/php-src/blob/PHP-8.4/Zend/zend_vm_def.h#L4088)
runs right after an internal function returns, *before* the pending exception is handled. So when an
internal function throws, pcntl_interrupt_function() reaches the dispatcher with
EG(exception) set. There, [zend_call_function() returns without calling
anything](https://github.com/php/php-src/blob/PHP-8.4/Zend/zend_execute_API.c#L826), the if
(EG(exception)) break; added by 296fad10fb4 fires on the very first entry, and the /*
drain the remaining */ loop recycles the entire queue. No handler ever runs, and nothing is
left for a later pcntl_signal_dispatch() either.
Any signal that arrives while such a function is running is therefore lost, not delayed. The fix
sets the exception aside for the duration of the dispatch, the way
zend_lookup_class_ex() already does around autoloading.
### 2. Signals queued behind a throwing handler
If a handler throws, the signals queued behind it were recycled as well. They now go back to the
queue so the next dispatch delivers them. The existing behaviour of not calling further handlers
while the exception propagates is unchanged, and pcntl_signal_dispatch_exception.phpt
still passes as is.
### How I ran into it
Any long blocking internal call that throws on timeout hits case 1. pecl/amqp is one:
AMQPQueue::consume() throws AMQPQueueException("Consumer timeout
exceed") when the connection read timeout expires. A Symfony Messenger worker built on
it (symfony/symfony#65920) misses **every** SIGTERM, whatever the timeout is, because
the worker spends essentially all of its wall time inside that call:
```
read_timeout=0.20s, SIGTERM at 1.70s: 31 rounds, never stopped
read_timeout=0.05s, SIGTERM at 2.13s: 121 rounds, never stopped
read_timeout=0.01s, SIGTERM at 2.57s: 653 rounds, never stopped
```
653 iterations of a 10 ms call and the signal is never delivered, not even by an explicit
pcntl_signal_dispatch() afterwards. SigBlk, SigIgn and
SigCgt read from inside the extension's callback confirm the signal is neither
blocked nor ignored and that the handler is installed, and the same signal during
sleep(), stream_select(), stream_socket_accept() or
socket_read() is delivered normally. What makes consume() different is
only that it *throws*: when a message arrives and it returns normally instead, the signal is
delivered.
With this patch, same build, same test, no userland workaround:
```
read_timeout=0.20s, SIGTERM at 1.70s: stopped, latency 0.094s
read_timeout=0.20s, SIGTERM at 2.31s: stopped, latency 0.083s
read_timeout=0.20s, SIGTERM at 3.05s: stopped, latency 0.140s
```
I verified this against pecl/amqp 2.2.0 built on this branch, with and without the patch. Case 2 is
covered by the added .phpt; case 1 needs an internal function that blocks long enough
for a signal to arrive and then throws, which I could not build out of core functions alone, since
re-entering the VM for any userland callback dispatches the signal first.
ext/pcntl, Zend/tests and ext/standard/tests are green (8180
passed, 0 failed).