Clear, drain and bound the pending-exception discard - #364
Merged
diolektor merged 1 commit intoSep 19, 2026
Merged
Conversation
Fix: - Discard a pending exception by clearing `EG(exception)` before releasing the object, not after. The engine refuses to run a destructor on the object it is still holding as the pending exception and reports the refusal as a core error, so in worker mode an uncaught exception whose class declares `__destruct` never ran that destructor, and the request — already answered with `500` — was filed as a worker that had come apart rather than an application that threw. Measured against a throwing endpoint: 31 such requests retired 8 workers, against the documented rule that an uncaught exception does not count toward recycling. The fatal also flagged every object alive on the worker as already destructed, and left the exception and its stack trace allocated until the worker retired, because the object store takes its own reference before the refusal and the catch arm's discard only gives that one back. - Keep draining until the slot is empty rather than clearing once. The destructor now actually runs, which makes it user code that can throw, and `zend_call_function` returns without calling anything while an exception is pending — one throwing destructor would have skipped every shutdown function the application registered, in order, with nothing reported. In the reset a worker runs between requests the cost is larger still: nothing else clears the slot before the next request, which would then be blamed for an exception it never raised. - Give that drain a ceiling, past which the last exception is dropped with its destructor refused and a line written to the log. Two classes whose destructors throw each other free one object and allocate one per turn, so no memory limit arrives, and the new throw does not chain onto the old one because the slot is already empty when it is raised. The execution deadline does not stand in for the ceiling where it matters: the between-request reset runs after the ini rollback, which disarms the timer and deliberately does not arm it again, and a streaming script is told to call `set_time_limit(0)`. Measured on a build without the ceiling: the request holds its worker thread until the client stops waiting — 15 seconds under the test runner — against 0.000 seconds with it. - Guard the discards that sit in a `zend_catch` arm with a `zend_try` of their own, and block fiber switching around them. A destructor that fatals there would leave through the bailout target the arm has already handed back and take the recovery with it, and one that suspends would park the fiber while `CG(unclean_shutdown)` and `gc_protect` are still raised, handing the thread to another request wearing those flags. The arms are not unreachable with a live destructor: the cancellation check on the output path bails out with no error reported at all, so nothing flags the objects on the way. - Lower `CG(unclean_shutdown)` on both sides of the discard in the fiber release guard. Before, because a generator the destructor reaches skips its `finally` blocks and closes the short way while the flag is up. After, because the bailout this guard swallows raises the flag again and no recovery follows this one — every remaining fiber in the scheduler's teardown walk would be released in that state. - Put the exception read behind a decorator or a trace in the same order, spelled out rather than through `zend_clear_exception()`, which would also discard the caller's saved exception and move its instruction pointer, and give it the same `zend_catch` its trace call has. That function has already taken the caller's exception out of the slot, so a fatal from a destructor would lose it along with the only reference to it, and leak both out-parameter buffers. Tests: - Three pairs of worker-mode probes, each talking through a file so they hold at any pool size: the destructor of an uncaught exception runs, a shutdown function still runs after a destructor threw on its way out, and a request that throws out of mutually throwing destructors still ends by itself. The third lifts its own execution deadline, because otherwise the deadline is what ends the spin, and asserts how long the request took rather than that it finished — without the ceiling it finishes too, when the client gives up. 64 worker-profile tests.
diolektor
deleted the
fix/pending-exception-released-before-its-slot-is-cleared
branch
September 19, 2026 19:05
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.
What was wrong
Seven places in the extension discarded a pending exception like this:
The release happens while
EG(exception)still names the object being released. The engine forbids exactly that:zend_objects_destroy_object()opens withif (EG(exception) == object) zend_error_noreturn(E_CORE_ERROR, "Attempt to destruct pending exception"). Its own discard,zend_clear_exception(), does the opposite and says why in a comment:/* exception may have destructor */.The price is not theoretical, and it is paid at the most reachable of the seven — the uncaught exception of a worker-mode handler. For an exception class declaring
__destruct(one handing back a lock, removing a temp file, closing a handle on the way out):500but was filed as a worker that came apart rather than an application that threw. Measured against a throwing endpoint: 31 such requests retired 8 workers, against the documented rule that an uncaught exception is answered and stays neutral to recycling;free_objwas never called. One exception plus its stack trace per request that threw, for the life of the worker.What changed
zend_clear_exception(), collected into one function rather than repeated at each site.zend_call_function()returnsSUCCESSwithout calling anything while an exception is pending — a single throwing destructor would silently cancel everyregister_shutdown_function()of the request. In the reset a worker runs between requests it is worse: nothing clears the slot before the next request, which is then blamed for an exception it never raised.set_time_limit(0). Past the ceiling the last object is dropped withIS_OBJ_DESTRUCTOR_CALLEDset and a line written to the log.zend_catcharms are guarded with azend_tryof their own and with fiber switching blocked. A destructor that fatals there would leave through the bailout target the arm has already handed back; one that suspends would park the fiber whileCG(unclean_shutdown)andgc_protectare still raised and hand the thread to another request wearing those flags. These arms are not unreachable with a live destructor: the cancellation check on the output path raises a barezend_bailout()with no error reported, so nothing is flagged on the way.CG(unclean_shutdown)is lowered on both sides of the discard in the fiber release guard — before, because a generator the destructor reaches skips itsfinallyblocks and closes the short way while the flag is up; after, because the bailout this guard swallows raises it again and no recovery follows this one, so the rest of the scheduler's teardown walk would run in that state.zend_clear_exception()(which would also discard the caller's saved exception and move its instruction pointer), and gains thezend_catchits trace call already had: the function has taken the caller's exception out of the slot by then, so a fatal would lose it with its only reference and leak both out-parameter buffers.Evidence
Three pairs of worker-mode probes, each talking through a file so they hold at any pool size. Every pair asserts the mechanism, not the status —
500comes back on a broken build too.Attempt to destruct pending exceptionper requestactual: 0), and the request's own duration reads15.003against a 2s boundThe third pair lifts its own deadline with
set_time_limit(0)and asserts how long the request took rather than that it finished, because an unbounded drain finishes too — measured on the mutant, the spin ends atmax_execution_timewith a504when the deadline is in force, and only when the client stops waiting when it is not. The ceiling is observable rather than inferred: on this branch the same request answers500in0.000sand logs one line saying the last exception was dropped without running its destructor.After the fix, the same throwing endpoint leaves
oxphp_worker_recycles_total 0(noreason="error"series at all), no core errors in the log, and the destructor marker in place.Verification
The extension sources are compiled by the Dockerfile rather than by cargo, so the worker-profile runs above are what covers this change; every image was built with
DOCKER_BUILDKIT=0 docker build --no-cacheand checked for freshness by a string the fix introduces.Notes for the reviewer
zend_try: the tail afterzend_exception_error()and the first step of the between-request reset. The hole they leave got strictly smaller with this change — before it, any exception with a__destructescaped there; now only one whose destructor itself fatals — and closing them is a separate piece of work.