From c1a3cd95948b6439b2ddf22a567a4659786517df Mon Sep 17 00:00:00 2001 From: Vasily Zorin Date: Mon, 22 Dec 2025 20:39:34 +0700 Subject: [PATCH] feat: add support for empty immutable shared arrays # Conflicts: # build.rs --- allowed_bindings.rs | 4 + docsrs_bindings.rs | 6 ++ src/flags.rs | 35 +++++---- src/types/array/mod.rs | 102 +++++++++++++++++++++++++- src/types/mod.rs | 2 +- src/types/zval.rs | 9 ++- tests/src/integration/array/array.php | 30 ++++++++ tests/src/integration/array/mod.rs | 38 +++++++++- 8 files changed, 203 insertions(+), 23 deletions(-) diff --git a/allowed_bindings.rs b/allowed_bindings.rs index 42dbb8750..da40b6945 100644 --- a/allowed_bindings.rs +++ b/allowed_bindings.rs @@ -87,6 +87,7 @@ bind! { zend_declare_class_constant, zend_declare_property, zend_do_implement_interface, + zend_empty_array, zend_read_static_property, zend_update_static_property, zend_enum_add_case, @@ -153,6 +154,9 @@ bind! { E_RECOVERABLE_ERROR, E_DEPRECATED, E_USER_DEPRECATED, + GC_FLAGS_MASK, + GC_FLAGS_SHIFT, + GC_IMMUTABLE, HT_MIN_SIZE, IS_ARRAY, IS_ARRAY_EX, diff --git a/docsrs_bindings.rs b/docsrs_bindings.rs index 29a2ee8c6..a00a16ce7 100644 --- a/docsrs_bindings.rs +++ b/docsrs_bindings.rs @@ -165,6 +165,9 @@ pub const IS_INDIRECT: u32 = 12; pub const IS_PTR: u32 = 13; pub const _IS_BOOL: u32 = 18; pub const Z_TYPE_FLAGS_SHIFT: u32 = 8; +pub const GC_FLAGS_MASK: u32 = 1008; +pub const GC_FLAGS_SHIFT: u32 = 0; +pub const GC_IMMUTABLE: u32 = 64; pub const IS_TYPE_REFCOUNTED: u32 = 1; pub const IS_TYPE_COLLECTABLE: u32 = 2; pub const IS_INTERNED_STRING_EX: u32 = 6; @@ -769,6 +772,9 @@ pub const zend_hash_key_type_HASH_KEY_IS_STRING: zend_hash_key_type = 1; pub const zend_hash_key_type_HASH_KEY_IS_LONG: zend_hash_key_type = 2; pub const zend_hash_key_type_HASH_KEY_NON_EXISTENT: zend_hash_key_type = 3; pub type zend_hash_key_type = ::std::os::raw::c_uint; +unsafe extern "C" { + pub static zend_empty_array: HashTable; +} unsafe extern "C" { pub fn zend_hash_clean(ht: *mut HashTable); } diff --git a/src/flags.rs b/src/flags.rs index db124e5ec..aa5c08e14 100644 --- a/src/flags.rs +++ b/src/flags.rs @@ -10,22 +10,22 @@ use crate::ffi::{ _IS_BOOL, CONST_CS, CONST_DEPRECATED, CONST_NO_FILE_CACHE, CONST_PERSISTENT, E_COMPILE_ERROR, E_COMPILE_WARNING, E_CORE_ERROR, E_CORE_WARNING, E_DEPRECATED, E_ERROR, E_NOTICE, E_PARSE, E_RECOVERABLE_ERROR, E_STRICT, E_USER_DEPRECATED, E_USER_ERROR, E_USER_NOTICE, E_USER_WARNING, - E_WARNING, IS_ARRAY, IS_CALLABLE, IS_CONSTANT_AST, IS_DOUBLE, IS_FALSE, IS_INDIRECT, - IS_ITERABLE, IS_LONG, IS_MIXED, IS_NULL, IS_OBJECT, IS_PTR, IS_REFERENCE, IS_RESOURCE, - IS_STRING, IS_TRUE, IS_TYPE_COLLECTABLE, IS_TYPE_REFCOUNTED, IS_UNDEF, IS_VOID, PHP_INI_ALL, - PHP_INI_PERDIR, PHP_INI_SYSTEM, PHP_INI_USER, Z_TYPE_FLAGS_SHIFT, ZEND_ACC_ABSTRACT, - ZEND_ACC_ANON_CLASS, ZEND_ACC_CALL_VIA_TRAMPOLINE, ZEND_ACC_CHANGED, ZEND_ACC_CLOSURE, - ZEND_ACC_CONSTANTS_UPDATED, ZEND_ACC_CTOR, ZEND_ACC_DEPRECATED, ZEND_ACC_DONE_PASS_TWO, - ZEND_ACC_EARLY_BINDING, ZEND_ACC_FAKE_CLOSURE, ZEND_ACC_FINAL, ZEND_ACC_GENERATOR, - ZEND_ACC_HAS_FINALLY_BLOCK, ZEND_ACC_HAS_RETURN_TYPE, ZEND_ACC_HAS_TYPE_HINTS, - ZEND_ACC_HEAP_RT_CACHE, ZEND_ACC_IMMUTABLE, ZEND_ACC_IMPLICIT_ABSTRACT_CLASS, - ZEND_ACC_INTERFACE, ZEND_ACC_LINKED, ZEND_ACC_NEARLY_LINKED, ZEND_ACC_NEVER_CACHE, - ZEND_ACC_NO_DYNAMIC_PROPERTIES, ZEND_ACC_PRELOADED, ZEND_ACC_PRIVATE, ZEND_ACC_PROMOTED, - ZEND_ACC_PROTECTED, ZEND_ACC_PUBLIC, ZEND_ACC_RESOLVED_INTERFACES, ZEND_ACC_RESOLVED_PARENT, - ZEND_ACC_RETURN_REFERENCE, ZEND_ACC_STATIC, ZEND_ACC_STRICT_TYPES, ZEND_ACC_TOP_LEVEL, - ZEND_ACC_TRAIT, ZEND_ACC_TRAIT_CLONE, ZEND_ACC_UNRESOLVED_VARIANCE, ZEND_ACC_USE_GUARDS, - ZEND_ACC_USES_THIS, ZEND_ACC_VARIADIC, ZEND_EVAL_CODE, ZEND_HAS_STATIC_IN_METHODS, - ZEND_INTERNAL_FUNCTION, ZEND_USER_FUNCTION, + E_WARNING, GC_IMMUTABLE, IS_ARRAY, IS_CALLABLE, IS_CONSTANT_AST, IS_DOUBLE, IS_FALSE, + IS_INDIRECT, IS_ITERABLE, IS_LONG, IS_MIXED, IS_NULL, IS_OBJECT, IS_PTR, IS_REFERENCE, + IS_RESOURCE, IS_STRING, IS_TRUE, IS_TYPE_COLLECTABLE, IS_TYPE_REFCOUNTED, IS_UNDEF, IS_VOID, + PHP_INI_ALL, PHP_INI_PERDIR, PHP_INI_SYSTEM, PHP_INI_USER, Z_TYPE_FLAGS_SHIFT, + ZEND_ACC_ABSTRACT, ZEND_ACC_ANON_CLASS, ZEND_ACC_CALL_VIA_TRAMPOLINE, ZEND_ACC_CHANGED, + ZEND_ACC_CLOSURE, ZEND_ACC_CONSTANTS_UPDATED, ZEND_ACC_CTOR, ZEND_ACC_DEPRECATED, + ZEND_ACC_DONE_PASS_TWO, ZEND_ACC_EARLY_BINDING, ZEND_ACC_FAKE_CLOSURE, ZEND_ACC_FINAL, + ZEND_ACC_GENERATOR, ZEND_ACC_HAS_FINALLY_BLOCK, ZEND_ACC_HAS_RETURN_TYPE, + ZEND_ACC_HAS_TYPE_HINTS, ZEND_ACC_HEAP_RT_CACHE, ZEND_ACC_IMMUTABLE, + ZEND_ACC_IMPLICIT_ABSTRACT_CLASS, ZEND_ACC_INTERFACE, ZEND_ACC_LINKED, ZEND_ACC_NEARLY_LINKED, + ZEND_ACC_NEVER_CACHE, ZEND_ACC_NO_DYNAMIC_PROPERTIES, ZEND_ACC_PRELOADED, ZEND_ACC_PRIVATE, + ZEND_ACC_PROMOTED, ZEND_ACC_PROTECTED, ZEND_ACC_PUBLIC, ZEND_ACC_RESOLVED_INTERFACES, + ZEND_ACC_RESOLVED_PARENT, ZEND_ACC_RETURN_REFERENCE, ZEND_ACC_STATIC, ZEND_ACC_STRICT_TYPES, + ZEND_ACC_TOP_LEVEL, ZEND_ACC_TRAIT, ZEND_ACC_TRAIT_CLONE, ZEND_ACC_UNRESOLVED_VARIANCE, + ZEND_ACC_USE_GUARDS, ZEND_ACC_USES_THIS, ZEND_ACC_VARIADIC, ZEND_EVAL_CODE, + ZEND_HAS_STATIC_IN_METHODS, ZEND_INTERNAL_FUNCTION, ZEND_USER_FUNCTION, }; use std::{convert::TryFrom, fmt::Display}; @@ -88,6 +88,9 @@ bitflags! { const RefCounted = (IS_TYPE_REFCOUNTED << Z_TYPE_FLAGS_SHIFT); /// Collectable const Collectable = (IS_TYPE_COLLECTABLE << Z_TYPE_FLAGS_SHIFT); + + /// Immutable (used for the shared empty array) + const Immutable = GC_IMMUTABLE; } } diff --git a/src/types/array/mod.rs b/src/types/array/mod.rs index a43e024a3..6dac9d4da 100644 --- a/src/types/array/mod.rs +++ b/src/types/array/mod.rs @@ -9,11 +9,12 @@ use crate::{ error::Result, ffi::zend_ulong, ffi::{ - _zend_new_array, HT_MIN_SIZE, zend_array_count, zend_array_destroy, zend_array_dup, - zend_hash_clean, zend_hash_index_del, zend_hash_index_find, zend_hash_index_update, - zend_hash_next_index_insert, zend_hash_str_del, zend_hash_str_find, zend_hash_str_update, + _zend_new_array, GC_FLAGS_MASK, GC_FLAGS_SHIFT, HT_MIN_SIZE, zend_array_count, + zend_array_destroy, zend_array_dup, zend_empty_array, zend_hash_clean, zend_hash_index_del, + zend_hash_index_find, zend_hash_index_update, zend_hash_next_index_insert, + zend_hash_str_del, zend_hash_str_find, zend_hash_str_update, }, - flags::DataType, + flags::{DataType, ZvalTypeFlags}, types::Zval, }; @@ -648,10 +649,37 @@ impl ZendHashTable { pub fn iter(&self) -> Iter<'_> { self.into_iter() } + + /// Determines whether this hashtable is immutable. + /// + /// Immutable hashtables are shared and cannot be modified. The primary + /// example is the empty immutable shared array returned by + /// [`ZendEmptyArray`]. + /// + /// # Example + /// + /// ```no_run + /// use ext_php_rs::types::ZendHashTable; + /// + /// let ht = ZendHashTable::new(); + /// assert!(!ht.is_immutable()); + /// ``` + #[must_use] + pub fn is_immutable(&self) -> bool { + // SAFETY: Type info is initialized by Zend on array init. + let gc_type_info = unsafe { self.gc.u.type_info }; + let gc_flags = (gc_type_info >> GC_FLAGS_SHIFT) & (GC_FLAGS_MASK >> GC_FLAGS_SHIFT); + + gc_flags & ZvalTypeFlags::Immutable.bits() != 0 + } } unsafe impl ZBoxable for ZendHashTable { fn free(&mut self) { + // Do not attempt to free the immutable shared empty array. + if self.is_immutable() { + return; + } // SAFETY: ZBox has immutable access to `self`. unsafe { zend_array_destroy(self) } } @@ -719,3 +747,69 @@ impl<'a> FromZvalMut<'a> for &'a mut ZendHashTable { zval.array_mut() } } + +/// Represents an empty, immutable, shared PHP array. +/// +/// Since PHP 7.3, it's possible for extensions to return a zval backed by +/// an immutable shared hashtable. This helps avoid redundant hashtable +/// allocations when returning empty arrays to userland PHP code. +/// +/// This struct provides a safe way to return an empty array without allocating +/// a new hashtable. It implements [`IntoZval`] so it can be used as a return +/// type for PHP functions. +/// +/// # Safety +/// +/// Unlike [`ZendHashTable`], this type does not allow any mutation of the +/// underlying array, as it points to a shared static empty array in PHP's +/// memory. +/// +/// # Example +/// +/// ```rust,ignore +/// use ext_php_rs::prelude::*; +/// use ext_php_rs::types::ZendEmptyArray; +/// +/// #[php_function] +/// pub fn get_empty_array() -> ZendEmptyArray { +/// ZendEmptyArray +/// } +/// ``` +/// +/// This is more efficient than returning `Vec::::new()` or creating +/// a new `ZendHashTable` when you know the result will be empty. +#[derive(Debug, Clone, Copy, Default)] +pub struct ZendEmptyArray; + +impl ZendEmptyArray { + /// Returns a reference to the underlying immutable empty hashtable. + /// + /// # Example + /// + /// ```no_run + /// use ext_php_rs::types::ZendEmptyArray; + /// + /// let empty = ZendEmptyArray; + /// let ht = empty.as_hashtable(); + /// assert!(ht.is_empty()); + /// assert!(ht.is_immutable()); + /// ``` + #[must_use] + pub fn as_hashtable(&self) -> &ZendHashTable { + // SAFETY: zend_empty_array is a static global initialized by PHP. + unsafe { &zend_empty_array } + } +} + +impl IntoZval for ZendEmptyArray { + const TYPE: DataType = DataType::Array; + const NULLABLE: bool = false; + + fn set_zval(self, zv: &mut Zval, _persistent: bool) -> Result<()> { + // Set the zval to point to the immutable shared empty array. + // This mirrors the ZVAL_EMPTY_ARRAY macro in PHP. + zv.u1.type_info = ZvalTypeFlags::Array.bits(); + zv.value.arr = ptr::from_ref(self.as_hashtable()).cast_mut(); + Ok(()) + } +} diff --git a/src/types/mod.rs b/src/types/mod.rs index ffbf52bf3..9cdda46c1 100644 --- a/src/types/mod.rs +++ b/src/types/mod.rs @@ -13,7 +13,7 @@ mod object; mod string; mod zval; -pub use array::{ArrayKey, ZendHashTable}; +pub use array::{ArrayKey, ZendEmptyArray, ZendHashTable}; pub use callable::ZendCallable; pub use class_object::ZendClassObject; pub use iterable::Iterable; diff --git a/src/types/zval.rs b/src/types/zval.rs index a96310bfa..ccfee006f 100644 --- a/src/types/zval.rs +++ b/src/types/zval.rs @@ -709,7 +709,14 @@ impl Zval { /// /// * `val` - The value to set the zval as. pub fn set_hashtable(&mut self, val: ZBox) { - self.change_type(ZvalTypeFlags::ArrayEx); + // Handle immutable shared arrays (e.g., the empty array) similar to + // ZVAL_EMPTY_ARRAY. Immutable arrays should not be reference counted. + let type_info = if val.is_immutable() { + ZvalTypeFlags::Array + } else { + ZvalTypeFlags::ArrayEx + }; + self.change_type(type_info); self.value.arr = val.into_raw(); } diff --git a/tests/src/integration/array/array.php b/tests/src/integration/array/array.php index 68c6532f7..cf5ede9c7 100644 --- a/tests/src/integration/array/array.php +++ b/tests/src/integration/array/array.php @@ -94,3 +94,33 @@ assert($mut_arr['added_by_rust'] === 'value', 'Added value should be correct'); $null_arr = null; assert(test_optional_array_mut_ref($null_arr) === -1, 'Option<&mut ZendHashTable> should accept null'); + +// Test ZendEmptyArray - returns an empty immutable shared array +$empty = test_empty_array(); +assert(is_array($empty), 'ZendEmptyArray should return an array'); +assert(count($empty) === 0, 'ZendEmptyArray should return an empty array'); + +// Verify we can add elements to a copy (proves it's transparent to userland) +$empty[] = 'added'; +assert(count($empty) === 1, 'Should be able to add elements to a copy of the empty array'); +assert($empty[0] === 'added', 'Added element should be accessible'); + +// Test is_immutable() for normal hashtables +assert(test_hashtable_is_immutable() === false, 'Normal ZendHashTable should not be immutable'); + +// Test is_immutable() for empty array +assert(test_empty_array_is_immutable() === true, 'Empty array from ZendEmptyArray should be immutable'); + +// Test returning empty Vec (should still work) +$empty_vec = test_empty_vec(); +assert(is_array($empty_vec), 'Empty Vec should return an array'); +assert(count($empty_vec) === 0, 'Empty Vec should return an empty array'); +$empty_vec[] = 42; +assert(count($empty_vec) === 1, 'Should be able to add elements to empty Vec result'); + +// Test returning empty HashMap (should still work) +$empty_hashmap = test_empty_hashmap(); +assert(is_array($empty_hashmap), 'Empty HashMap should return an array'); +assert(count($empty_hashmap) === 0, 'Empty HashMap should return an empty array'); +$empty_hashmap['key'] = 'value'; +assert(count($empty_hashmap) === 1, 'Should be able to add elements to empty HashMap result'); diff --git a/tests/src/integration/array/mod.rs b/tests/src/integration/array/mod.rs index 2e3f39be2..a48146c33 100644 --- a/tests/src/integration/array/mod.rs +++ b/tests/src/integration/array/mod.rs @@ -5,7 +5,7 @@ use ext_php_rs::{ ffi::HashTable, php_function, prelude::ModuleBuilder, - types::{ArrayKey, ZendHashTable, Zval}, + types::{ArrayKey, ZendEmptyArray, ZendHashTable, Zval}, wrap_function, }; @@ -60,6 +60,37 @@ pub fn test_optional_array_mut_ref(arr: Option<&mut ZendHashTable>) -> i64 { } } +/// Test returning an empty immutable array using `ZendEmptyArray` +#[php_function] +pub fn test_empty_array() -> ZendEmptyArray { + ZendEmptyArray +} + +/// Test that a normal `ZendHashTable` is not immutable +#[php_function] +pub fn test_hashtable_is_immutable() -> bool { + let ht = ZendHashTable::new(); + ht.is_immutable() +} + +/// Test that the empty array from `ZendEmptyArray` is immutable +#[php_function] +pub fn test_empty_array_is_immutable() -> bool { + ZendEmptyArray.as_hashtable().is_immutable() +} + +/// Test that returning an empty `Vec` still works (should allocate a new array) +#[php_function] +pub fn test_empty_vec() -> Vec { + Vec::new() +} + +/// Test that returning an empty `HashMap` still works (should allocate a new array) +#[php_function] +pub fn test_empty_hashmap() -> HashMap { + HashMap::new() +} + pub fn build_module(builder: ModuleBuilder) -> ModuleBuilder { builder .function(wrap_function!(test_array)) @@ -69,6 +100,11 @@ pub fn build_module(builder: ModuleBuilder) -> ModuleBuilder { .function(wrap_function!(test_array_keys)) .function(wrap_function!(test_optional_array_ref)) .function(wrap_function!(test_optional_array_mut_ref)) + .function(wrap_function!(test_empty_array)) + .function(wrap_function!(test_hashtable_is_immutable)) + .function(wrap_function!(test_empty_array_is_immutable)) + .function(wrap_function!(test_empty_vec)) + .function(wrap_function!(test_empty_hashmap)) } #[cfg(test)]