Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .config/nextest.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>`, `buffer_new<T>`, typed local declarations, `ptr`/`buffer<T>` 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.
Expand Down
16 changes: 11 additions & 5 deletions src/ir_lower/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1574,6 +1574,17 @@ impl<'m, 'f> LoweringContext<'m, 'f> {
{
return true;
}
// 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),
Some(
Expand Down Expand Up @@ -1638,11 +1649,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
)
)
}
Expand Down
6 changes: 6 additions & 0 deletions src/ir_lower/stmt/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1332,6 +1332,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" => {
Expand Down Expand Up @@ -3330,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(
Expand Down
15 changes: 11 additions & 4 deletions src/types/checker/functions/resolution/signature.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>` 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<T>` 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<PhpType> {
let mut specific: Option<PhpType> = None;
let mut empty_array: Option<PhpType> = None;
for return_info in return_types {
let return_ty = &return_info.ty;
if matches!(return_ty, PhpType::Void) {
Expand All @@ -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.
Expand Down
186 changes: 186 additions & 0 deletions tests/codegen/arrays/foreach_value_append.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
//! 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<mixed>` 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#"<?php
function collect(string $csv): array {
$out = [];
foreach (explode(',', $csv) as $item) {
$out[] = $item;
}
return $out;
}
$r = collect('a,b,c');
echo $r[0], "\n";
foreach ($r as $e) {
echo $e;
}"#,
);
assert_eq!(out, "a\nabc");
}

/// Same shape with int elements: the caller previously printed the address of
/// the Mixed box instead of the element value.
#[test]
fn test_foreach_int_value_append_survives_function_return() {
let out = compile_and_run(
r#"<?php
function collect(): array {
$out = [];
foreach ([1, 2, 3] as $item) {
$out[] = $item;
}
return $out;
}
$r = collect();
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#"<?php
function collect(): array {
$out = [];
foreach ([1.25, 2.5, 3.75] as $item) {
$out[] = $item;
}
return $out;
}
$r = collect();
echo $r[0], "|", $r[1], "|", $r[2];"#,
);
assert_eq!(out, "1.25|2.5|3.75");
}

/// Bool elements remain unboxed across the function-return boundary instead
/// of being read back through a widened `Mixed` array layout.
#[test]
fn test_foreach_bool_value_append_survives_function_return() {
let out = compile_and_run(
r#"<?php
function collect(): array {
$out = [];
foreach ([true, false] as $item) {
$out[] = $item;
}
return $out;
}
$r = collect();
if ($r[0]) { echo "T"; }
if (!$r[1]) { echo "F"; }"#,
);
assert_eq!(out, "TF");
}

/// The string ownership fix remains clean under allocator poisoning and leak
/// reporting, guarding both the original UAF and a future missing release.
#[test]
fn test_foreach_string_value_append_heap_debug_clean() {
let out = compile_and_run_with_heap_debug(
r#"<?php
function collect(string $csv): array {
$out = [];
foreach (explode(',', $csv) as $item) {
$out[] = $item;
}
return $out;
}
$r = collect('a,b,c');
echo $r[0], $r[1], $r[2];"#,
);
assert!(out.success, "program failed: {}", out.stderr);
assert_eq!(out.stdout, "abc");
assert!(
out.stderr.contains("HEAP DEBUG: leak summary: clean"),
"expected a clean heap, got: {}",
out.stderr
);
}

/// Appending the foreach value of an inline literal source: the borrowed
/// current-value string must not be released into the source array (its block
/// was reused by the append's str_persist and then zeroed by the source's
/// deep-free at loop exit).
#[test]
fn test_foreach_string_value_append_inline_literal_source() {
let out = compile_and_run(
r#"<?php
$out = [];
foreach (["a", "b"] as $item) {
$out[] = $item;
}
echo $out[0], $out[1];"#,
);
assert_eq!(out, "ab");
}

/// The concrete-typed loop variable still supports reads after the loop and
/// owned reassignment inside the body.
#[test]
fn test_foreach_string_value_local_reads_and_reassignment() {
let out = compile_and_run(
r#"<?php
$out = [];
foreach (["a", "b"] as $item) {
$item = $item . "!";
$out[] = $item;
}
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#"<?php
function collect(bool $empty): array {
if ($empty) { return []; }
$out = [];
foreach (["name", "role"] as $key) {
$out[$key] = strtoupper($key);
}
return $out;
}
$empty = collect(true);
$values = collect(false);
echo count($empty), "|", $values["name"], "|", $values["role"];
"#,
);
assert_eq!(out, "0|NAME|ROLE");
}
1 change: 1 addition & 0 deletions tests/codegen/arrays/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@ mod assoc_helpers;
mod nested;
mod callbacks;
mod foreach_key_write;
mod foreach_value_append;
mod list_and_keys;
mod assoc_set_ops;
Loading