Skip to content

fix: destroy persistent resources before a PHP thread exits - #2812

Merged
dunglas merged 1 commit into
php:mainfrom
andreasisaak:fix/destroy-persistent-list-on-thread-exit
Nov 5, 2026
Merged

dunglas merged 1 commit into
php:mainfrom
andreasisaak:fix/destroy-persistent-list-on-thread-exit

Conversation

@andreasisaak

Copy link
Copy Markdown
Contributor

I hit this in a Laravel Octane app with watch enabled: after about 62 reboots PostgreSQL refused new connections (remaining connection slots are reserved for roles with the SUPERUSER attribute). The app uses PDO::ATTR_PERSISTENT, and every reboot left one idle connection per worker thread behind. 8 workers, max_connections 500, so that adds up quickly.

The cause is php/php-src#13381 (PHP 8.4). Since then ts_free_thread() doesn't destroy the thread's persistent list anymore, zend_shutdown() does it for the threads that are still registered in TSRM. FrankenPHP threads call ts_free_thread() when they exit, so their lists never get destroyed. That happens on every thread restart: the reboot (watcher, RestartWorkers(), admin API, probably also the OPcache restart from #2568, I didn't test that one) and max_requests.

PHP 8.2 and 8.3 have a different problem in the same spot. ts_free_thread() still destroys the list there, but with a pfsockopen() socket the reboot aborts with tcache_thread_shutdown(): unaligned tcache chunk detected. I only found that because the new test crashed on 8.3.

The fix destroys the persistent list right before ts_free_thread(), while everything is still in place. That's basically what php-src master does now with zend_thread_free_handler (php/php-src#24428), but it isn't in any release branch, so the patch is active for everything below 8.7. zend_destroy_rsrc_list() and zend_init_rsrc_plist() aren't ZEND_API, so I call the hash functions they wrap. Re-initializing the list afterwards keeps the second destroy on 8.2/8.3 harmless, and would do the same if the master change ever gets backported.

The tests open a socket with pfsockopen() and count how many get closed after RestartWorkers() and after max_requests. Without the fix they fail on 8.4 and 8.5 (no socket gets closed) and abort on 8.2 and 8.3. With the fix they pass on all four. The whole root package passes on 8.2, 8.3 and 8.5, the caddy module on 8.5 (-race, official 1.13.4 builder images). There's no 8.6 builder image yet, so I couldn't try that one.

I also checked it with the real thing: 4 workers with one persistent pgsql connection each, three reboots through the watcher. The official 1.13.4 image goes from 4 to 16 connections, with this patch it stays at 4. Same with max_requests (4 → 18 without, 4 with). Workers that just return and start again in the same thread still reuse their connection.

@dunglas
dunglas merged commit 6d37a8c into php:main Nov 5, 2026
27 of 28 checks passed
@dunglas

dunglas commented Nov 5, 2026

Copy link
Copy Markdown
Member

LGTM, thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants