Skip to content

fix: leak one refcount in ZBox<ZendClassObject<T>>::set_zval#735

Merged
ptondereau merged 2 commits into
masterfrom
fix/class-object-refcount-leak
May 11, 2026
Merged

fix: leak one refcount in ZBox<ZendClassObject<T>>::set_zval#735
ptondereau merged 2 commits into
masterfrom
fix/class-object-refcount-leak

Conversation

@ptondereau
Copy link
Copy Markdown
Member

@ptondereau ptondereau commented May 11, 2026

Description

This one surfaced from biscuit-php while refactoring an exception type with #[php(prop)] fields thrown via with_object. Tests stayed green, but PHP 8.5.2-dev printed _zend_hash_str_add_or_update_i at shutdown, easy to read as harmless debug-build noise. Bisecting (#[php(prop)] on or off, ZendClassObject::new vs from_class) narrowed it to the set_zval impl on ZBox<ZendClassObject<T>>. The sibling impl ZBox<ZendObject>::set_zval in src/types/object.rs already carried the dec_count workaround, with a comment from the original author flagging it, the class-object impl was the one that never got the same treatment.

While returning a ZendClassObject<T> from Rust to PHP through into_zval, the box's only reference was being preserved by into_raw and then a second one was being added by set_object, leaving the object stuck at refcount 2. The zval would later release one, but the count never reached zero. Every exception built that way, every class object handed back from a #[php(prop)] getter, every PHP-visible value coming out of that path leaked a copy on shutdown. On a debug PHP build the leak then propagated into shared class storage and tripped _zend_hash_str_add_or_update_i on module shutdown; on release builds it was just silent bytes piling up request after request.

The fix mirrors what ZBox<ZendObject>::set_zval in src/types/object.rs has been doing all along: drop one reference before handing the raw pointer to set_object, so the post-insertion count lands on the expected 1. The companion impl for RegisteredEnum + RegisteredClass in src/enum_.rs had the same shape and the same bug, so it got the same one-line correction.

Coverage closes the loop. A class extending \Exception with a #[php(prop)] field is now thrown 1024 times through the exact buggy entry point (ZendClassObject::new(...).into_zval(...) + with_object), and the test asserts that memory growth stays under 8 KB. Without the fix it climbs past 600 KB. Memory growth was chosen over the debug-only HT assertion so the regression remains visible on release PHP builds as well.

While auditing, the original draft analysis around throw_object (in src/exception.rs) was found to be incorrect: zend_throw_exception_object does not addref on the success path across PHP 8.1 → 8.5, so the current ManuallyDrop flow already balances correctly. No change there.

Checklist

@coveralls
Copy link
Copy Markdown

coveralls commented May 11, 2026

Coverage Report for CI Build 25676991317

Coverage decreased (-0.01%) to 66.23%

Details

  • Coverage decreased (-0.01%) from the base build.
  • Patch coverage: 4 uncovered changes across 2 files (0 of 4 lines covered, 0.0%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
src/enum_.rs 2 0 0.0%
src/types/class_object.rs 2 0 0.0%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 13065
Covered Lines: 8653
Line Coverage: 66.23%
Coverage Strength: 33.26 hits per line

💛 - Coveralls

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 11, 2026

🐰 Bencher Report

Branchfix/class-object-refcount-leak
TestbedPHP 8.4.21 (cli) (built: May 8 2026 04:58:07) (NTS)

⚠️ WARNING: Truncated view!

The full continuous benchmarking report exceeds the maximum length allowed on this platform.

⚠️ WARNING: No Threshold found!

Without a Threshold, no Alerts will ever be generated.

🐰 View full continuous benchmarking report in Bencher

ZBox::into_raw transfers ownership without touching the
refcount, so the box's single ref carries into set_object.
set_object then calls inc_count, bumping the count to 2.
The zval drops one ref on destruction; the object never
reaches zero and leaks.

On a PHP debug build the leak cascades into an assertion in
_zend_hash_str_add_or_update_i at module shutdown: leaked
instances keep shared class storage (used by classes with
#[php(prop)] fields) alive past its expected lifetime, and
property cleanup hits a frozen hashtable without the
HASH_FLAG_ALLOW_COW_VIOLATION bit.

Apply the same fix already present on the sibling impl
ZBox<ZendObject>::set_zval in object.rs: call dec_count
before into_raw so the net post-insertion count stays at 1.
The matching impl for RegisteredEnum + RegisteredClass in
enum_.rs had the same bug and is fixed alongside.
@ptondereau ptondereau force-pushed the fix/class-object-refcount-leak branch from e8514a6 to e86975a Compare May 11, 2026 14:00
Adds an integration test that exercises the exact buggy code path
fixed in the previous commit: a class extending \Exception with a
#[php(prop)] field, constructed via ZendClassObject::new(...)
.into_zval(...) and thrown via PhpException::with_object. The
caught exception's refcount is read inside the catch block via
debug_zval_dump; with the fix the only holder is the catch-bound
variable, so the dump reports refcount 2. Without the fix the
buggy set_zval keeps an extra ref and the dump reports 3.

Refcount via debug_zval_dump was chosen over a memory-growth
measurement so the same assertion fires on release and debug PHP
builds. memory_get_usage / gc_collect_cycles interact poorly with
a separate latent refcount bug in the IntoZval impl for
&mut ZendClassObject<T> (out of scope here): on debug builds GC
tries to add to a shared HT and trips
_zend_hash_str_add_or_update_i.

Verified against PHP 8.4.20 NTS and 8.4.20 ZTS-DEBUG: passes
with the fix, fails (refcount 3) without it.
@ptondereau ptondereau force-pushed the fix/class-object-refcount-leak branch from e86975a to 71ea067 Compare May 11, 2026 14:39
@ptondereau ptondereau marked this pull request as ready for review May 11, 2026 15:12
@ptondereau ptondereau merged commit 7a2ffc7 into master May 11, 2026
122 of 132 checks passed
@ptondereau ptondereau deleted the fix/class-object-refcount-leak branch May 11, 2026 15:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants