From 4538c0619d912c2659a58b94bba0e2604e94d793 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Wed, 15 Jul 2026 08:59:51 +0000 Subject: [PATCH] fix: consume the async exit exception left in EG at request shutdown After a deadlock the scheduler raises a terminal DeadlockError as the exit exception. When the entrypoint drives the main coroutine to completion itself (php_execute_script, the normal file path), that function's own post-run check reports and frees it. But `php -r` returns from zend_eval_string with EG(exception) clean; the main coroutine is only finalized later, during php_request_shutdown's final scheduler pass, which rethrows the DeadlockError into EG(exception) with no caller left to handle it. In a debug build the Zend MM shutdown then reports it as a leaked object ("Freeing ... 152 bytes"). Consume it right where it is raised: after ZEND_ASYNC_RUN_SCHEDULER_AFTER_MAIN, if an exception is pending, report and release it exactly as php_execute_script does. The `php -r` deadlock now prints the "Uncaught DeadlockError" fatal like the file path, and the object is freed. The guard is a no-op for every request that reaches shutdown with EG clean. --- main/main.c | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/main/main.c b/main/main.c index ad3c270a8d51..0eb577c54b8b 100644 --- a/main/main.c +++ b/main/main.c @@ -1988,6 +1988,14 @@ void php_request_shutdown(void *dummy) // control is passed to the coroutines one last time (if any remain). zend_try { ZEND_ASYNC_RUN_SCHEDULER_AFTER_MAIN(false); + + // The final scheduler pass can finalize the main coroutine here and rethrow its exit + // exception into EG(exception) -- this happens when the entrypoint (e.g. `php -r`) did not + // drive the coroutine to completion itself. With no caller left to handle it, report and + // release it, exactly as php_execute_script() does for the in-script path. + if (UNEXPECTED(EG(exception))) { + zend_exception_error(EG(exception), E_ERROR); + } } zend_end_try(); // Detach async IO from all streams (they become sync-only).