Skip to content

fix: consume the async exit exception left in EG at request shutdown#15

Closed
EdmondDantes wants to merge 1 commit into
true-async-stablefrom
deadlock-exit-exception-leak-r-path
Closed

fix: consume the async exit exception left in EG at request shutdown#15
EdmondDantes wants to merge 1 commit into
true-async-stablefrom
deadlock-exit-exception-leak-r-path

Conversation

@EdmondDantes

Copy link
Copy Markdown

The bug

After a deadlock, the scheduler raises a terminal DeadlockError as the exit exception. A debug build then reports it as a leaked object at Zend MM shutdown:

zend_objects.c(190) :  Freeing ... (152 bytes)
=== Total 1 memory leaks detected ===

It reproduces only via php -r, never through file execution, and only after an async deadlock (channel or mutual await, caught or uncaught). valgrind --leak-check=full reports 0 bytes lost — the object is freed at MM shutdown, so it is not a growing leak, but it is a genuine defect: plain php -r 'throw new Exception' does not leak, only the async path does.

Root cause — the entrypoint asymmetry

php_execute_script() (the file path) finalizes the main coroutine and consumes its exception itself:

ZEND_ASYNC_RUN_SCHEDULER_AFTER_MAIN(false);   // finalize main coroutine
...
if (EG(exception)) { zend_exception_error(EG(exception), E_ERROR); }   // consume

The -r endpoint is zend_eval_string_ex(), which does not run the scheduler-after-main. zend_eval_string_ex returns with EG(exception) clean; the main coroutine is only finalized later, during php_request_shutdown()'s own ZEND_ASYNC_RUN_SCHEDULER_AFTER_MAIN (main.c:1990), which rethrows the DeadlockError into EG(exception) — with no caller left to handle it.

Confirmed by a backtrace of where EG(exception) is set in -r mode:

#0 async_apply_exception_to_context        ext/async/exceptions.c:424
#2 fiber_switch_context_ex                 ext/async/scheduler.c:359
#5 async_scheduler_main_coroutine_suspend  ext/async/scheduler.c:1362
#7 php_request_shutdown                     main/main.c:1990   <- after eval already returned
#8 do_cli

The fix

Consume the exception right where it is raised — after ZEND_ASYNC_RUN_SCHEDULER_AFTER_MAIN in php_request_shutdown, mirroring exactly what php_execute_script already does at its own scheduler-after-main:

zend_try {
    ZEND_ASYNC_RUN_SCHEDULER_AFTER_MAIN(false);

    if (UNEXPECTED(EG(exception))) {
        zend_exception_error(EG(exception), E_ERROR);
    }
} zend_end_try();

For the file path this is a no-op: php_execute_script already finalized the coroutine and consumed the exception, so EG(exception) is clean by the time shutdown's scheduler pass runs. The guard only fires on the deferred (-r/eval/stdin) path.

Verified

before after
-r deadlock — leak 1 0
-r deadlock — prints Uncaught DeadlockError no yes, once
-r deadlock — exit code 255 255
file deadlock — prints (not doubled) 1 1
file deadlock — leak 0 0

All four deadlock shapes (channel/mutual-await × caught/uncaught) via -r: 0 leaks. The -r path now behaves identically to the file path — it prints the Uncaught DeadlockError fatal and frees the object.

ext/async/tests and ext/async/fuzzy-tests/_generated show no new failures; Zend/tests unaffected (the one failure there, arginfo_zpp_mismatch, crashes identically on baseline without this change).

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.
@EdmondDantes
EdmondDantes requested a review from bukka as a code owner July 15, 2026 09:05
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.

1 participant