Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 43 additions & 0 deletions tests/thread/081-thread_fatal_disposes_notify_handle.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
--TEST--
Thread: a fatal error disposes live Thread events (no leftover libuv notify handles)
--SKIPIF--
<?php
if (!PHP_ZTS) die('skip ZTS required');
if (!function_exists('Async\spawn_thread')) die('skip spawn_thread not available');
?>
--INI--
memory_limit=64M
--FILE--
<?php
/*
* A fatal error bails out without dtor_obj, so a Thread still held by a
* variable must release its uv_async notify handle from free_obj. An open
* handle fails uv_loop_close() and leaks the persistent thread context.
*
* A debug build prints one "leftover libuv handle" line per survivor on
* stderr, and run-tests compares stderr together with stdout, so the leak
* appears as extra lines after the fatal error. No trailing %A for that
* reason: it would match them.
*/
use function Async\spawn_thread;
use function Async\await_all;

$threads = [];

for ($i = 0; $i < 4; $i++) {
$threads[] = spawn_thread(static fn() => 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
18 changes: 18 additions & 0 deletions thread.c
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading