fix(coroutine): honour an exception claimed from extended_dispose - #229
Merged
Conversation
EdmondDantes
force-pushed
the
fix/thread-channel-cancellation-token
branch
from
August 13, 2026 16:44
1a5116b to
3de312a
Compare
EdmondDantes
force-pushed
the
fix/pool-task-exception-kills-worker
branch
from
August 13, 2026 16:44
5c0732a to
0f11b75
Compare
EdmondDantes
force-pushed
the
fix/thread-channel-cancellation-token
branch
from
August 13, 2026 16:45
3de312a to
37f8bb9
Compare
EdmondDantes
force-pushed
the
fix/pool-task-exception-kills-worker
branch
from
August 13, 2026 16:45
0f11b75 to
cae984e
Compare
EdmondDantes
force-pushed
the
fix/thread-channel-cancellation-token
branch
from
August 13, 2026 18:02
37f8bb9 to
4ab6e98
Compare
EdmondDantes
force-pushed
the
fix/pool-task-exception-kills-worker
branch
from
August 13, 2026 18:02
cae984e to
52c6fda
Compare
EdmondDantes
changed the base branch from
fix/thread-channel-cancellation-token
to
main
August 13, 2026 18:26
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #228.
A
ThreadPool(coroutine: true)task that throws destroys the OS worker thread that ran it, and the pool keeps reporting that worker as alive. Withworkers: 2: two distinct workers before, one after the first throw, none after the second — whilegetWorkerCount()answers 2 andisClosed()answers false throughout. With no workers leftsubmit()still accepts and the Futures never settle, so the process hangs with nothing in the log that names the cause.Cause
Two flags carry near-identical documentation in
zend_async_API.h:EXC_CAUGHT(bit 2) andEXCEPTION_HANDLED(bit 6).pool_task_dispose()claims the exception exactly as the API prescribes — it rejects the task's Future and callsZEND_COROUTINE_SET_EXCEPTION_HANDLED(thread_pool.c:765-767).The claim is dropped. The sole bit-6-to-bit-2 conversion is at
coroutine.c:670-672, immediately afterZEND_ASYNC_CALLBACKS_NOTIFY, andextended_disposeruns atcoroutine.c:687-691— nineteen lines later, after the window has closed.coroutine_object_destroy()gates only on bit 2 (coroutine.c:224), finds no claim and rethrows into the worker'sEG(exception). The scheduler'sTRY_HANDLE_EXCEPTIONat the top of its loop then callsstart_graceful_shutdown(), which cancels every coroutine registered on that thread, including the worker's own. The worker leaves its receive loop, the handler returns, andphp_request_shutdown()reports the stray exception. TheFatal errorline is that shutdown report, not the trigger — there is no bailout anywhere on the path.Sync-mode pools are unaffected: there the claim is made by
zend_async_waker_callback_resolve, which runs inside the notify window.The change
The conversion is repeated after
extended_dispose, so a claim made from that callback counts as well.The blast radius is small and checkable:
ZEND_COROUTINE_SET_EXCEPTION_HANDLEDis called on a coroutine in exactly one place in the extension (thread_pool.c:767), and bit 2 is read in exactly one place (coroutine.c:224). Of the fiveextended_disposeregistrants only the pool claims an exception, and it does not want the destructor's rethrow.Tests
tests/thread_pool/081-coroutine_task_exception_worker_survives.phptasserts the property that was missing: not that the Future rejects — that always worked — but that the thread which ran the failing task takes the next one. It fails on the base branch and passes with the fix.The whole suite: 2109 tests, 1857 passed, 248 skipped, 3 failed — the same three pre-existing failures in
tests/curlandtests/io, which fail identically without any of this branch's changes.Left for a separate issue
The pool has no liveness tracking at all:
base.worker_countis written only inthread_pool_createand never decremented, andbase.workers[]is written and never read. So a worker death cannot be observed by any caller — andreload()sizes its exit-token cohort from that same stale count, which is why it hangs once a worker has gone.