diff --git a/src/ir_lower/context.rs b/src/ir_lower/context.rs index d51a18deaa..a1e7ea421b 100644 --- a/src/ir_lower/context.rs +++ b/src/ir_lower/context.rs @@ -1498,7 +1498,7 @@ impl<'m, 'f> LoweringContext<'m, 'f> { } /// Returns whether the value is a read from a one-shot hidden expression temp. - fn value_is_owned_temp_load(&self, value: ValueId) -> bool { + pub(crate) fn value_is_owned_temp_load(&self, value: ValueId) -> bool { let Some(inst) = self.builder.value_defining_instruction(value) else { return false; }; diff --git a/src/ir_lower/expr/mod.rs b/src/ir_lower/expr/mod.rs index 0d7403d15b..29a410d1c3 100644 --- a/src/ir_lower/expr/mod.rs +++ b/src/ir_lower/expr/mod.rs @@ -8594,6 +8594,9 @@ fn lower_array_access_from_value( }; let result_type = array_access_result_type(ctx, array_value.value, op, expr); let release_receiver = ctx.value_is_owned_index_read_temp(array_value); + let release_owned_mixed_temp_receiver = op == Op::RuntimeCall + && ctx.builder.value_php_type(array_value.value).codegen_repr() == PhpType::Mixed + && ctx.value_is_owned_temp_load(array_value.value); let mut result = ctx.emit_value( op, vec![array_value.value, index_value.value], @@ -8624,6 +8627,27 @@ fn lower_array_access_from_value( if release_receiver { crate::ir_lower::ownership::release_if_owned(ctx, array_value, Some(expr.span)); } + // A receiver materialized through a one-shot hidden owned temp (e.g. the + // merge temp `lower_nullable_array_access` uses for a chained `?array` + // read) carries an owned reference that nothing else releases anymore: + // `take_owned_temp` already cleared the backing slot, so this read is the + // temp value's last consuming use. The exact-Mixed guard keeps this path on + // `__rt_mixed_array_get`; `Op::RuntimeCall` also represents `ArrayAccess` + // object dispatch and is broader than this ownership proof. Drop the temp's + // reference here exactly once, on the loaded SSA value itself, so both + // merge results are freed — the populated container from the non-null path + // and the boxed Mixed null from the null path. `release_if_owned` only + // type-gates EIR emission; the `OwnedTemp` load predicate establishes the + // ownership transfer at this call site. + // + // `__rt_mixed_array_get` returns an owned, independent cell (string + // payloads are persisted and child heap pointers retained), so releasing + // the receiver cannot invalidate the result; typed reads (`ArrayGet`, + // `StrCharAt`, ...) can return payloads that still borrow from the + // receiver's storage and must keep it alive. + if release_owned_mixed_temp_receiver { + crate::ir_lower::ownership::release_if_owned(ctx, array_value, Some(expr.span)); + } result } diff --git a/tests/codegen/runtime_gc/regressions.rs b/tests/codegen/runtime_gc/regressions.rs index e95cee2fa7..ed58757758 100644 --- a/tests/codegen/runtime_gc/regressions.rs +++ b/tests/codegen/runtime_gc/regressions.rs @@ -1231,6 +1231,41 @@ echo "done"; ); } +/// Regression test for issue #525: a chained subscript read on a `?array` +/// receiver materializes the intermediate container through the nullable-access +/// hidden owned temp (`lower_nullable_array_access`), and the consuming read +/// must release that temp's reference after its last use. Exercises both the +/// non-null receiver (populated temp) and the null receiver (boxed-null temp) +/// on every iteration and asserts the heap is clean at exit. +#[test] +fn test_regression_525_nullable_chained_read_releases_hidden_temp() { + let out = compile_and_run_with_heap_debug( + r#"