diff --git a/docs/php/classes.md b/docs/php/classes.md index 9d3ce30880..dee4c59862 100644 --- a/docs/php/classes.md +++ b/docs/php/classes.md @@ -266,9 +266,9 @@ Property default values are applied both for the normal `new ClassName()` form a 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 **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. +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 default whose ELEMENTS are themselves array literals (`public array $x = [[1], [2]];`) is not supported yet and reports a compile error; assign it in the constructor instead. The same literal is accepted everywhere else — as a local, a parameter default, or a class constant. +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 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"; + // One declaration can introduce several properties or constants, separated by commas. The type // and every modifier belong to the whole list; each name carries its own initializer. class Viewport diff --git a/src/codegen/literal_defaults.rs b/src/codegen/literal_defaults.rs index 13c99f36f4..0bdea9db3c 100644 --- a/src/codegen/literal_defaults.rs +++ b/src/codegen/literal_defaults.rs @@ -8,10 +8,12 @@ //! //! Key details: //! - This is intentionally narrower than full PHP expression lowering: only -//! scalar, string, null, indexed-array literals with scalar/string/null -//! elements, empty object-typed indexed arrays, and associative-array literals -//! (empty, positional, or with constant integer/string keys and scalar/string/null -//! values) land here. +//! scalar, string, null, indexed-array literals, empty object-typed indexed +//! arrays, and associative-array literals (empty, positional, or with constant +//! integer/string keys) land here. Array elements and associative values are +//! themselves scalar/string/null or nested array literals, in either spelling +//! and to any depth; each nested container is allocated with, and owned by, the +//! container enclosing it. //! - The declared PHP type selects the storage shape, and slot-shape arms must precede the //! generic `Mixed`/`Union(_)` boxing arms. A null-capable int slot (`?int` under //! `NullRepr::Tagged`) is an inline two-word `{payload, tag}` TaggedScalar, so it takes @@ -95,6 +97,12 @@ pub(crate) enum LiteralDefaultValue { } /// 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), @@ -102,6 +110,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 @@ -209,7 +240,9 @@ pub(crate) fn literal_default_value( } // The keyed spelling of the arm above. PHP has no separate associative array type, so // `["k" => 1]` in a `?array` slot is the same default as `[1, 2]` is; only the storage - // the literal needs differs, and hash storage is what a string key requires. + // the literal needs differs, and hash storage is what a string key requires. 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 underneath. (PhpType::Mixed | PhpType::Union(_), ExprKind::ArrayLiteralAssoc(items)) => { let value_type = PhpType::Mixed; let entries = items @@ -414,8 +447,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(()) @@ -438,7 +476,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"); @@ -451,7 +489,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"); @@ -520,6 +558,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() @@ -559,6 +636,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)), } } @@ -609,30 +756,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( @@ -640,20 +792,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), @@ -796,6 +980,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 @@ -834,6 +1026,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 @@ -868,6 +1068,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 @@ -902,6 +1110,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/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..cf593a06a5 --- /dev/null +++ b/tests/codegen/objects/nested_array_property_defaults.rs @@ -0,0 +1,157 @@ +//! 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#" 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 +/// must not reach another's. PHP's value semantics say the same, and the copy taken out of a +/// destroyed object has to stay readable -- which it cannot if the object's release freed a +/// child the copy still holds. +#[test] +fn test_nested_property_defaults_do_not_share_storage_between_instances() { + 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 87a00bbaef..aeb50a20fc 100644 --- a/tests/codegen/runtime_gc.rs +++ b/tests/codegen/runtime_gc.rs @@ -5,7 +5,7 @@ //! - `cargo test` through Rust's test harness. //! //! Key details: -//! - Submodules group focused fixtures for basics, regressions, stack args, copy-on-write and cycle handling, growth, related suites, resource scope-cleanup, by-reference builtin arguments that name a property, static property, or container element, calls that OMIT an optional by-reference argument (whose caller-side cell nothing reads back), the reference a `foreach` loop holds on an object source, the container an array literal allocates when it defaults a boxed property, and read-modify-write stores into a typed static property or a property array element. +//! - Submodules group focused fixtures for basics, regressions, stack args, copy-on-write and cycle handling, growth, related suites, resource scope-cleanup, by-reference builtin arguments that name a property, static property, or container element, calls that OMIT an optional by-reference argument (whose caller-side cell nothing reads back), the reference a `foreach` loop holds on an object source, the containers an array literal allocates when it defaults a property, boxed or nested, and read-modify-write stores into a typed static property or a property array element. #[path = "runtime_gc/basics.rs"] mod basics; @@ -41,6 +41,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/spread_promotion.rs"] mod spread_promotion; #[path = "runtime_gc/stack_args.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"); +}