From de2e83ec7c704b64444665267594f20689dbb296 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Tue, 11 Aug 2026 10:53:55 +0000 Subject: [PATCH 1/3] #219: dispose the Thread event from free_obj as well php_error_cb() marks every live object as destructed before it bails out, so dtor_obj runs for none of them and teardown calls only free_obj. thread_object_dtor() was the sole caller of event->dispose(), and that dispose is the sole uv_close() of the cross-thread notify handle. After any fatal error the handle stayed open: uv_loop_close() returned EBUSY, and the loop's internals plus the persistent thread context leaked. free_obj now disposes the event too. Dispatching finally handlers stays in the dtor, since user code must not run on the fatal path. --- CHANGELOG.md | 6 +++ ...1-thread_fatal_disposes_notify_handle.phpt | 44 +++++++++++++++++++ thread.c | 20 +++++++++ 3 files changed, 70 insertions(+) create mode 100644 tests/thread/081-thread_fatal_disposes_notify_handle.phpt diff --git a/CHANGELOG.md b/CHANGELOG.md index 326a90c..c826749 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to the Async extension for PHP will be documented in this fi The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- **A fatal error left every live `Async\Thread` holding an open libuv handle.** Before it bails out, the engine marks all objects as destructed, so `dtor_obj` never runs — and `Thread` released its event only from there. The cross-thread notify handle therefore survived reactor shutdown: `uv_loop_close()` returned `EBUSY`, the loop's internals and the persistent thread context leaked, and a debug build printed one "leftover libuv handle" line per thread. The release now also runs from `free_obj`, which the engine always calls. + ## [0.9.0] - 2026-08-06 ### Changed diff --git a/tests/thread/081-thread_fatal_disposes_notify_handle.phpt b/tests/thread/081-thread_fatal_disposes_notify_handle.phpt new file mode 100644 index 0000000..6a63eb5 --- /dev/null +++ b/tests/thread/081-thread_fatal_disposes_notify_handle.phpt @@ -0,0 +1,44 @@ +--TEST-- +Thread: a fatal error disposes live Thread events (no leftover libuv notify handles) +--SKIPIF-- + +--INI-- +memory_limit=64M +--FILE-- + bin2hex(random_bytes(8))); +} + +await_all($threads); +echo "threads joined\n"; + +// $threads stays live: the Thread objects are still reachable at bailout. +$data = []; + +while (true) { + $data[] = str_repeat('A', 1024 * 1024); +} +--EXPECTF-- +threads joined + +Fatal error: Allowed memory size of %d bytes exhausted%sin %s on line %d diff --git a/thread.c b/thread.c index 379a6fa..65b7bb5 100644 --- a/thread.c +++ b/thread.c @@ -3033,6 +3033,26 @@ static void thread_object_free(zend_object *object) { async_thread_object_t *thread = async_thread_object_from_obj(object); + /* A fatal error skips dtor_obj entirely: php_error_cb marks every live + * object as destructed before it bails out. The event would then keep its + * libuv notify handle open past reactor shutdown (uv_loop_close => EBUSY) + * and leak the persistent thread context, so dispose it here as well. + * Dispatching finally handlers stays in the dtor — running user code on + * the fatal path is not safe; here the array is only released. */ + if (thread->thread_event != NULL) { + zend_async_event_t *event = &thread->thread_event->base; + thread->thread_event = NULL; + + if (event->dispose != NULL) { + event->dispose(event); + } + } + + if (thread->finally_handlers != NULL) { + zend_array_destroy(thread->finally_handlers); + thread->finally_handlers = NULL; + } + /* Release the scope ref captured at spawn time. This runs after the * dtor has finished (and after any delayed finally-handlers kept the * Thread alive via GC_ADDREF), so the scope outlives every handler From 3c9ecf857fc02253304fcc38bd6615bee06eedc1 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Tue, 11 Aug 2026 11:10:09 +0000 Subject: [PATCH 2/3] #219: cut the comments down to what is not obvious from the code --- CHANGELOG.md | 2 +- ...081-thread_fatal_disposes_notify_handle.phpt | 17 ++++++++--------- thread.c | 11 +++++------ 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c826749..f87544e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- **A fatal error left every live `Async\Thread` holding an open libuv handle.** Before it bails out, the engine marks all objects as destructed, so `dtor_obj` never runs — and `Thread` released its event only from there. The cross-thread notify handle therefore survived reactor shutdown: `uv_loop_close()` returned `EBUSY`, the loop's internals and the persistent thread context leaked, and a debug build printed one "leftover libuv handle" line per thread. The release now also runs from `free_obj`, which the engine always calls. +- **A fatal error left every live `Async\Thread` holding an open libuv handle.** `Thread` disposed its event from `dtor_obj` alone, and the engine marks all objects as destructed before it bails out, so a fatal error skipped that dispose entirely. The cross-thread notify handle then survived reactor shutdown: `uv_loop_close()` returned `EBUSY`, the loop's internals and the persistent thread context leaked, and a debug build printed one "leftover libuv handle" line per thread. The dispose now also runs from `free_obj`, which the engine always calls. ## [0.9.0] - 2026-08-06 diff --git a/tests/thread/081-thread_fatal_disposes_notify_handle.phpt b/tests/thread/081-thread_fatal_disposes_notify_handle.phpt index 6a63eb5..9298f51 100644 --- a/tests/thread/081-thread_fatal_disposes_notify_handle.phpt +++ b/tests/thread/081-thread_fatal_disposes_notify_handle.phpt @@ -10,15 +10,14 @@ memory_limit=64M --FILE-- EBUSY) - * and leak the persistent thread context, so dispose it here as well. - * Dispatching finally handlers stays in the dtor — running user code on - * the fatal path is not safe; here the array is only released. */ + /* php_error_cb() marks every object as destructed before a fatal bails out, + * so dtor_obj never runs and free_obj is the last chance to dispose the + * event. An undisposed one keeps its notify handle open, which fails + * uv_loop_close() at reactor shutdown and leaks the thread context. */ if (thread->thread_event != NULL) { zend_async_event_t *event = &thread->thread_event->base; thread->thread_event = NULL; @@ -3048,6 +3046,7 @@ static void thread_object_free(zend_object *object) } } + /* Only the dtor dispatches the handlers: they are user code. */ if (thread->finally_handlers != NULL) { zend_array_destroy(thread->finally_handlers); thread->finally_handlers = NULL; From 205c9b19333458d4fcbb878ee25b5d3159c1fab8 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Tue, 11 Aug 2026 11:22:53 +0000 Subject: [PATCH 3/3] #219: lead the free_obj comment with its conclusion --- thread.c | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/thread.c b/thread.c index f2d737d..4887dd8 100644 --- a/thread.c +++ b/thread.c @@ -3033,9 +3033,9 @@ static void thread_object_free(zend_object *object) { async_thread_object_t *thread = async_thread_object_from_obj(object); - /* php_error_cb() marks every object as destructed before a fatal bails out, - * so dtor_obj never runs and free_obj is the last chance to dispose the - * event. An undisposed one keeps its notify handle open, which fails + /* free_obj must dispose the event too: php_error_cb() marks every object + * as destructed before a fatal bails out, so dtor_obj does not run at all. + * An event left undisposed keeps its notify handle open, which fails * uv_loop_close() at reactor shutdown and leaks the thread context. */ if (thread->thread_event != NULL) { zend_async_event_t *event = &thread->thread_event->base; @@ -3046,7 +3046,6 @@ static void thread_object_free(zend_object *object) } } - /* Only the dtor dispatches the handlers: they are user code. */ if (thread->finally_handlers != NULL) { zend_array_destroy(thread->finally_handlers); thread->finally_handlers = NULL;