Skip to content

Add destructor-hazard sections to defer reentrant destructors#22697

Open
iliaal wants to merge 1 commit into
php:masterfrom
iliaal:proto/dtor-hazard-pin
Open

Add destructor-hazard sections to defer reentrant destructors#22697
iliaal wants to merge 1 commit into
php:masterfrom
iliaal:proto/dtor-hazard-pin

Conversation

@iliaal

@iliaal iliaal commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

This is the destructor slice of the reentrancy work tracked in GH-20001, building on the error-handler deferral in #22515. It adds ZEND_DTOR_HAZARD_BEGIN/END sections and a deferral gate in zend_objects_store_del: while a section is open, an object that reaches refcount zero and has a destructor is pinned and its destructor deferred until the section closes. Applied to three reentrancy use-after-frees where an internal C loop drops a refcount while holding a raw pointer a synchronous __destruct could free: concat (GH-20477), zend_hash_clean (GH-22061), and unserialize teardown (GH-21824).

How this differs from arnaud-lb#31: that prototype deferred every destructor through a single global EG(delayed_effects) queue, pinned the dying object with GC_SET_REFCOUNT(obj, 1), and flushed at the VM function-leave helper. It left three problems open: throwing destructors reordered against try/finally and catch (6 XFAILed tests), 67 tests needed a dummy call to create a flush point, and GC reconciliation between the RC=1 pin and cycle collection stayed unresolved. This mechanism is scoped instead. BEGIN/END bracket only the internal loops that hold the raw pointer, so no user code runs inside a window. That drops the try/finally reordering question, drops the timing change (flush is at section exit, not an arbitrary safepoint), uses GC_ADDREF instead of GC_SET_REFCOUNT so it reuses the existing store_del invariants, and closes the GC hole by having gc_possible_root_when_full skip synchronous collection while a section is open.

Performance: the store_del gate is one branch guarded by has_dtor, so destructor-less objects are untouched. Sections arm only on cold paths. Release callgrind over 2M concats shows +1.2 instructions per concat.

P.S. As a follow-up, this can retire hand-rolled per-site guards for the same class: SplFixedArray's cached_resize field exists only to defer a re-entrant setSize a destructor triggers during the element-free, so wrapping that phase in a hazard section would let the whole cached_resize machinery be removed. I will propose that separately once this lands.

An internal C operation that drops a refcount while holding a raw pointer
into a structure a synchronous __destruct could free is a use-after-free.
Add ZEND_DTOR_HAZARD_BEGIN/END sections and a deferral gate in
zend_objects_store_del: while a section is open, an object that reaches
refcount zero and has a destructor is pinned with GC_ADDREF and its
destructor deferred until the section closes. gc_possible_root_when_full
skips synchronous collection while a section is open. Apply the sections
to concat (phpGH-20477), zend_hash_clean (phpGH-22061), and unserialize
teardown (phpGH-21824).

Fixes phpGH-20477
Fixes phpGH-22061
Fixes phpGH-21824
@iluuu1994

Copy link
Copy Markdown
Member

This doesn't look like a general solution. Many paths in the VM currently have awkward handling for destructors. Basically, search for "garbage" in Zend/zend_vm_def.h. With Arnaud's solution, we can completely get rid of this handling and forget about this issue entirely. The same will go for error handlers.

@iliaal

iliaal commented Jul 11, 2026

Copy link
Copy Markdown
Contributor Author

For error handlers there is already a general solution in #22515 that afaik covers all known issues and cases with essentially 0 performance impact. This is 1st stab that while not strictly general provides isolated, essentially performance safe fix for 3 issues and simplification/unification path for one-off fixes in SPL.

Still lookin at broader fix, but this is an interim working solution

@iluuu1994

Copy link
Copy Markdown
Member

I'm really struggling to see how GH-22515 is different form Arnaud's approach. He also implemented his solution through interrupts. Also note that this is intentionally in one PR specifically because we are planning to propose the behavioral change, namely the delaying of destructors and error handlers, as an RFC.

@iliaal

iliaal commented Jul 12, 2026

Copy link
Copy Markdown
Contributor Author

Right, the transport is the same, a queue drained at vm_interrupt safepoints, and #22515's HANDLE_EXCEPTION drain is adapted from Arnaud's branch. The difference is the gate, not the mechanism.

#22515 defers only diagnostics raised at user-code oplines, the class where the VM holds raw pointers into live operands, and it excludes the call/declare/include opcodes; E_USER_ERROR and E_RECOVERABLE_ERROR never defer; anything raised from an internal function keeps the synchronous handler call. #31 queues any matching diagnostic raised with a live frame, internal functions included (E_NO_DELAY exists, but only its own replay sites set it). That wider gate is where the RFC-sized surface comes from: set_error_handler($delay), PromotedErrorException, promotion precedence.

Flush density differs the same way. #22515 keeps the function-entry interrupt check and flushes at the first safepoint after the raising opcode completes (back-edges, leave, post-call, exception handling, script end), and it postpones the flush past an in-flight call in both the interpreter and the JIT, which is what fixes the CALL-VM cleanup_unfinished_calls fault (gh18262) on Windows/macOS. #31 removes the entry check in all VM kinds and its ZEND_RETURN flush is #if 0'd, so a top-level raise can carry to script end; the 67 tests adding a (function() {})(); flush point and the 48 opting out with delay: false compensate for that. At tip it also pins opcache.jit=0 in four tests, and its five XFAILs are all throwing-destructor/try-finally; #22515 runs under function JIT, tracing JIT and the CALL VM with no pins and no XFAILs.

Both agree on the core semantic: the raising opcode always completes before any handler runs. #22515 is the subset with the smallest observable delta that still fixes the crash class: no new API, no new exception class, and its 75 test-expectation updates are interleave shifts or corrections where a handler used to see mid-opcode state. What's left for the RFC is exactly #31's remainder: internal-function raises, destructors, and the opt-out API. On destructors I agree the general mechanism is the goal and I'm working on it; this is an interim guard for three live UAFs, not a competing end state. If the RFC lands delay-everything, both fold into it.

Comment thread Zend/zend_objects_API.h
ZEND_API void ZEND_FASTCALL zend_defer_destructor(zend_object *object);
ZEND_API void zend_flush_deferred_destructors(void);

#define ZEND_DTOR_HAZARD_BEGIN() do { \

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please no more new macros.

@iluuu1994

Copy link
Copy Markdown
Member

This response looks like AI. If so, please put the relevant part in quotes and disclose your usage. I think that's something we should be able to expect from members. I don't want to live in a dystopian world where we can't even trust the words from our colleagues.

and #22515 HANDLE_EXCEPTION drain is adapted from Arnaud's branch

Then why not give attribution? Why not ask Arnaud to collaborate and share ideas to minimize backwards incompatibilities, rather than proposing effectively the same approach with his name fully erased?

@iluuu1994 iluuu1994 closed this Jul 12, 2026
@iluuu1994 iluuu1994 reopened this Jul 12, 2026
@iluuu1994

Copy link
Copy Markdown
Member

Fat fingers. Didn't intend to close.

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.

3 participants