Skip to content

Defer object destructors to the next VM safepoint#22743

Open
iliaal wants to merge 2 commits into
php:masterfrom
iliaal:feat/deferred-dtors-upstream
Open

Defer object destructors to the next VM safepoint#22743
iliaal wants to merge 2 commits into
php:masterfrom
iliaal:feat/deferred-dtors-upstream

Conversation

@iliaal

@iliaal iliaal commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Object destructors now run at the next VM safepoint instead of inline at the point an object's last reference is released, so a destructor can no longer reenter the VM mid-opcode. Logical death stays synchronous: a WeakReference reads null and a WeakMap entry is gone at the drop, only the __destruct call and the free move. The flush points include every try-region boundary, so a destructor dropped inside a try stays catchable by that try at the correct nesting level; output is identical across the interpreter, tracing JIT, and function JIT.

Release callgrind shows no change on code without try/catch and under 1.5% on realistic destructor-in-try workloads; the only larger delta is a trivial try/return in a hot JIT trace, where forced opline materialization dominates and collapses once the function does real work.

This builds on the destructor deferral in arnaud-lb#31; the difference in flush placement and its effect on catchability is in a comment below.

The first commit is #22515, the user-error-handler half of the same mechanism, in review; this stacks the destructor half on top and rebases to the single destructor commit once #22515 lands.

Fixes GH-20001

iliaal added 2 commits July 14, 2026 20:56
A user error handler raised mid-opcode can reenter the VM and free or
mutate state the opcode still holds a raw pointer into, a long-standing
source of use-after-free and memory-corruption bugs. Defer the handler
to the next vm_interrupt safepoint, after the opcode completes. The
interpreter and both JITs reach that safepoint through the existing
vm_interrupt mechanism. A diagnostic silenced by @ keeps its
error_reporting across the deferral; diagnostics raised from internal
functions still run synchronously, since the function may gate on
EG(exception) or throw right after emitting.

Function JIT flushes at the interpreter's safepoints: post-call with the
callee frame still on top, each jump-target block, and function entry,
spilling the live SSA values before it resumes. Exception handling
flushes any buffered diagnostic with the pending exception saved and
restored around the handler, so nothing is dropped when a later opcode
throws. Under the CALL VM the flush is deferred past a call being
assembled, so a throwing handler cannot unwind into a half-built frame
and fault in cleanup_unfinished_calls.

Fixes phpGH-20018
Fixes phpGH-16792
Fixes phpGH-17416
Fixes phpGH-18274
Fixes phpGH-20042
Fixes phpGH-21245
Fixes phpGH-22419
Run an object's __destruct at the next VM safepoint instead of inline at the
point its refcount reaches zero, so a destructor can no longer reenter the VM
in the middle of an opcode's operand cleanup. Logical death stays synchronous:
a WeakReference reads null and a WeakMap entry is gone at the drop, and only
the physical destructor call and the free are deferred. Safepoints are entry
into a user function, method, closure, magic method or hook, a statement-level
internal call, every try-region boundary (entry, normal exit,
return/break/continue/goto/throw out of a try, and the end of a finally body),
a loop back-edge, and script or fiber end.

Because a flushed destructor exception is thrown from an opline still inside
the try region that owns it, the existing exception table delivers it to the
same catch that would have caught an inline throw; region scoping is positional
and needs no per-region runtime state. Several destructor exceptions at one
safepoint compose into a single getPrevious chain, an exit or unwind exception
wins over a destructor exception, and every object is destroyed exactly once.
Behavior is identical across the interpreter, tracing JIT, and function JIT.
Generators and fibers carry their own deferred-destructor queue, so a
destructor deferred inside one runs there rather than in the consumer.

Based on the destructor deferral in arnaud-lb#31, which first delayed
destructors and user error handlers to the vm_interrupt safepoint. The flush
here additionally fires at every try-region boundary, so a deferred destructor
exception stays catchable by its enclosing try rather than escaping it.

Fixes phpGH-20001
@iliaal

iliaal commented Jul 15, 2026

Copy link
Copy Markdown
Contributor Author

For reviewers comparing this with arnaud-lb#31, which prototyped the same deferral for both user error handlers and destructors:

#31 flushes deferred work at the existing vm_interrupt safepoints (function-leave, after-internal-call, backward-jump). Those points do not coincide with try-region boundaries, so a destructor deferred inside a try can be flushed after control has already left the try, and its exception is then delivered outside the enclosing catch. #31 marks that case with an XFAIL on bug53511 and try_finally_017/020/022/023, and inserts a (function(){})(); flush call in 67 test files to pin the destructor to a deterministic point.

This PR adds every try-region boundary as a flush point, so a destructor deferred inside a try is flushed while still positionally inside that region and the existing exception table delivers its exception to the same catch an inline throw would hit. Catchability holds with no XFAILs, and the remaining test churn is destructor-ordering and stack-frame output with an added flush call in only two tests. The error-handler half in #22515 uses the same try-boundary flush.

@arnaud-lb, since this overlaps the destructor part of #31, does the try-boundary flush placement look right to you?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Delaying destructors and GC

1 participant