From 202ee3050f488a7d37ea58677aec34f7bd840c10 Mon Sep 17 00:00:00 2001 From: mirchaemanuel Date: Thu, 16 Jul 2026 15:43:27 +0200 Subject: [PATCH 1/5] fix(ir): keep concrete scalar foreach values through the loop local (#405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A by-value foreach over an array with a concrete scalar element type degraded the loop variable to a boxed Mixed local, so appending it widened the array under construction to array at runtime while the checker-side function signature kept the concrete element type. Callers then read the returned array through the concrete layout: box addresses for int elements, and a garbage (ptr,len) reinterpretation for string elements that made the next read exhaust the heap. foreach_value_type() now preserves Int/Float/Str/Bool elements (the codegen already loads unboxed concrete iterator values on both arches), and IterCurrentValue/IterCurrentKey results are modeled as owning temporaries only when they really bind a fresh owned copy: a concrete Str current value is a borrowed pointer into the source array's payload (the same rule the ownership model applies to ArrayGet string results), so the retaining store must not release it — that release freed the source array's string block, the next __rt_str_persist reused it, and the source's deep-free at foreach.exit zeroed it under its new owner. --- src/ir_lower/context.rs | 18 +++- src/ir_lower/stmt/mod.rs | 1 + tests/codegen/arrays/foreach_value_append.rs | 97 ++++++++++++++++++++ tests/codegen/arrays/mod.rs | 1 + 4 files changed, 112 insertions(+), 5 deletions(-) create mode 100644 tests/codegen/arrays/foreach_value_append.rs diff --git a/src/ir_lower/context.rs b/src/ir_lower/context.rs index 3e90995703..8479afaa7e 100644 --- a/src/ir_lower/context.rs +++ b/src/ir_lower/context.rs @@ -1562,6 +1562,19 @@ impl<'m, 'f> LoweringContext<'m, 'f> { { return true; } + // By-value foreach binds a fresh OWNED copy of the current element/key + // (a boxed Mixed cell, an increfed object, a retained callable + // descriptor); without this `store_local` re-acquires it and never + // releases the copy, leaking on every iteration. Concrete `Str` + // elements are the exception: like `ArrayGet` string results they are + // borrowed pointers into the source container's payload, so treating + // them as owning would free the source array's block out from under it. + if matches!( + self.builder.value_defining_op(value.value), + Some(Op::IterCurrentValue | Op::IterCurrentKey) + ) { + return !matches!(php_type.codegen_repr(), PhpType::Str); + } matches!( self.builder.value_defining_op(value.value), Some( @@ -1626,11 +1639,6 @@ impl<'m, 'f> LoweringContext<'m, 'f> { | Op::IteratorMethodCall | Op::SplRuntimeCall | Op::FiberRuntimeCall - // By-value foreach binds a fresh OWNED copy of the current - // element/key; without this `store_local` re-acquires it and - // never releases the copy, leaking on every iteration. - | Op::IterCurrentValue - | Op::IterCurrentKey ) ) } diff --git a/src/ir_lower/stmt/mod.rs b/src/ir_lower/stmt/mod.rs index e1d4b6d22e..f693879837 100644 --- a/src/ir_lower/stmt/mod.rs +++ b/src/ir_lower/stmt/mod.rs @@ -1328,6 +1328,7 @@ fn foreach_value_type(source_ty: &PhpType) -> PhpType { PhpType::Array(elem) => match elem.codegen_repr() { PhpType::Callable => PhpType::Callable, PhpType::Object(class_name) => PhpType::Object(class_name), + elem @ (PhpType::Int | PhpType::Float | PhpType::Str | PhpType::Bool) => elem, _ => PhpType::Mixed, }, PhpType::Object(class_name) if class_name == "Phar" || class_name == "PharData" => { diff --git a/tests/codegen/arrays/foreach_value_append.rs b/tests/codegen/arrays/foreach_value_append.rs new file mode 100644 index 0000000000..6806a0428d --- /dev/null +++ b/tests/codegen/arrays/foreach_value_append.rs @@ -0,0 +1,97 @@ +//! Purpose: +//! Regression tests for appending a by-value `foreach` loop variable into +//! another array that crosses a function-return boundary (issue #405). +//! +//! Called from: +//! - `cargo test` through the `codegen_tests` harness via `crate::support`. +//! +//! Key details: +//! - `foreach_value_type` now keeps concrete scalar element types (`int`, +//! `float`, `bool`, `string`) for the by-value loop variable instead of +//! degrading them to a boxed `Mixed` local. Previously the appended box +//! widened the array under construction to `array` at runtime while +//! the checker-side function signature kept the concrete element type, so +//! the caller read box pointers through the concrete element layout +//! (garbage ints, or a fatal "heap memory exhausted" on string reads). +//! - Concrete `Str` results of `iter_current_value` are borrowed pointers +//! into the source array's payload (like `ArrayGet` string results), NOT +//! owning temporaries: the retaining store must not release them, or the +//! source array's string block is freed while still referenced and the +//! next `__rt_str_persist` reuses it (use-after-free corruption). + +use crate::support::*; + +/// Issue #405 minimal repro: appending the foreach value of an exploded CSV +/// and returning the array previously printed nothing and exhausted the heap +/// when the caller read it back. +#[test] +fn test_foreach_string_value_append_survives_function_return() { + let out = compile_and_run( + r#" Date: Thu, 16 Jul 2026 19:02:49 +0200 Subject: [PATCH 2/5] test: extend the slow-test timeout override to test_eval_declared_method_return_type_values The test intentionally exercises five complete compile-link-run eval programs in one test (~38s on a local ARM64 debug build, identical on pristine main), which clears the global 60s slow-timeout only barely and timed out on a loaded shared macos-14 CI runner. Same category and same 180s override as the eval regression tests already listed here. --- .config/nextest.toml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.config/nextest.toml b/.config/nextest.toml index 4c0e34ab89..0e8e4e1da3 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -102,3 +102,11 @@ slow-timeout = { period = "180s", terminate-after = 1 } [[profile.ci.overrides]] filter = 'test(test_eval_aot_callable_named_ref_arg_prep_fatal_cleans_up_stack)' slow-timeout = { period = "180s", terminate-after = 1 } + +[[profile.default.overrides]] +filter = 'test(test_eval_declared_method_return_type_values)' +slow-timeout = { period = "180s", terminate-after = 1 } + +[[profile.ci.overrides]] +filter = 'test(test_eval_declared_method_return_type_values)' +slow-timeout = { period = "180s", terminate-after = 1 } From c4c32e9a4ddbf4c62a12c8ec723c499de6b57cb1 Mon Sep 17 00:00:00 2001 From: Vincenzo Petrucci Date: Fri, 17 Jul 2026 18:35:47 +0200 Subject: [PATCH 3/5] test: cover foreach scalar layouts and ownership --- src/ir_lower/context.rs | 22 +++---- tests/codegen/arrays/foreach_value_append.rs | 64 ++++++++++++++++++++ 2 files changed, 74 insertions(+), 12 deletions(-) diff --git a/src/ir_lower/context.rs b/src/ir_lower/context.rs index 1d0347d036..1fa10bc10b 100644 --- a/src/ir_lower/context.rs +++ b/src/ir_lower/context.rs @@ -1574,18 +1574,16 @@ impl<'m, 'f> LoweringContext<'m, 'f> { { return true; } - // By-value foreach binds a fresh OWNED copy of the current element/key - // (a boxed Mixed cell, an increfed object, a retained callable - // descriptor); without this `store_local` re-acquires it and never - // releases the copy, leaking on every iteration. Concrete `Str` - // elements are the exception: like `ArrayGet` string results they are - // borrowed pointers into the source container's payload, so treating - // them as owning would free the source array's block out from under it. - if matches!( - self.builder.value_defining_op(value.value), - Some(Op::IterCurrentValue | Op::IterCurrentKey) - ) { - return !matches!(php_type.codegen_repr(), PhpType::Str); + // By-value foreach binds either an owned current value or an owned boxed + // Mixed key. Concrete `Str` values are the exception: like `ArrayGet` + // string results they borrow the source container's payload, so treating + // them as owning would free the array's string block out from under it. + match self.builder.value_defining_op(value.value) { + Some(Op::IterCurrentValue) => { + return !matches!(php_type.codegen_repr(), PhpType::Str); + } + Some(Op::IterCurrentKey) => return true, + _ => {} } matches!( self.builder.value_defining_op(value.value), diff --git a/tests/codegen/arrays/foreach_value_append.rs b/tests/codegen/arrays/foreach_value_append.rs index 6806a0428d..16da1f3f5a 100644 --- a/tests/codegen/arrays/foreach_value_append.rs +++ b/tests/codegen/arrays/foreach_value_append.rs @@ -63,6 +63,70 @@ echo $r[0], $r[1], $r[2];"#, assert_eq!(out, "123"); } +/// Float elements keep their concrete register and array-slot representation +/// when the collected array crosses the function-return boundary. +#[test] +fn test_foreach_float_value_append_survives_function_return() { + let out = compile_and_run( + r#" Date: Fri, 17 Jul 2026 18:38:21 +0200 Subject: [PATCH 4/5] docs(changelog): document foreach return fix --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f11a511de7..b71ac88b3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Releases are listed newest first. ## [Unreleased] - Added the `--strict-php` flag: the compiler accepts only PHP-compatible constructs. Extension syntax (`ifdef`, `packed class`, `extern`, `ptr_cast`, `buffer_new`, typed local declarations, `ptr`/`buffer` annotations) is rejected at compile time with per-violation diagnostics across the main file, includes, and autoloaded files, while extension builtins (`ptr_*`, `zval_*`, `buffer_*`, `class_attribute_*`) behave exactly as under the PHP interpreter — `function_exists()` reports `false`, calling one is an undefined function with a hint naming the disabled extension, and user code may declare its own functions with those names. Strict mode also reaches `eval()` with PHP's execute-time semantics: extension builtins do not exist inside eval'd fragments (runtime fatal on call, coherent `function_exists`/`is_callable`), extension syntax in a fragment is a runtime parse error, and user functions shadowing extension names stay callable. Programs using compiler preludes (PDO, timezone, image, web) keep compiling; `--define` cannot be combined with the flag. - Added PHP-compatible sessions to `--web` across PHP 8.2–8.5 and every supported target: `$_SESSION`, the complete `session_*()` API and interfaces, file persistence with `flock`, the `php`/`php_binary`/`php_serialize` wire formats, custom save handlers, strict mode, lazy writes, GC, SID and runtime INI configuration, cookies and cache limiters, auto-start, multipart upload progress, and trans-SID rewriting. Session state, locks, and bridge buffers are reset between requests, while invalid names, IDs, serialized input, and every supported callable-handler form follow PHP-compatible validation and failure paths. The web prelude now retains session helpers by reachability, omits the legacy callable-handler adapter when unused, uses a compact auto-start path, and shares callable descriptors and invokers module-wide, keeping ordinary `--web` compilation and assembly size close to the pre-session baseline. +- Fixed by-value `foreach` values corrupting arrays returned from functions (issue #405): concrete `int`, `float`, `bool`, and `string` element layouts now remain consistent across the loop local and function-return boundary, while stored string values retain their borrowed iterator payload before the source array is released. Callers now read the returned values correctly instead of seeing box addresses, empty strings, or a fatal heap-exhaustion error. - Fixed checked-arithmetic Mixed boxes leaking when consumed directly by a parent operation (issue #500): the boxed `int|float` result of runtime `+`/`-`/`*` (and unary `-`) is now released after it is coerced for `%`, bitwise/shift operators, `**`, comparisons, and int-coerced or mixed-key array indexes, so shapes like `($i * 7 + 1) & 0xFFFF` or `$SIN[($i * 7 + 5) & 1023]` in tight loops no longer exhaust the heap. Assignment, call-argument, chained mixed-op, and string-coercion releases stay balanced (no double frees), overflow-to-float promotion is unchanged, and heap-debug stays clean with the EIR optimizer enabled or disabled. - Added PHP-compatible `mb_strlen()` to both native compilation and the Magician `eval()` runtime, including the nullable optional `$encoding` argument, UTF-8 malformed-sequence handling, byte-count aliases, iconv-backed multibyte encodings, callable dispatch, and catchable `ValueError` for unknown encodings on every supported target. - Added experimental PHP `eval()` support across macOS ARM64, Linux ARM64, and Linux x86_64. Eligible literal fragments are parsed at compile time and lowered to native EIR, including direct or scope-backed caller-local synchronization; dynamic strings and unsupported literal shapes fall back to the optional statically linked `elephc-magician` EvalIR interpreter. Within the supported eval subset, the fallback preserves caller/global scope updates, dynamic functions/classes/constants, callables, reflection, builtins, exceptions, ownership/COW behavior, and PHP-visible diagnostics without requiring PHP or the Zend Engine. Bridge linking is automatic when required and can be forced with `--with-eval`; generated builtin documentation now reports AOT and eval availability separately. From 7389709baac8b2b2f4e714133e9a98c6c73e70e0 Mon Sep 17 00:00:00 2001 From: Vincenzo Petrucci Date: Fri, 17 Jul 2026 19:39:37 +0200 Subject: [PATCH 5/5] fix(ir): preserve associative generic array returns --- src/ir_lower/stmt/mod.rs | 5 ++++ .../checker/functions/resolution/signature.rs | 15 ++++++++--- tests/codegen/arrays/foreach_value_append.rs | 25 +++++++++++++++++++ 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/ir_lower/stmt/mod.rs b/src/ir_lower/stmt/mod.rs index f68535d62e..a0534b2859 100644 --- a/src/ir_lower/stmt/mod.rs +++ b/src/ir_lower/stmt/mod.rs @@ -3331,6 +3331,11 @@ fn coerce_container_to_return_type( { Op::HashToMixed } + (PhpType::Array(source_elem), PhpType::AssocArray { .. }) + if source_elem.as_ref() == &PhpType::Never => + { + Op::ArrayToHash + } _ => return None, }; Some(ctx.emit_value( diff --git a/src/types/checker/functions/resolution/signature.rs b/src/types/checker/functions/resolution/signature.rs index 1594fefe7b..8ecbf226c6 100644 --- a/src/types/checker/functions/resolution/signature.rs +++ b/src/types/checker/functions/resolution/signature.rs @@ -296,13 +296,16 @@ impl Checker { /// Infers a concrete array type from return info when the declared return type is a generic `array` hint. /// -/// Returns `Some(PhpType)` only when every non-void return in `return_types` is the same -/// array type (including `array` or `assocArray` shapes). Returns `None` if returns differ, -/// include non-array types, or are all `void`. +/// Returns `Some(PhpType)` only when every non-void, non-empty return in +/// `return_types` is the same array type (including `array` or `assocArray` +/// shapes). An empty indexed array is neutral because it can be materialized in +/// either concrete storage family at the return boundary. Returns `None` if +/// non-empty returns differ, include non-array types, or are all `void`. fn inferred_specific_array_type_from_infos( return_types: &[super::super::returns::ReturnInfo], ) -> Option { let mut specific: Option = None; + let mut empty_array: Option = None; for return_info in return_types { let return_ty = &return_info.ty; if matches!(return_ty, PhpType::Void) { @@ -311,13 +314,17 @@ fn inferred_specific_array_type_from_infos( if !matches!(return_ty, PhpType::Array(_) | PhpType::AssocArray { .. }) { return None; } + if matches!(return_ty, PhpType::Array(elem) if elem.as_ref() == &PhpType::Never) { + empty_array = Some(return_ty.clone()); + continue; + } match &specific { None => specific = Some(return_ty.clone()), Some(existing) if existing == return_ty => {} _ => return None, } } - specific + specific.or(empty_array) } /// Returns true when a function return type is a homogeneous array of callables. diff --git a/tests/codegen/arrays/foreach_value_append.rs b/tests/codegen/arrays/foreach_value_append.rs index 16da1f3f5a..09e7f70a96 100644 --- a/tests/codegen/arrays/foreach_value_append.rs +++ b/tests/codegen/arrays/foreach_value_append.rs @@ -159,3 +159,28 @@ echo $out[0], $out[1], "|", $item;"#, ); assert_eq!(out, "a!b!|b!"); } + +/// A generic `array` return with an empty branch specializes to the associative +/// shape built by concrete string values from `foreach`. The empty branch must +/// be converted to the same hash ABI instead of requesting an unsupported +/// `AssocArray -> Array(Mixed)` runtime conversion (the web `ini_get_all()` CI +/// regression exposed by issue #405). +#[test] +fn test_foreach_string_value_key_preserves_assoc_return_with_empty_branch() { + let out = compile_and_run( + r#"