TL;DR
An error handler is part of the program's memory-safety boundary. It must understand which objects are complete, which references remain live and whether cleanup can trigger additional work. PHP's October 2026 advisories expose two different failures around that boundary: incomplete state consumed during cleanup and request-owned state retained across worker reuse. For runtime and extension maintainers, the useful response is to test interruption points and ownership transitions explicitly. The state models below are original teaching models, not reproductions of exploit payloads or claims about an unreviewed implementation.
Draw the lifecycle before examining the crash
Consider a fictional runtime extension that parses a document into a tree. It allocates a root object, attaches child storage, registers the tree in request state and eventually exposes it to application code. At the end of the request, cleanup walks the tree and releases its buffers. The ordinary path is easy to sketch because every step finishes.
Now add failure between every pair of steps. Allocation can fail after a pointer has become reachable but before its length or type field is initialized. Error reporting can allocate another object. Cleanup can call a callback that changes shared state. A worker can survive the failed request and begin a new one. The lifecycle is no longer a straight line with one exit at the bottom.
The PHP out-of-memory advisory describes an early exit that can leave partially initialized request memory visible to shutdown processing. The stale-state advisory describes request-owned references surviving in worker state and being consumed during a later request. These are distinct defects, despite both involving memory and failure handling.
Our review asks what a maintainer can prove about a proposed extension without relying on the happy path. It uses invented objects and state names to make the reasoning visible. Actual remediation of the PHP vulnerabilities requires the applicable fixed runtime. Changing an extension's style or adding application error handling does not substitute for that update.
Three lifetimes, three questions
The teaching model has three lifetimes. Process state lasts while the worker exists. Request state lasts for one request. Operation state lasts while a particular parse or conversion is in progress. A pointer stored at a longer lifetime must not outlive the object it references unless the reference is cleared or ownership is deliberately transferred.
Ask first who allocates an object, then who may observe it, and finally who ends its lifetime. These are separate responsibilities. The code that allocates a buffer may not be the code that exposes it to cleanup. The operation that frees it may not know about a reference stored in a worker field. Writing only “owned by the request” leaves those relationships underspecified.
PHP's maintainer-authored Internals Book discussion of Zend memory management distinguishes request-bound and persistent allocations. It is historical implementation background, not a complete specification of every current PHP release. The distinction motivates the review: a reusable process needs an explicit boundary between temporary request objects and state intended to survive.
For the fictional parser, mark the root and child buffers as operation-owned until construction commits. Mark the request's tree reference as request-owned after that commit. The worker may keep a counter of completed parses, but it must not retain a raw pointer into the request tree merely because keeping the last result is convenient.
Counterexample one: publish before construction finishes
Suppose the parser allocates a root, stores its address in the request cleanup list, then allocates child storage. If child allocation fails, cleanup discovers the root. Can it tell that child storage was never initialized? If it assumes the object is complete, a routine memory shortage becomes a violation of the cleanup contract.
One proposed repair is to initialize every field before publication. That can be sound when the initial values represent a valid empty object and every consumer respects that representation. It is insufficient if a field uses a special value that later code treats as a valid resource, or if a callback observes the object before all invariants are established.
Another proposal keeps the object private until construction succeeds. This reduces the number of partial states visible to general cleanup, but it creates a local cleanup obligation for failed construction. Review the failure path that releases the private buffers. A design has not solved ownership merely by moving the responsibility into a less visible function.
A third design makes construction states explicit. The object can be allocated, initialized, attached or committed, and cleanup accepts a defined subset of operations in each state. That can support complicated initialization, but the state tag itself must be valid before publication. It also increases the number of combinations that review and testing must cover.
| Interruption point | Object visible outside constructor? | Required cleanup knowledge |
|---|---|---|
| Before allocation succeeds | No object exists | Nothing to release |
| Root allocated, children absent | Only if explicitly published | Root is incomplete |
| Children initialized, not attached | Local ownership remains | Which local buffers exist |
| Tree committed to request | Yes | Full tree invariants hold |
The table is a design aid, not an exhaustive model of PHP. Its value is that a reviewer can point to a row and ask which function enforces the promise. If the answer is “that failure should be rare,” the implementation still lacks a contract.
Counterexample two: cleanup is allowed to call back
Imagine that releasing a tree also emits a diagnostic through a user-configurable callback. The callback creates another object and places it in request state. Cleanup then continues under the assumption that the request's object list is shrinking. It may miss the new object, release it in the wrong phase or revisit an object whose status changed during the callback.
This is reentrancy: code is called again, directly or indirectly, while an earlier operation has not finished establishing its result. Concurrency is not required. A single thread can reenter a subsystem through logging, error handling, destructors or extension callbacks. A lock designed for threads therefore does not automatically address the problem.
The PHP manual's destructor discussion shows that destruction has observable behavior and timing constraints. For our invented extension, the broader design question is whether cleanup invokes arbitrary behavior while invariants are temporarily broken. Treat that question as part of the API contract rather than a detail hidden in a utility function.
A reviewer proposes forbidding callbacks after cleanup begins. That is simple only if the extension can enforce the prohibition across all reachable paths. An error-reporting helper may itself call extensible code. A buffer release may trigger an object destructor. Trace the indirect calls and specify which operations are safe in the closing state.
Another proposal detaches all externally reachable references before invoking callbacks. This can prevent observers from seeing a half-destroyed structure, but callbacks may still create new state that needs a defined owner. The design must say whether new work is rejected, queued elsewhere or assigned to a separate cleanup pass. Otherwise detachment merely moves the ambiguity.
The useful test is an ordinary callback that attempts a permitted operation at an inconvenient moment. It does not require a weaponized payload. If the callback reveals that a subsystem can enter a state its own interface cannot explain, maintainers have found a design problem worth fixing before an attacker supplies a more hostile sequence.
Counterexample three: the next request inherits a pointer
Now consider worker reuse. The fictional extension records the last parsing error in a process-level field so that diagnostics can refer to it. The error object's storage belongs to the request. Normal shutdown clears the field, but an exceptional path returns early. The worker remains alive, and a later request's initialization reads the field before replacing it.
The defect is not that memory was freed. Freeing request memory is expected. The defect is that a longer-lived location still advertises access to that memory. Describing it as a cleanup leak can be misleading: the dangerous reference may exist even though the allocator correctly reclaimed the underlying storage.
A tempting response is to clear the field at the beginning of every request. That helps only if no operation reads it earlier. Initialization is a sequence, not an instantaneous boundary. Configuration diagnostics, extension hooks or other early work can execute before the clearing step. The proof must establish ordering for every consumer, including consumers outside the subsystem that originally stored the reference.
A stronger design gives the field a lifecycle rule: it is either empty or refers to storage whose lifetime covers every possible consumer. If diagnostic history genuinely needs to persist, copy a bounded representation into persistent storage with explicit ownership. Retaining a convenient raw pointer does not make the original object persistent.
Copying is not automatically safe either. The representation must exclude pointers back into request-owned buffers, and its replacement must release prior persistent allocations correctly. The review should inspect the whole reachable object graph, not only the top-level struct. A persistent container filled with temporary pointers still carries the original lifetime problem.
Design the failure experiment around an invariant
A test named “parser handles errors” is too broad to diagnose these problems. Name the invariant instead: an incomplete tree is never traversed as complete; a detached request object is unreachable from worker state; a cleanup callback cannot republish a closing object. Each statement identifies an observation that can fail for a specific reason.
For the fictional constructor, introduce controlled allocation failures in a test harness at successive construction steps. Record which allocations succeeded, which objects became visible and which release operations occurred. The purpose is to explore the state space systematically. Sending ever larger inputs without knowing which allocation failed can consume substantial resources while repeatedly exercising the same path.
The harness must distinguish failure injection from production behavior. A deliberately failing allocator changes execution, and an instrumented build can change layout and timing. A passing experiment supports the tested invariant under the tested conditions; it does not establish that all production schedules or allocator behaviors have been exhausted.
Keep the experiment bounded. Run it in an isolated development environment with limited resources and no production credentials. Preserve the smallest input and configuration that demonstrate the invariant violation. This gives maintainers a reviewable regression case without requiring an operational exploit or exposing unrelated service data.
One request is not enough for a reuse property
A test that launches a fresh process for every input cannot observe a stale worker reference consumed by a second request. It may demonstrate correct request-local behavior while missing the cross-request boundary entirely. The harness needs a way to establish that the same worker processes the relevant sequence.
For a defensive regression, the sequence can use a harmless first request that exercises a controlled error and a harmless second request that checks isolation. Record worker identity and ensure the second observation is actually made in the intended process. Otherwise load balancing may make a flaky test appear safe simply because the requests reached different workers.
Include a third request when the design claims recovery after the failure. A process can survive one subsequent operation while carrying state that affects another. The goal is not to grow the sequence indefinitely. It is to select observations that correspond to the lifetime rules the subsystem promises and document which rules the test does not cover.
Test a normal request after a failed request as well as two failures in succession. The first combination checks whether ordinary users inherit damaged state. The second checks whether error handling compounds the problem. Keep their assertions separate so that a failure points to the violated boundary rather than merely reporting a generic process crash.
Let instrumentation provide evidence, not absolution
Clang AddressSanitizer instruments programs to detect memory errors including out-of-bounds access and use-after-free. A report can connect an invalid access to allocation and release history, which is valuable for the lifecycle questions above. It does not prove the absence of every memory defect when a test produces no report.
Instrumentation coverage matters. If only one component is instrumented while another owns the allocation, the evidence can be incomplete. Build configuration, symbolization and the exact executable used in the run belong in the reproduction record. A stack trace without the corresponding artifact and configuration may be difficult for another maintainer to interpret.
Use the report to reconstruct ownership, not merely to suppress the crashing line. A defensive check at the consumer can stop one observed access while leaving the invalid reference available to another consumer. Trace where the object became visible, when its lifetime ended and which transition failed to remove or replace the reference.
Also examine tests that terminate cleanly. A hard process exit may be the intended safe response to an unrecoverable state, but the harness should verify that the supervising system treats the exit as expected. A test runner that regards every nonzero exit as an unrelated infrastructure failure can hide the very behavior the runtime deliberately introduced.
The availability cost belongs in the design review
A process can preserve memory safety by refusing to continue after an unrecoverable failure. That does not mean the operational cost is irrelevant. A service that repeatedly replaces workers may lose capacity, interrupt requests or overload a supervisor. Security and availability need separate acceptance criteria so that improving one does not silently erase the other.
Suppose a fictional pool has ten workers and each ordinary request occupies one worker for a tenth of a second. Ignoring all other bottlenecks, its nominal service capacity is one hundred requests per second. If two workers are unavailable during replacement, that simple ceiling becomes eighty. These figures are illustrative, and real capacity also depends on arrival variation, downstream services and workload differences.
The calculation does not justify retaining a memory-unsafe worker. It shows why replacement behavior, backpressure and resource limits should be tested alongside the safety property. A robust design can fail closed at the damaged worker while preserving enough healthy capacity to handle permitted traffic or return a controlled temporary response.
Separate per-request accounting from process and container limits. They have different enforcement points and failure consequences. The October PHP advisory includes qualified emergency mitigation guidance; copying one setting without its surrounding conditions can alter the failure path and availability in unexpected ways. Operators should follow the vendor's complete guidance and prioritize the actual runtime fix.
In the extension review, maintainers describe the intended outcome for each resource boundary. A recoverable parse failure returns a controlled error. An unrecoverable internal state terminates the affected work according to the runtime's contract. The supervisor's response and the application's retry behavior are then evaluated against that contract, rather than inferred from a generic desire to avoid crashes.
A reviewer challenges the proposed repair
The author says the patch clears the process-level field during shutdown. The reviewer asks whether an error callback can populate it again after that step. This is a sequencing question, and the answer requires tracing later cleanup operations. If repopulation is possible, clearing once is not an adequate invariant.
The author responds by moving the clear to the final cleanup stage. The reviewer asks what happens when execution leaves before reaching that stage. The patch now needs a guarantee about exceptional exits, not simply a later line number. A robust explanation identifies which exits are possible and how each preserves or abandons state safely.
The author proposes an additional clear during request startup. The reviewer asks whether anything consumes the field before that clear. This returns the discussion to initialization ordering. Multiple clears can be useful defense in depth, but their number is not evidence that the lifetime boundary has been proved.
Finally, the author changes the representation so that the worker field holds only a bounded persistent diagnostic value, never a pointer into request memory. The reviewer still checks allocation failure, replacement and teardown of the persistent value. The design has reduced one class of lifetime coupling, while creating explicit obligations that can be tested directly.
This exchange is fictional and should not be read as a reconstruction of PHP's actual patch review. Its purpose is to demonstrate how a reviewer can keep asking about observable invariants instead of accepting a local code change as a complete explanation of a system-level failure.
Evidence that survives the next refactor
Keep regression assertions at the ownership boundary whenever possible. A test that depends on a particular helper's name may fail during a harmless refactor while missing a behavioral regression. A test that establishes request isolation and invalid-state rejection can remain useful even when internal functions move.
At the same time, retain focused implementation tests where they expose a difficult interruption point. A public-interface test may be too coarse to force every partial-construction state. The combination is valuable: targeted tests exercise internal transitions, and integration tests establish that the runtime's externally visible lifecycle still respects the intended boundary.
Document why an unusual test exists. A future maintainer seeing an error followed by another request may simplify it into a single request unless the cross-request property is stated. A short explanation of the invariant protects the test's purpose better than a comment that merely repeats the sequence of operations.
When the runtime or allocator changes, rerun the relevant experiments with the new configuration and review their assumptions. A previously unreachable callback may become reachable, or a new extension hook may run earlier in initialization. The historical test is a starting point for reasoning about the changed lifecycle, not a permanent certificate attached to the subsystem.
The design artifact to take away
The useful output of this review is a lifecycle map with explicit publication points, ownership transfers, interruption behavior and permitted consumers. Attach a small matrix of experiments to those claims. Record which paths use a fresh process, which reuse a worker and which deliberately interrupt construction or cleanup.
For the fictional parser, the accepted design never exposes an incomplete tree as complete, never leaves request pointers in worker diagnostics and specifies what cleanup callbacks may do. Tests verify those properties under controlled failures and repeated requests. The documentation also names the availability consequences of terminating damaged work and the supervisor behavior expected afterward.
A final review should look for contradictions between those artifacts. If the ownership map says callbacks cannot run during teardown but the test harness invokes one there, either the map or the harness is wrong. If a test expects recovery while the runtime contract requires termination, a passing assertion can reward unsafe behavior. Resolve these discrepancies before treating test counts as evidence of quality.
The same method applies beyond one parser. Cache entries, error objects, extension globals and request metadata can all cross lifetimes. The practical lesson from the PHP disclosures is that failure handling deserves the same precision as normal execution. A system is only as well specified as the states it permits when its ordinary work cannot finish.