[php-src] PR #23624: ext/pcntl: do not drop queued signals when an exception is pending

From: 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).

« previous php.git-pulls (#38398) next »