Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/enum_.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use crate::{
error::{Error, Result},
ffi::zend_enum_get_case,
flags::{ClassFlags, DataType},
rc::PhpRc,
types::{ZendObject, ZendStr, Zval},
};

Expand Down Expand Up @@ -88,7 +89,12 @@ where
const NULLABLE: bool = false;

fn set_zval(self, zv: &mut Zval, _persistent: bool) -> Result<()> {
let obj = self.into_zend_object()?;
// `set_object` increments the object refcount; the ZBox returned by
// `into_zend_object` already owns one ref, so we drop one before
// handing the pointer over to keep the net count at 1. Same pattern as
// `ZBox<ZendObject>::set_zval` in `object.rs`.
let mut obj = self.into_zend_object()?;
obj.dec_count();
zv.set_object(obj.into_raw());
Ok(())
}
Expand Down
6 changes: 5 additions & 1 deletion src/types/class_object.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ use crate::{
zend_object, zend_object_std_init, zend_objects_clone_members,
},
flags::DataType,
rc::PhpRc,
types::{ZendObject, Zval},
zend::ClassEntry,
};
Expand Down Expand Up @@ -331,7 +332,10 @@ impl<T: RegisteredClass> IntoZval for ZBox<ZendClassObject<T>> {
const TYPE: DataType = DataType::Object(Some(T::CLASS_NAME));
const NULLABLE: bool = false;

fn set_zval(self, zv: &mut Zval, _: bool) -> Result<()> {
fn set_zval(mut self, zv: &mut Zval, _: bool) -> Result<()> {
// `set_object` inc_counts on insertion, so dec_count first to keep the
// net refcount at 1. Matches `ZBox<ZendObject>::set_zval` in object.rs.
self.std.dec_count();
let obj = self.into_raw();
zv.set_object(&mut obj.std);
Ok(())
Expand Down
28 changes: 28 additions & 0 deletions tests/src/integration/class/class.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,34 @@
assert_exception_thrown(fn() => throw $ex);
assert_exception_thrown(fn() => throwException());

// Regression coverage for the ZBox<ZendClassObject<T>>::set_zval refcount bug.
// The Rust side builds the exception via ZendClassObject::new(...).into_zval(...)
// rather than T.into_zval(...), and the class carries a #[php(prop)] field. Before
// the fix the buggy set_zval leaked one ref per throw; debug_zval_dump exposes the
// surplus deterministically and works on both release and debug PHP builds.
//
// The refcount has to be read INSIDE the catch block: PHP keeps the catch-bound
// variable in scope after the block ends, so any dump after the closing brace would
// see both bindings and report one ref too many.
$reported_refcount = null;
try {
throw_class_object_exception_with_prop();
} catch (TestClassExtendsWithProp $e) {
assert($e->payload === 'boom', 'Property value should survive the throw round-trip');
ob_start();
debug_zval_dump($e);
$dump = ob_get_clean();
if (preg_match('/refcount\((\d+)\)\{/', $dump, $m)) {
$reported_refcount = (int) $m[1];
}
}
assert(
$reported_refcount === 2,
"Caught exception refcount should be 2 (\$e + debug_zval_dump's copy); a " .
"regression in ZBox<ZendClassObject<T>>::set_zval pushes it to 3. Got: " .
var_export($reported_refcount, true)
);

$arrayAccess = new TestClassArrayAccess();
assert_exception_thrown(fn() => $arrayAccess[0] = 'foo');
assert_exception_thrown(fn() => $arrayAccess['foo']);
Expand Down
34 changes: 33 additions & 1 deletion tests/src/integration/class/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,36 @@ pub fn throw_exception() -> PhpResult<i32> {
)
}

/// Regression coverage for the `ZBox<ZendClassObject<T>>::set_zval` refcount
/// bug: throwing an exception built through `ZendClassObject::new(...)
/// .into_zval(...)` (rather than `T.into_zval(...)`) and carrying a
/// `#[php(prop)]` field leaks the underlying object. On a PHP debug build the
/// leak cascades into a `_zend_hash_str_add_or_update_i` assertion at module
/// shutdown.
#[php_class]
#[php(extends(ce = ce::exception, stub = "\\Exception"))]
#[derive(Default)]
pub struct TestClassExtendsWithProp {
#[php(prop)]
pub payload: String,
}

#[php_impl]
impl TestClassExtendsWithProp {
pub fn __construct() -> Self {
Self::default()
}
}

#[php_function]
pub fn throw_class_object_exception_with_prop() -> PhpResult<i32> {
let payload = TestClassExtendsWithProp {
payload: "boom".to_string(),
};
let zval = ZendClassObject::new(payload).into_zval(false)?;
Err(PhpException::from_class::<TestClassExtendsWithProp>("ignored".into()).with_object(zval))
}

#[php_class]
#[php(implements(ce = ce::arrayaccess, stub = "ArrayAccess"))]
#[php(extends(ce = ce::exception, stub = "\\Exception"))]
Expand Down Expand Up @@ -606,6 +636,7 @@ pub fn build_module(builder: ModuleBuilder) -> ModuleBuilder {
.class::<TestClass>()
.class::<TestClassArrayAccess>()
.class::<TestClassExtends>()
.class::<TestClassExtendsWithProp>()
.class::<TestClassExtendsImpl>()
.class::<TestClassMethodVisibility>()
.class::<TestClassProtectedConstruct>()
Expand All @@ -622,7 +653,8 @@ pub fn build_module(builder: ModuleBuilder) -> ModuleBuilder {
.class::<TestCloneableClass>()
.class::<TestUncloneableClass>()
.function(wrap_function!(test_class))
.function(wrap_function!(throw_exception));
.function(wrap_function!(throw_exception))
.function(wrap_function!(throw_class_object_exception_with_prop));

#[cfg(php84)]
let builder = builder
Expand Down
Loading