diff --git a/CHANGELOG.md b/CHANGELOG.md index 326a90c..f87544e 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.** `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 ### 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..9298f51 --- /dev/null +++ b/tests/thread/081-thread_fatal_disposes_notify_handle.phpt @@ -0,0 +1,43 @@ +--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 is never unset: the Thread objects must be 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..4887dd8 100644 --- a/thread.c +++ b/thread.c @@ -3033,6 +3033,24 @@ static void thread_object_free(zend_object *object) { async_thread_object_t *thread = async_thread_object_from_obj(object); + /* 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; + 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