From c0447beb120c9ba1035e1cecef915bbe2d7ee4c2 Mon Sep 17 00:00:00 2001 From: Guillaume Loulier Date: Thu, 17 Sep 2026 23:31:02 +0200 Subject: [PATCH 1/3] feat(objects): a property default may hold array literals, at any depth class C { public array $x = [[1], [2]]; } // was: compile error Declaring the class was enough to be refused -- no read, no write, no instantiation -- while the same literal was already accepted as a local, a parameter default and a class constant. `LiteralArrayElement` is the set of things a default can materialize without evaluating code, and it was flat: Int, Bool, Float, Str, Null. A container element had nowhere to go, so it fell through to the unsupported-default error, which names the PROPERTY's type and so read as if the slot were at fault. It was never the slot: a plain `array` property failed exactly as `?array` and `mixed` did. Make the form recursive. A container element materializes into its own container, which the enclosing one then owns, so the tree is allocated with the object and released exactly once with it. The two enclosing paths transfer that ownership differently and both had to be taught it: the indexed path boxes into a Mixed cell with the OWNED boxer, so the box's retain plus the builder's release nets to a transfer; the hash path hands `__rt_hash_set` the pointer directly, because it does not retain what it stores -- it only releases what it overwrites. Also adds `BoxedAssocArray`, the keyed counterpart of `BoxedArray`. The issue's `?array $x = ["k" => [1]];` row was two independent gaps: a keyed literal had no boxed form at ALL, so `public ?array $x = ["k" => 1];` was refused on its own while `public ?array $x = [1, 2];` beside it compiled. Verified over the issue's table plus 13 further spellings, every one byte-identical to host PHP 8.5.10. Ownership measured rather than assumed: 600 objects carrying three different nested shapes closed at allocs=7401 frees=7401, `leak summary: clean`, with a flat 2832-byte peak. Instances do not share storage, and a copy taken out of a default outlives the object it came from. Out of scope, left as it was: `public iterable $x = ["k" => 1];` fails earlier and differently (`prop_set assigning PHP type AssocArray`), on main as much as here. Fixes #1052. Claude-Session: https://claude.ai/code/session_01KSAAWPyNBq6dP2b5puN3wr --- docs/php/classes.md | 4 +- examples/classes/main.php | 20 ++ src/codegen/block_emit.rs | 14 + src/codegen/literal_defaults.rs | 267 +++++++++++++++++- .../lower_inst/objects/property_defaults.rs | 18 ++ tests/codegen/objects.rs | 2 + .../objects/nested_array_property_defaults.rs | 133 +++++++++ tests/codegen/runtime_gc.rs | 2 + .../runtime_gc/nested_property_defaults.rs | 120 ++++++++ 9 files changed, 568 insertions(+), 12 deletions(-) create mode 100644 tests/codegen/objects/nested_array_property_defaults.rs create mode 100644 tests/codegen/runtime_gc/nested_property_defaults.rs diff --git a/docs/php/classes.md b/docs/php/classes.md index 1a4e2276bd..e05be7555b 100644 --- a/docs/php/classes.md +++ b/docs/php/classes.md @@ -263,7 +263,9 @@ An untyped property with no explicit default (`public $x;`, instance or static) Property default values are applied both for the normal `new ClassName()` form and for dynamic `new $variable()` instantiation (and therefore for runtime-instantiated stream wrappers and stream filters). When the class name resolves to a known class, dynamic instantiation follows the same allocation path as direct construction, so constructor arguments are evaluated and `__construct` runs normally. -An `array`-typed (or untyped) property may take an associative literal default such as `['a' => 1]`. The property is then stored as an associative array, so string-key reads and writes (`$this->data['a']`, `$this->data[$key]`) type-check and run like any other associative array. A positional literal default (`[1, 2, 3]`) keeps integer-keyed list storage. +An `array`-typed (or untyped) property may take an associative literal default such as `['a' => 1]`. The property is then stored as an associative array, so string-key reads and writes (`$this->data['a']`, `$this->data[$key]`) type-check and run like any other associative array. A positional literal default (`[1, 2, 3]`) keeps integer-keyed list storage. A `?array`, `mixed` or union-typed property takes either spelling too, stored as a boxed value. + +The elements may themselves be array literals, to any depth and in either spelling: `public array $grid = [[1, 2], [3, 4]];`, `public array $conf = ['db' => ['host' => 'localhost']];`, and mixtures such as `[[1], 2]` all initialize without running any code. Each nested container is allocated as part of the object's initialization and owned by the one enclosing it, so the whole tree is released exactly once with the object, and two instances never share storage — writing through `$a->grid[0][] = 9` leaves a second instance's default untouched, as in PHP. ```php inc(); $b->inc(); echo "a=" . $a->get() . " b=" . $b->get() . "\n"; + +// A property default may be an array literal whose elements are array literals, +// to any depth and in either spelling. Each nested container belongs to the +// object holding it, so two instances never share one. +class Grid { + public array $rows = [[1, 2], [3, 4]]; + public array $conf = ['db' => ['host' => 'localhost', 'port' => 5432]]; + + public function cell(int $row, int $col): int { + return $this->rows[$row][$col]; + } +} + +$g = new Grid(); +echo "cell(1,0)=" . $g->cell(1, 0) . "\n"; +echo "host=" . $g->conf['db']['host'] . ":" . $g->conf['db']['port'] . "\n"; + +$g->rows[0][] = 9; +$fresh = new Grid(); +echo "written=" . count($g->rows[0]) . " fresh=" . count($fresh->rows[0]) . "\n"; diff --git a/src/codegen/block_emit.rs b/src/codegen/block_emit.rs index f2c8314850..4f868c7215 100644 --- a/src/codegen/block_emit.rs +++ b/src/codegen/block_emit.rs @@ -1199,6 +1199,20 @@ fn emit_static_property_default_value( &PhpType::Array(Box::new(elem_type.clone())), ); } + LiteralDefaultValue::BoxedAssocArray { + value_type, + entries, + } => { + emit_assoc_array_literal_default_to_result(ctx, value_type, entries)?; + // The OWNED boxer, for the same reason as the positional arm above. + crate::codegen::emit_box_current_owned_value_as_mixed( + ctx.emitter, + &PhpType::AssocArray { + key: Box::new(PhpType::Mixed), + value: Box::new(value_type.clone()), + }, + ); + } } let symbol = static_property_symbol(class_name, property); abi::emit_store_result_to_symbol(ctx.emitter, &symbol, php_type, false); diff --git a/src/codegen/literal_defaults.rs b/src/codegen/literal_defaults.rs index c179d701e6..277cacfd33 100644 --- a/src/codegen/literal_defaults.rs +++ b/src/codegen/literal_defaults.rs @@ -82,9 +82,24 @@ pub(crate) enum LiteralDefaultValue { enum_name: String, case_name: String, }, + /// An associative-array literal stored into a `mixed`/union slot, boxed into a Mixed cell. + /// + /// The keyed counterpart of `BoxedArray`, and missing for the same reason it was: only the + /// positional spelling had been taught to box, so `public ?array $x = ["k" => 1];` was + /// refused outright while `public ?array $x = [1, 2];` beside it compiled. + BoxedAssocArray { + value_type: PhpType, + entries: Vec, + }, } /// Literal indexed-array element that can be materialized without evaluating code. +/// +/// The two container forms make this recursive, which is what lets a default nest: +/// `public array $x = [[1], [2]];` is an outer literal whose elements are themselves literals. +/// Each container element allocates its own storage at emit time and hands that storage to the +/// enclosing container, so the whole tree is owned by its root and released exactly once with +/// it (issue #1052). #[derive(Clone)] pub(crate) enum LiteralArrayElement { Int(i64), @@ -92,6 +107,29 @@ pub(crate) enum LiteralArrayElement { Float(f64), Str(String), Null, + /// A nested indexed-array literal, materialized into its own array. + Array { + elem_type: PhpType, + elements: Vec, + }, + /// A nested associative-array literal, materialized into its own hash. + AssocArray { + value_type: PhpType, + entries: Vec, + }, +} + +impl LiteralArrayElement { + /// Returns true for the forms that materialize into their own freshly allocated container. + /// + /// Such a value arrives owned, so the enclosing container must TRANSFER that reference + /// rather than add one; a scalar element has no reference to transfer. + fn is_owned_container(&self) -> bool { + matches!( + self, + LiteralArrayElement::Array { .. } | LiteralArrayElement::AssocArray { .. } + ) + } } /// Literal associative-array key that can be materialized without evaluating code. Positional @@ -197,6 +235,30 @@ pub(crate) fn literal_default_value( elements, }) } + // The keyed spelling of the arm above. Values are typed `Mixed` for the same reason the + // positional elements are: the slot is `mixed`, so a later write of any type into the + // hash must not find a narrower value type stamped underneath. + (PhpType::Mixed | PhpType::Union(_), ExprKind::ArrayLiteralAssoc(items)) => { + let value_type = PhpType::Mixed; + let entries = items + .iter() + .map(|(key, value_expr)| { + Ok(LiteralAssocEntry { + key: literal_array_key(context, &key.kind, op_name)?, + value: literal_array_element( + context, + &value_type, + &value_expr.kind, + op_name, + )?, + }) + }) + .collect::>>()?; + Ok(LiteralDefaultValue::BoxedAssocArray { + value_type, + entries, + }) + } (PhpType::Void | PhpType::Never, ExprKind::Null) => Ok(LiteralDefaultValue::NullSentinel), (PhpType::Void | PhpType::Never, _) => Ok(LiteralDefaultValue::NullSentinel), (PhpType::Object(_), ExprKind::Null) => Ok(LiteralDefaultValue::Null), @@ -380,8 +442,13 @@ pub(super) fn emit_array_literal_default_to_result( emit_array_literal_allocation(ctx, elem_type, elements.len())?; abi::emit_push_reg(ctx.emitter, abi::int_result_reg(ctx.emitter)); for element in elements { - let value_type = emit_array_element_value(ctx, element); - append_array_literal_element(ctx, elem_type, &value_type)?; + let value_type = emit_array_element_value(ctx, element)?; + append_array_literal_element( + ctx, + elem_type, + &value_type, + element.is_owned_container(), + )?; } abi::emit_pop_reg(ctx.emitter, abi::int_result_reg(ctx.emitter)); Ok(()) @@ -404,7 +471,7 @@ pub(crate) fn emit_assoc_array_literal_default_to_result( Arch::AArch64 => { materialize_assoc_literal_key_aarch64(ctx, &entry.key); abi::emit_push_reg_pair(ctx.emitter, "x1", "x2"); - let actual_value_type = emit_array_element_value(ctx, &entry.value); + let actual_value_type = emit_array_element_value(ctx, &entry.value)?; materialize_assoc_literal_value(ctx, value_type, &actual_value_type)?; abi::emit_pop_reg_pair(ctx.emitter, "x1", "x2"); abi::emit_pop_reg(ctx.emitter, "x0"); @@ -417,7 +484,7 @@ pub(crate) fn emit_assoc_array_literal_default_to_result( Arch::X86_64 => { materialize_assoc_literal_key_x86_64(ctx, &entry.key); abi::emit_push_reg_pair(ctx.emitter, "rsi", "rdx"); - let actual_value_type = emit_array_element_value(ctx, &entry.value); + let actual_value_type = emit_array_element_value(ctx, &entry.value)?; materialize_assoc_literal_value(ctx, value_type, &actual_value_type)?; abi::emit_pop_reg_pair(ctx.emitter, "rsi", "rdx"); abi::emit_pop_reg(ctx.emitter, "rdi"); @@ -486,6 +553,45 @@ fn literal_array_element( ExprKind::FloatLiteral(value) => Ok(LiteralArrayElement::Float(*value)), ExprKind::StringLiteral(value) => Ok(LiteralArrayElement::Str(value.clone())), ExprKind::Null => Ok(LiteralArrayElement::Null), + // A nested literal recurses rather than being refused. Only a Mixed-capable slot + // reaches here with a container: a `Str`/`Int`/`Float` element type cannot hold one, + // and falls through to the unsupported error below as before. The nested elements are + // typed `Mixed` for the same reason the outer ones are -- the container has to accept + // a later write of any type. + ExprKind::ArrayLiteral(items) => { + let inner_elem_type = PhpType::Mixed; + let elements = items + .iter() + .map(|item| { + literal_array_element(context, &inner_elem_type, &item.kind, op_name) + }) + .collect::>>()?; + Ok(LiteralArrayElement::Array { + elem_type: inner_elem_type, + elements, + }) + } + ExprKind::ArrayLiteralAssoc(items) => { + let inner_value_type = PhpType::Mixed; + let entries = items + .iter() + .map(|(key, value_expr)| { + Ok(LiteralAssocEntry { + key: literal_array_key(context, &key.kind, op_name)?, + value: literal_array_element( + context, + &inner_value_type, + &value_expr.kind, + op_name, + )?, + }) + }) + .collect::>>()?; + Ok(LiteralArrayElement::AssocArray { + value_type: inner_value_type, + entries, + }) + } ExprKind::Negate(inner) => match &inner.kind { ExprKind::IntLiteral(value) => value .checked_neg() @@ -525,6 +631,76 @@ fn literal_array_element( ExprKind::StringLiteral(value) => Ok(LiteralArrayElement::Str(value.clone())), _ => Err(unsupported_literal_default(context, elem_type, op_name)), }, + // A container element type reached through a KEYED outer literal: `["k" => [1]]` infers + // the outer hash's value type from the literal, so the element type is already + // `array<...>` rather than `Mixed`. The nested elements keep that inferred type instead + // of collapsing to `Mixed`, so the inner container is stamped the way a read of it will + // expect. + PhpType::Array(inner_elem_type) => match expr { + ExprKind::ArrayLiteral(items) => { + let inner_elem_type = inner_elem_type.codegen_repr(); + let elements = items + .iter() + .map(|item| { + literal_array_element(context, &inner_elem_type, &item.kind, op_name) + }) + .collect::>>()?; + Ok(LiteralArrayElement::Array { + elem_type: inner_elem_type, + elements, + }) + } + _ => Err(unsupported_literal_default(context, elem_type, op_name)), + }, + PhpType::AssocArray { value, .. } => { + let inner_value_type = value.as_ref().codegen_repr(); + match expr { + ExprKind::ArrayLiteralAssoc(items) => { + let entries = items + .iter() + .map(|(key, value_expr)| { + Ok(LiteralAssocEntry { + key: literal_array_key(context, &key.kind, op_name)?, + value: literal_array_element( + context, + &inner_value_type, + &value_expr.kind, + op_name, + )?, + }) + }) + .collect::>>()?; + Ok(LiteralArrayElement::AssocArray { + value_type: inner_value_type, + entries, + }) + } + // A positional literal stored into hash storage takes PHP's implicit 0,1,2,… + // keys, the same rule the top-level `AssocArray` arm applies. + ExprKind::ArrayLiteral(items) => { + let entries = items + .iter() + .enumerate() + .map(|(index, item)| { + Ok(LiteralAssocEntry { + key: LiteralArrayKey::Int(index as i64), + value: literal_array_element( + context, + &inner_value_type, + &item.kind, + op_name, + )?, + }) + }) + .collect::>>()?; + Ok(LiteralArrayElement::AssocArray { + value_type: inner_value_type, + entries, + }) + } + _ => Err(unsupported_literal_default(context, elem_type, op_name)), + } + } _ => Err(unsupported_literal_default(context, elem_type, op_name)), } } @@ -575,30 +751,35 @@ fn emit_array_literal_allocation( } /// Emits one literal array element into the canonical result register(s). +/// +/// A container element recurses into the same emitter the enclosing container used, leaving a +/// freshly allocated OWNED container in the result register. Both callers stage the enclosing +/// container's pointer on the stack around this call, and the nested emitters' own stack traffic +/// is balanced, so the nesting is safe to any depth. fn emit_array_element_value( ctx: &mut FunctionContext<'_>, element: &LiteralArrayElement, -) -> PhpType { +) -> Result { match element { LiteralArrayElement::Int(value) => { abi::emit_load_int_immediate(ctx.emitter, abi::int_result_reg(ctx.emitter), *value); - PhpType::Int + Ok(PhpType::Int) } LiteralArrayElement::Bool(value) => { abi::emit_load_int_immediate(ctx.emitter, abi::int_result_reg(ctx.emitter), i64::from(*value)); - PhpType::Bool + Ok(PhpType::Bool) } LiteralArrayElement::Float(value) => { let label = ctx.data.add_float(*value); abi::emit_load_symbol_to_reg(ctx.emitter, abi::float_result_reg(ctx.emitter), &label, 0); - PhpType::Float + Ok(PhpType::Float) } LiteralArrayElement::Str(value) => { let (label, len) = ctx.data.add_string(value.as_bytes()); let (ptr_reg, len_reg) = abi::string_result_regs(ctx.emitter); abi::emit_symbol_address(ctx.emitter, ptr_reg, &label); abi::emit_load_int_immediate(ctx.emitter, len_reg, len as i64); - PhpType::Str + Ok(PhpType::Str) } LiteralArrayElement::Null => { abi::emit_load_int_immediate( @@ -606,20 +787,52 @@ fn emit_array_element_value( abi::int_result_reg(ctx.emitter), 0x7fff_ffff_ffff_fffe, ); - PhpType::Void + Ok(PhpType::Void) + } + LiteralArrayElement::Array { + elem_type, + elements, + } => { + emit_array_literal_default_to_result(ctx, elem_type, elements)?; + Ok(PhpType::Array(Box::new(elem_type.clone()))) + } + LiteralArrayElement::AssocArray { + value_type, + entries, + } => { + emit_assoc_array_literal_default_to_result(ctx, value_type, entries)?; + Ok(PhpType::AssocArray { + key: Box::new(PhpType::Mixed), + value: Box::new(value_type.clone()), + }) } } } /// Appends the current literal element value to the array pointer saved on the stack. +/// +/// `value_is_owned` distinguishes the two ways a value can arrive. A scalar has no reference, +/// so boxing it retains whatever the box needs and nothing is left over. A nested container +/// arrives with the +1 `__rt_array_new`/`__rt_hash_new` gave it and no other holder, so boxing +/// must TRANSFER that reference: the retaining box plus a release of the original leaves the +/// cell as the single owner. Retaining without releasing would leak the whole nested tree once +/// per element. fn append_array_literal_element( ctx: &mut FunctionContext<'_>, elem_type: &PhpType, value_type: &PhpType, + value_is_owned: bool, ) -> Result<()> { match elem_type.codegen_repr() { PhpType::Mixed | PhpType::Union(_) | PhpType::Iterable => { - emit_box_current_value_as_mixed(ctx.emitter, &value_type.codegen_repr()); + if value_is_owned { + crate::codegen::emit_box_current_owned_value_as_mixed( + ctx.emitter, + &value_type.codegen_repr(), + ); + } else { + emit_box_current_value_as_mixed(ctx.emitter, &value_type.codegen_repr()); + } append_refcounted_array_literal_element(ctx, &PhpType::Mixed); } PhpType::Int | PhpType::Bool => append_scalar_array_literal_element(ctx), @@ -762,6 +975,14 @@ fn materialize_assoc_literal_value_aarch64( ctx.emitter.instruction("mov x4, xzr"); // null hash values use a zero high payload word Ok(()) } + // A nested container arrives as an owned heap pointer. `__rt_hash_set` does not retain + // what it stores -- it only releases what it OVERWRITES -- so handing it the pointer is + // itself the ownership transfer, and releasing afterwards here would free a live child. + PhpType::Array(_) | PhpType::AssocArray { .. } => { + ctx.emitter.instruction("mov x3, x0"); // pass the nested container pointer as the hash value low word + ctx.emitter.instruction("mov x4, xzr"); // container hash values do not use the high payload word + Ok(()) + } other => Err(CodegenIrError::unsupported(format!( "assoc array default element PHP type {:?}", other @@ -800,6 +1021,14 @@ fn materialize_assoc_literal_value_x86_64( ctx.emitter.instruction("xor r8, r8"); // null hash values use a zero high payload word Ok(()) } + // A nested container arrives as an owned heap pointer. `__rt_hash_set` does not retain + // what it stores -- it only releases what it OVERWRITES -- so handing it the pointer is + // itself the ownership transfer, and releasing afterwards here would free a live child. + PhpType::Array(_) | PhpType::AssocArray { .. } => { + ctx.emitter.instruction("mov rcx, rax"); // pass the nested container pointer as the hash value low word + ctx.emitter.instruction("xor r8, r8"); // container hash values do not use the high payload word + Ok(()) + } other => Err(CodegenIrError::unsupported(format!( "assoc array default element PHP type {:?}", other @@ -834,6 +1063,14 @@ fn materialize_assoc_literal_concrete_value_aarch64( ctx.emitter.instruction("mov x4, x2"); // pass the string length as the Mixed hash value high word Ok(()) } + // Mixed-capable hash storage keeps the concrete kind in the entry's TAG word, so a + // container is stored as its raw owned pointer under tag 4/5 rather than wrapped in a + // Mixed cell -- the same shape a concrete string takes under tag 1. + PhpType::Array(_) | PhpType::AssocArray { .. } => { + ctx.emitter.instruction("mov x3, x0"); // pass the nested container pointer as the Mixed hash value low word + ctx.emitter.instruction("mov x4, xzr"); // container Mixed values do not use the high payload word + Ok(()) + } other => Err(CodegenIrError::unsupported(format!( "assoc array default Mixed element PHP type {:?}", other @@ -868,6 +1105,14 @@ fn materialize_assoc_literal_concrete_value_x86_64( ctx.emitter.instruction("mov r8, rdx"); // pass the string length as the Mixed hash value high word Ok(()) } + // Mixed-capable hash storage keeps the concrete kind in the entry's TAG word, so a + // container is stored as its raw owned pointer under tag 4/5 rather than wrapped in a + // Mixed cell -- the same shape a concrete string takes under tag 1. + PhpType::Array(_) | PhpType::AssocArray { .. } => { + ctx.emitter.instruction("mov rcx, rax"); // pass the nested container pointer as the Mixed hash value low word + ctx.emitter.instruction("xor r8, r8"); // container Mixed values do not use the high payload word + Ok(()) + } other => Err(CodegenIrError::unsupported(format!( "assoc array default Mixed element PHP type {:?}", other diff --git a/src/codegen/lower_inst/objects/property_defaults.rs b/src/codegen/lower_inst/objects/property_defaults.rs index bbc032546e..cd728cea02 100644 --- a/src/codegen/lower_inst/objects/property_defaults.rs +++ b/src/codegen/lower_inst/objects/property_defaults.rs @@ -258,6 +258,24 @@ pub(super) fn emit_property_default( abi::emit_store_to_address(ctx.emitter, int_reg, object_reg, default.offset); abi::emit_store_zero_to_address(ctx.emitter, object_reg, default.offset + 8); } + LiteralDefaultValue::BoxedAssocArray { + value_type, + entries, + } => { + abi::emit_push_reg(ctx.emitter, object_reg); + emit_assoc_array_literal_default_to_result(ctx, value_type, entries)?; + crate::codegen::emit_box_current_owned_value_as_mixed( + ctx.emitter, + &PhpType::AssocArray { + key: Box::new(PhpType::Mixed), + value: Box::new(value_type.clone()), + }, + ); + abi::emit_pop_reg(ctx.emitter, object_reg); + let int_reg = abi::int_result_reg(ctx.emitter); + abi::emit_store_to_address(ctx.emitter, int_reg, object_reg, default.offset); + abi::emit_store_zero_to_address(ctx.emitter, object_reg, default.offset + 8); + } LiteralDefaultValue::EmptyAssocArray { value_type } => { abi::emit_push_reg(ctx.emitter, object_reg); emit_empty_assoc_array_literal_to_result(ctx, value_type); diff --git a/tests/codegen/objects.rs b/tests/codegen/objects.rs index a6fc336edc..061035ae0b 100644 --- a/tests/codegen/objects.rs +++ b/tests/codegen/objects.rs @@ -26,6 +26,8 @@ mod constructor_promotion; mod static_properties; #[path = "objects/untyped_property_defaults.rs"] mod untyped_property_defaults; +#[path = "objects/nested_array_property_defaults.rs"] +mod nested_array_property_defaults; #[path = "objects/nested_arrays.rs"] mod nested_arrays; #[path = "objects/nullable_dispatch.rs"] diff --git a/tests/codegen/objects/nested_array_property_defaults.rs b/tests/codegen/objects/nested_array_property_defaults.rs new file mode 100644 index 0000000000..934f76470b --- /dev/null +++ b/tests/codegen/objects/nested_array_property_defaults.rs @@ -0,0 +1,133 @@ +//! Purpose: +//! Integration tests for property defaults whose ELEMENTS are themselves array literals: +//! `public array $x = [[1], [2]];` and its keyed, nullable, mixed and static spellings. Every +//! one of these was refused at compile time until the literal-default form became recursive, +//! even though the same literal was already accepted as a local, a parameter default and a +//! class constant. +//! +//! Called from: +//! - `cargo test` through Rust's test harness. +//! +//! Key details: +//! - Inline PHP fixtures are compiled to native binaries and assertions compare stdout. +//! - Expected values are real `LC_ALL=C php` 8.5 output for the same fixtures. +//! - Ownership of the nested containers is covered separately under `runtime_gc/`. + +use super::*; + +/// The issue's headline shape: an indexed default whose elements are indexed literals. +#[test] +fn test_indexed_property_default_with_indexed_literal_elements() { + let out = compile_and_run( + r#"x), "|", $c->x[0][0], "|", $c->x[1][0]; +"#, + ); + assert_eq!(out, "2|1|2"); +} + +/// The same default on the nullable and `mixed` slots, which box the outer container. +/// +/// The issue's table lists all three, and notes that a plain `array` property failed exactly +/// as a `?array` or `mixed` one did -- it was never about the slot. +#[test] +fn test_nested_literal_default_on_nullable_and_mixed_slots() { + let out = compile_and_run( + r#"x), $n->x[1][0], "|", count($m->x), $m->x[1][0]; +"#, + ); + assert_eq!(out, "22|22"); +} + +/// The keyed spellings, on the plain `array` slot and on the boxed ones. +/// +/// `public ?array $x = ["k" => 1];` was refused on its own too -- a keyed literal had no boxed +/// form at all, only the positional one -- so the keyed nullable row of the issue's table was +/// two independent gaps, not one. +#[test] +fn test_keyed_property_defaults_including_nested_and_boxed() { + let out = compile_and_run( + r#" [1]]; } +class B { public ?array $x = ["k" => 1]; } +class D { public mixed $x = ["k" => [1]]; } +class E { public array $x = ["k" => ["j" => 7]]; } +$a = new A(); +$b = new B(); +$d = new D(); +$e = new E(); +echo $a->x["k"][0], "|", $b->x["k"], "|", $d->x["k"][0], "|", $e->x["k"]["j"]; +"#, + ); + assert_eq!(out, "1|1|1|7"); +} + +/// Depth beyond one level, and elements that mix containers with scalars. +/// +/// The recursion has no depth limit of its own, and an element list does not have to be +/// uniform: `[[1], 2]` is an array element beside an int element in the same literal. +#[test] +fn test_nested_property_defaults_nest_deeply_and_mix_element_kinds() { + let out = compile_and_run( + r#"deep[0][0][0], "|", count($c->mixedKinds), $c->mixedKinds[1], "|", + count($c->scalars[0]), $c->scalars[0][1], "|", count($c->empty[0]); +"#, + ); + assert_eq!(out, "1|22|5s|0"); +} + +/// A static property takes the same nested default. +/// +/// Static and instance defaults go through separate emitters (`block_emit` and +/// `property_defaults`), so a fix applied to one does not reach the other. +#[test] +fn test_static_property_default_with_nested_literal_elements() { + let out = compile_and_run( + r#"x[0][] = 99; +echo count($a->x[0]), count($b->x[0]), "|"; +$c = new A(); +$inner = $c->x[0]; +unset($c); +$inner[] = 7; +$d = new A(); +echo $inner[0], count($inner), count($d->x[0]); +"#, + ); + assert_eq!(out, "21|121"); +} diff --git a/tests/codegen/runtime_gc.rs b/tests/codegen/runtime_gc.rs index 8a90fc8ec8..235e0cc6cb 100644 --- a/tests/codegen/runtime_gc.rs +++ b/tests/codegen/runtime_gc.rs @@ -35,6 +35,8 @@ mod by_ref_place_args; mod omitted_by_ref_default_args; #[path = "runtime_gc/foreach_object_source.rs"] mod foreach_object_source; +#[path = "runtime_gc/nested_property_defaults.rs"] +mod nested_property_defaults; #[path = "runtime_gc/stack_args.rs"] mod stack_args; #[path = "runtime_gc/cow_and_cycles.rs"] diff --git a/tests/codegen/runtime_gc/nested_property_defaults.rs b/tests/codegen/runtime_gc/nested_property_defaults.rs new file mode 100644 index 0000000000..cb3b0aaa13 --- /dev/null +++ b/tests/codegen/runtime_gc/nested_property_defaults.rs @@ -0,0 +1,120 @@ +//! Purpose: +//! Heap-debug coverage for property defaults whose elements are themselves array literals. +//! Each nested container is allocated during the object's initialization and handed to the +//! container enclosing it, so the whole tree has to be owned by its root and released exactly +//! once with the object. +//! +//! Called from: +//! - `cargo test` through Rust's test harness. +//! +//! Key details: +//! - Each fixture runs under `--heap-debug` and asserts `leak summary: clean`. The two ways to +//! get the transfer wrong land on opposite sides of that assertion: retaining the child +//! without releasing the builder's reference leaks the whole subtree once per object, and +//! releasing it without the retain frees a child the object still points at, which shows up +//! as corrupted reads or a double free rather than as a leak -- so the fixtures read the +//! values back as well. +//! - The loops allocate hundreds of objects, so a per-object leak cannot hide in heap slack. +//! - Expected stdout values are real `LC_ALL=C php` 8.5 output for the same fixtures. + +use crate::support::compile_and_run_with_heap_debug; + +/// Asserts the program printed `expected` and left a clean heap under heap debug. +fn assert_clean(out: crate::support::ProgramOutput, expected: &str) { + assert_eq!(out.stdout, expected, "stderr: {}", out.stderr); + assert!( + out.stderr.contains("HEAP DEBUG: leak summary: clean"), + "expected clean heap, got: {}", + out.stderr + ); +} + +/// An indexed default holding indexed literals releases its whole tree with the object. +#[test] +fn test_indexed_nested_property_default_releases_with_the_object() { + let out = compile_and_run_with_heap_debug( + r#"x) + $a->x[1][0]; + unset($a); +} +echo $t; +"#, + ); + assert_clean(out, "1200"); +} + +/// The keyed and boxed spellings release too, including a tree that mixes both. +/// +/// The hash path transfers ownership differently from the indexed one -- `__rt_hash_set` takes +/// the value it stores rather than retaining it, where the indexed path boxes into a Mixed cell +/// and releases the builder's reference -- so a single fixture cannot cover both. +#[test] +fn test_keyed_and_boxed_nested_property_defaults_release_with_the_object() { + let out = compile_and_run_with_heap_debug( + r#" [1, 2], "b" => ["c" => "d"], "e" => 3]; } +class C { public array $x = ["k" => ["j" => 7]]; } +$t = 0; +for ($i = 0; $i < 300; $i++) { + $b = new B(); + $t += count($b->x) + $b->x["a"][1]; + unset($b); + $c = new C(); + $t += $c->x["k"]["j"]; + unset($c); +} +echo $t; +"#, + ); + assert_clean(out, "3600"); +} + +/// A deep tree with string, float and null leaves stays balanced. +/// +/// Strings are the leaf that can go wrong independently: they are persisted rather than +/// refcounted like a container, so a tree carrying both has to get two ownership rules right +/// at once. +#[test] +fn test_deep_nested_property_default_with_mixed_leaves_releases_cleanly() { + let out = compile_and_run_with_heap_debug( + r#"x[0][0]); + unset($d); +} +echo $t; +"#, + ); + assert_clean(out, "1500"); +} + +/// A copy taken out of the default outlives the object it came from. +/// +/// This is the over-release side of the contract: if the object's release freed a child the +/// copy still holds, reading the copy afterwards reads freed memory. The loop repeats it so a +/// recycled block would come back with someone else's contents. +#[test] +fn test_copy_of_a_nested_default_survives_its_object() { + let out = compile_and_run_with_heap_debug( + r#"x[0]; + unset($a); + $t += $inner[0] + count($inner); + unset($inner); +} +echo $t; +"#, + ); + assert_clean(out, "600"); +} From 7b4328e1da69e8d27f47fe8e793ee295d976091d Mon Sep 17 00:00:00 2001 From: Guillaume Loulier Date: Fri, 18 Sep 2026 17:05:29 +0200 Subject: [PATCH 2/3] test(objects): cover the STATIC keyed boxed-default path Raised in review. The static fixture used a positional default on a plain `array` slot, which never reaches the keyed boxing emitter: `block_emit`'s static path is separate from `property_defaults`' instance one, and the `BoxedAssocArray` arm is separate again from the `BoxedArray` arm beside it. A regression in the static keyed boxing or its ownership would not have been caught. Covers a nullable flat keyed default, a `mixed` slot holding a nested indexed literal, and a two-level keyed one. The flat spelling is included because it was refused on its own before this change, independently of any nesting. Claude-Session: https://claude.ai/code/session_01KSAAWPyNBq6dP2b5puN3wr --- .../objects/nested_array_property_defaults.rs | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/codegen/objects/nested_array_property_defaults.rs b/tests/codegen/objects/nested_array_property_defaults.rs index 934f76470b..cf593a06a5 100644 --- a/tests/codegen/objects/nested_array_property_defaults.rs +++ b/tests/codegen/objects/nested_array_property_defaults.rs @@ -106,6 +106,30 @@ echo count(S::$s), "|", S::$s[1][0]; assert_eq!(out, "2|2"); } +/// A STATIC property with a KEYED default on a boxed slot takes the `BoxedAssocArray` path. +/// +/// Raised in review: the static fixture above uses a positional default on a plain `array` +/// slot, which never reaches the keyed boxing emitter in `block_emit`. That emitter is separate +/// from the instance one in `property_defaults`, and separate again from the positional +/// `BoxedArray` arm beside it, so nothing here covered it. The flat keyed spelling is included +/// because it was refused on its own before this change, independently of any nesting. +#[test] +fn test_static_keyed_defaults_on_boxed_slots() { + let out = compile_and_run( + r#" 1]; + public static mixed $nested = ["k" => [1, 2]]; + public static ?array $deep = ["a" => ["b" => "c"]]; +} +echo S::$flat["k"], "|", + count(S::$nested["k"]), S::$nested["k"][1], "|", + S::$deep["a"]["b"]; +"#, + ); + assert_eq!(out, "1|22|c"); +} + /// Two instances hold separate storage, and a copy outlives the object it came from. /// /// Each nested container is allocated per object, so a write through one instance's default From 2b52d44627e66257fa09e4c174cd125149be21d4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 21 Sep 2026 10:27:03 +0000 Subject: [PATCH 3/3] docs(classes): drop the duplicated nullable/union array-default sentence Co-authored-by: Vincenzo Petrucci --- docs/php/classes.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/php/classes.md b/docs/php/classes.md index 81af34c027..dee4c59862 100644 --- a/docs/php/classes.md +++ b/docs/php/classes.md @@ -264,11 +264,12 @@ An untyped property with no explicit default (`public $x;`, instance or static) Property default values are applied both for the normal `new ClassName()` form and for dynamic `new $variable()` instantiation (and therefore for runtime-instantiated stream wrappers and stream filters). When the class name resolves to a known class, dynamic instantiation follows the same allocation path as direct construction, so constructor arguments are evaluated and `__construct` runs normally. -An `array`-typed (or untyped) property may take an associative literal default such as `['a' => 1]`. The property is then stored as an associative array, so string-key reads and writes (`$this->data['a']`, `$this->data[$key]`) type-check and run like any other associative array. A positional literal default (`[1, 2, 3]`) keeps integer-keyed list storage. A `?array`, `mixed` or union-typed property takes either spelling too, stored as a boxed value. +An `array`-typed (or untyped) property may take an associative literal default such as `['a' => 1]`. The property is then stored as an associative array, so string-key reads and writes (`$this->data['a']`, `$this->data[$key]`) type-check and run like any other associative array. A positional literal default (`[1, 2, 3]`) keeps integer-keyed list storage. The elements may themselves be array literals, to any depth and in either spelling: `public array $grid = [[1, 2], [3, 4]];`, `public array $conf = ['db' => ['host' => 'localhost']];`, and mixtures such as `[[1], 2]` all initialize without running any code. Each nested container is allocated as part of the object's initialization and owned by the one enclosing it, so the whole tree is released exactly once with the object, and two instances never share storage — writing through `$a->grid[0][] = 9` leaves a second instance's default untouched, as in PHP. A **nullable or union** array property takes either literal too — `public ?array $x = [1, 2];` and `public ?array $x = ['k' => 1];` both initialize, as do the `mixed` and `array|string` spellings. The value is boxed the way any other `mixed` payload is, so the slot can later hold `null` or a non-array without changing representation. + ```php