From b44daa999a246d45d6fbe1a07f20e83add630585 Mon Sep 17 00:00:00 2001 From: mirchaemanuel Date: Mon, 20 Jul 2026 09:26:51 +0200 Subject: [PATCH 1/2] fix(runtime): guard COW helpers against the null-container sentinel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A by-reference foreach whose source is a missing array element handed the in-band null-container sentinel to the copy-on-write helpers and then re-read it as a live array header on every advancement, segfaulting instead of skipping the loop. The #526/#533 hardening covered only the read/by-value path: its length snapshot is gated on `!by_ref`, so neither guard reached by-reference initialization. Following the #533 convention that sentinel guards live in the runtime helpers, `__rt_array_ensure_unique` and `__rt_hash_ensure_unique` now recognize the sentinel next to their existing null check and return it unchanged, hardening every COW call site. Foreach initialization then folds a sentinel source to the canonical zero pointer in the iterator's private slot only — the origin local keeps the sentinel, so the user-visible variable stays null — and the per-iteration by-reference live-length read needs just the cheap zero check, substituting length zero for a null source. Ordinary by-reference mutation, aliasing, and PHP's visit-appended-elements semantics over real indexed and associative arrays are unchanged. Closes #556 Claude-Session: https://claude.ai/code/session_01QyzntRke1Cgxgq1ueuz4X9 --- CHANGELOG.md | 1 + src/codegen/lower_inst/iterators.rs | 52 +++- .../runtime/arrays/array_ensure_unique.rs | 29 ++- .../runtime/arrays/hash_ensure_unique.rs | 18 +- src/codegen_support/sentinels.rs | 25 ++ tests/codegen/regressions/arrays.rs | 235 ++++++++++++++++++ 6 files changed, 351 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index db8801a502..0f125d82a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ Releases are listed newest first. - Fixed owning temporary receivers leaking after object property reads (issues #540 and #516): named, dynamic, and nullsafe EIR property paths now stabilize borrowed string, array, object, and callable results before releasing a Mixed-unboxed or otherwise temporary receiver. Nullsafe short-circuits also release owning nullable receivers on the null branch, while indexed consumers preserve chained reads such as `$object->items[0]`, aliasing, and COW behavior. Heap-debug remains clean with the EIR optimizer enabled or disabled. This closes root cause 4 from issue #540. - Fixed inline array arguments leaking when a source function or instance method returns an independent array (issues #540 and #516): the checker now derives conservative return-to-parameter alias summaries, so EIR suppresses temporary cleanup only for arguments the result can actually reuse. Real passthrough returns, descendant overrides, dynamic/indirect storage, and copy-on-write aliases remain protected; optimizer inlining preserves borrowed parameter/return slots without double cleanup, and heap-debug stays clean with `--ir-opt` enabled or disabled on the shared target-independent path. - Fixed the remaining persistent-worker ownership growth from issues #540 and #516: scalar casts now release consumed owning inputs; builtin metadata identifies the independent storage returned by `rawurldecode()`, `implode()`, `htmlspecialchars()`, and `htmlentities()` so wrappers and direct calls can release stabilized read arguments; Mixed-to-string call conversions are now owned explicitly by EIR and follow the same alias-aware cleanup; and constructor materialization releases temporary boxed-Mixed arguments. The original Ivory workload now has zero live-block growth through 500 requests with EIR optimization enabled or disabled, and its first/last 50-request latency stays flat at approximately 3 ms. +- Fixed by-reference `foreach` over a missing array element segfaulting (issue #556): the by-reference path was left out of scope by the #526/#533 read-side hardening, so the null-container sentinel from a missed read was handed to the copy-on-write helpers as a real array pointer and then re-read as a live array header on every iteration. `__rt_array_ensure_unique` and `__rt_hash_ensure_unique` now recognize the sentinel and return it unchanged (hardening every copy-on-write call site), foreach initialization folds a sentinel source to the canonical zero pointer in the iterator's private slot while the user-visible variable stays null, and the by-reference live-length read substitutes zero for a null source. The direct form reports the missing key while the `?? []` form silently iterates its empty default; both skip the loop body and continue instead of crashing. Ordinary by-reference mutation, aliasing, and PHP's visit-appended-elements semantics are unchanged, and heap-debug stays clean with `--ir-opt` enabled or disabled across every supported target. ## [0.26.1] - Expanded flow-sensitive type narrowing for PHP's common guard patterns: `int|false` and other false-sentinel unions now preserve the literal `false` subtype and narrow to their success type after a divergent `=== false` guard, without incorrectly removing a full `bool` member; `=== null` and `is_null()` guards narrow nullable values; and stable object properties can be narrowed through `instanceof`, ternaries, and throw guards. Property facts are invalidated after writes or receiver rebindings and are not retained across property hooks or `__get`, whose repeated reads may differ. diff --git a/src/codegen/lower_inst/iterators.rs b/src/codegen/lower_inst/iterators.rs index d7853ad221..52800c257b 100644 --- a/src/codegen/lower_inst/iterators.rs +++ b/src/codegen/lower_inst/iterators.rs @@ -62,6 +62,21 @@ pub(super) fn lower_iter_start(ctx: &mut FunctionContext<'_>, inst: &Instruction } if by_ref { ensure_unique_static_iter_source(ctx, source, &source_kind)?; + if matches!( + source_kind, + IteratorSourceKind::Indexed { .. } | IteratorSourceKind::Hash + ) { + // -- normalize a missed-read sentinel source to the canonical zero pointer -- + // Only the iterator's private slot is normalized; the origin local keeps the + // sentinel so the user-visible value stays null. `IterNext` re-reads the live + // length from this slot every iteration, so folding the sentinel here keeps + // that hot path on the cheap zero check (issue #556). + crate::codegen::sentinels::emit_normalize_null_container_to_zero( + ctx.emitter, + result_reg, + abi::secondary_scratch_reg(ctx.emitter), + ); + } } abi::store_at_offset(ctx.emitter, result_reg, offset - ITER_SOURCE_OFFSET_DELTA); if matches!(source_kind, IteratorSourceKind::DynamicIterable) { @@ -380,6 +395,12 @@ fn iter_start_is_by_ref(inst: &Instruction) -> bool { } /// Splits statically typed array/hash sources before by-reference iteration. +/// +/// A null/sentinel source — the value a missed read such as `foreach ($a[7] as &$v)` +/// materializes — has nothing to split: the copy-on-write helpers recognize both the zero +/// pointer and the in-band `NULL_SENTINEL` and return them unchanged (issue #556). The +/// origin local and SSA slot therefore keep the sentinel; the caller normalizes only the +/// iterator's private source slot afterwards. fn ensure_unique_static_iter_source( ctx: &mut FunctionContext<'_>, source: ValueId, @@ -869,6 +890,33 @@ fn snapshot_indexed_array_length(ctx: &mut FunctionContext<'_>, offset: usize) { abi::store_at_offset(ctx.emitter, len_reg, offset - ITER_SNAPSHOT_LEN_OFFSET_DELTA); } +/// Loads the live entry count from the indexed-array header in `array_reg` into `len_reg`, +/// substituting zero for a zero source pointer. Used by the by-reference `IterNext` +/// advancement, whose source slot `IterStart` has already normalized: a missed-read +/// sentinel was folded to zero there, so the per-iteration hot path needs only the cheap +/// zero check instead of the full null-container guard (issue #556). `len_reg` must not +/// alias `array_reg`. +fn load_live_indexed_array_length(ctx: &mut FunctionContext<'_>, array_reg: &str, len_reg: &str) { + let null_label = ctx.next_label("iter_len_null_source"); + let done_label = ctx.next_label("iter_len_done"); + match ctx.emitter.target.arch { + Arch::AArch64 => { + ctx.emitter + .instruction(&format!("cbz {}, {}", array_reg, null_label)); // zero sources (normalized missed reads) have no header to read + } + Arch::X86_64 => { + ctx.emitter + .instruction(&format!("test {}, {}", array_reg, array_reg)); // is the source the canonical zero container pointer? + ctx.emitter.instruction(&format!("jz {}", null_label)); // zero sources (normalized missed reads) have no header to read + } + } + abi::emit_load_from_address(ctx.emitter, len_reg, array_reg, 0); + abi::emit_jump(ctx.emitter, &done_label); + ctx.emitter.label(&null_label); + abi::emit_load_int_immediate(ctx.emitter, len_reg, 0); + ctx.emitter.label(&done_label); +} + /// Boxes a concrete iterator method result when the EIR result slot expects `Mixed`. fn box_iterator_method_result_if_needed( ctx: &mut FunctionContext<'_>, @@ -1252,7 +1300,7 @@ fn lower_indexed_iter_next_aarch64( if by_ref { let array_reg = abi::int_result_reg(ctx.emitter); abi::load_at_offset(ctx.emitter, array_reg, offset - ITER_SOURCE_OFFSET_DELTA); - abi::emit_load_from_address(ctx.emitter, len_reg, array_reg, 0); + load_live_indexed_array_length(ctx, array_reg, len_reg); ctx.emitter.instruction(&format!("cmp {}, {}", index_reg, len_reg)); // compare the candidate offset against the live array length } else { abi::load_at_offset(ctx.emitter, len_reg, offset - ITER_SNAPSHOT_LEN_OFFSET_DELTA); @@ -1283,7 +1331,7 @@ fn lower_indexed_iter_next_x86_64( if by_ref { let array_reg = abi::symbol_scratch_reg(ctx.emitter); abi::load_at_offset(ctx.emitter, array_reg, offset - ITER_SOURCE_OFFSET_DELTA); - abi::emit_load_from_address(ctx.emitter, len_reg, array_reg, 0); + load_live_indexed_array_length(ctx, array_reg, len_reg); ctx.emitter.instruction(&format!("cmp {}, {}", index_reg, len_reg)); // compare the candidate offset against the live array length } else { abi::load_at_offset(ctx.emitter, len_reg, offset - ITER_SNAPSHOT_LEN_OFFSET_DELTA); diff --git a/src/codegen_support/runtime/arrays/array_ensure_unique.rs b/src/codegen_support/runtime/arrays/array_ensure_unique.rs index 2d44b8cd52..452ff971f2 100644 --- a/src/codegen_support/runtime/arrays/array_ensure_unique.rs +++ b/src/codegen_support/runtime/arrays/array_ensure_unique.rs @@ -13,8 +13,10 @@ use crate::codegen_support::platform::Arch; /// Splits a shared array before mutation using copy-on-write (COW) semantics. /// -/// Null arrays (x0=0) are returned immediately as they are trivially unique. -/// For non-null arrays: if refcount > 1, clones the array and decrements the +/// Null arrays (x0=0) and the in-band null-container sentinel that missed reads +/// materialize (issue #556) are returned unchanged: neither carries a heap header, +/// so they must never reach the refcount load below. +/// For real arrays: if refcount > 1, clones the array and decrements the /// original's refcount; if refcount <= 1, returns the array unchanged. /// /// Dispatches to `emit_array_ensure_unique_linux_x86_64` on x86_64; emits @@ -32,8 +34,15 @@ pub fn emit_array_ensure_unique(emitter: &mut Emitter) { emitter.comment("--- runtime: array_ensure_unique ---"); emitter.label_global("__rt_array_ensure_unique"); - // -- null arrays are already trivially unique -- + // -- null and sentinel-null arrays are already trivially unique -- emitter.instruction("cbz x0, __rt_array_ensure_unique_done"); // null inputs do not need copy-on-write splitting + crate::codegen_support::abi::emit_load_int_immediate( + emitter, + "x9", + crate::codegen_support::sentinels::NULL_SENTINEL, + ); + emitter.instruction("cmp x0, x9"); // does the array carry the in-band null-container sentinel? + emitter.instruction("b.eq __rt_array_ensure_unique_done"); // sentinel-null arrays from missed reads have no header to split // -- only shared arrays need to be cloned -- emitter.instruction("ldr w9, [x0, #-12]"); // load the current array refcount from the uniform header @@ -59,9 +68,10 @@ pub fn emit_array_ensure_unique(emitter: &mut Emitter) { /// Emits the x86_64 Linux implementation of `__rt_array_ensure_unique`. /// -/// Mirrors the ARM64 logic: null inputs return immediately; shared arrays -/// (refcount > 1) are cloned via `__rt_array_clone_shallow` and the original's -/// refcount is decremented; unique arrays (refcount <= 1) are returned unchanged. +/// Mirrors the ARM64 logic: null and sentinel-null inputs return immediately; +/// shared arrays (refcount > 1) are cloned via `__rt_array_clone_shallow` and the +/// original's refcount is decremented; unique arrays (refcount <= 1) are returned +/// unchanged. /// /// Input: rdi = candidate indexed-array pointer /// Output: rax = unique indexed-array pointer @@ -73,6 +83,13 @@ fn emit_array_ensure_unique_linux_x86_64(emitter: &mut Emitter) { emitter.instruction("mov rax, rdi"); // default to returning the original indexed-array pointer when no copy-on-write split is needed emitter.instruction("test rdi, rdi"); // null indexed-array pointers are already trivially unique emitter.instruction("je __rt_array_ensure_unique_done"); // return immediately for null inputs without touching heap metadata + crate::codegen_support::abi::emit_load_int_immediate( + emitter, + "r10", + crate::codegen_support::sentinels::NULL_SENTINEL, + ); + emitter.instruction("cmp rdi, r10"); // does the indexed array carry the in-band null-container sentinel? + emitter.instruction("je __rt_array_ensure_unique_done"); // sentinel-null arrays from missed reads have no header to split emitter.instruction("mov r10d, DWORD PTR [rdi - 12]"); // load the current indexed-array refcount from the uniform heap header emitter.instruction("cmp r10d, 1"); // does the indexed array have more than one logical owner? emitter.instruction("jbe __rt_array_ensure_unique_done"); // refcount <= 1 means the indexed array can be mutated in place diff --git a/src/codegen_support/runtime/arrays/hash_ensure_unique.rs b/src/codegen_support/runtime/arrays/hash_ensure_unique.rs index 647610ffb8..71d1541c00 100644 --- a/src/codegen_support/runtime/arrays/hash_ensure_unique.rs +++ b/src/codegen_support/runtime/arrays/hash_ensure_unique.rs @@ -12,6 +12,8 @@ use crate::codegen_support::emit::Emitter; use crate::codegen_support::platform::Arch; /// hash_ensure_unique: split a shared hash table before mutation. +/// Null hashes and the in-band null-container sentinel that missed reads materialize +/// (issue #556) are returned unchanged: neither carries a heap header to split. /// Input: x0 = candidate hash pointer /// Output: x0 = unique hash pointer (original or cloned) pub fn emit_hash_ensure_unique(emitter: &mut Emitter) { @@ -24,8 +26,15 @@ pub fn emit_hash_ensure_unique(emitter: &mut Emitter) { emitter.comment("--- runtime: hash_ensure_unique ---"); emitter.label_global("__rt_hash_ensure_unique"); - // -- null hashes are already trivially unique -- + // -- null and sentinel-null hashes are already trivially unique -- emitter.instruction("cbz x0, __rt_hash_ensure_unique_done"); // null inputs do not need copy-on-write splitting + crate::codegen_support::abi::emit_load_int_immediate( + emitter, + "x9", + crate::codegen_support::sentinels::NULL_SENTINEL, + ); + emitter.instruction("cmp x0, x9"); // does the hash carry the in-band null-container sentinel? + emitter.instruction("b.eq __rt_hash_ensure_unique_done"); // sentinel-null hashes from missed reads have no header to split // -- only shared hashes need to be cloned -- emitter.instruction("ldr w9, [x0, #-12]"); // load the current hash refcount from the uniform header @@ -60,6 +69,13 @@ fn emit_hash_ensure_unique_linux_x86_64(emitter: &mut Emitter) { emitter.instruction("mov rax, rdi"); // default to returning the original associative-array pointer when no copy-on-write split is needed emitter.instruction("test rdi, rdi"); // null associative-array pointers are already trivially unique emitter.instruction("je __rt_hash_ensure_unique_done"); // return immediately for null inputs without touching heap metadata + crate::codegen_support::abi::emit_load_int_immediate( + emitter, + "r10", + crate::codegen_support::sentinels::NULL_SENTINEL, + ); + emitter.instruction("cmp rdi, r10"); // does the associative array carry the in-band null-container sentinel? + emitter.instruction("je __rt_hash_ensure_unique_done"); // sentinel-null hashes from missed reads have no header to split emitter.instruction("mov r10d, DWORD PTR [rdi - 12]"); // load the current associative-array refcount from the uniform heap header emitter.instruction("cmp r10d, 1"); // does the associative array have more than one logical owner? emitter.instruction("jbe __rt_hash_ensure_unique_done"); // refcount <= 1 means the associative array can be mutated in place diff --git a/src/codegen_support/sentinels.rs b/src/codegen_support/sentinels.rs index afcb611e96..6ae57ea50a 100644 --- a/src/codegen_support/sentinels.rs +++ b/src/codegen_support/sentinels.rs @@ -147,6 +147,31 @@ pub(crate) fn emit_branch_if_null_container( } } +/// Replaces the in-band `NULL_SENTINEL` in `value_reg` with a zero pointer, leaving any +/// other value untouched. Clobbers `scratch_reg` with the sentinel bit pattern. Used where +/// a null container must be representable by the cheap zero check alone — e.g. the +/// by-reference foreach iterator source slot, whose live length is re-read every iteration +/// (issue #556). Zero-pointer inputs are already normal and pass through unchanged. +pub(crate) fn emit_normalize_null_container_to_zero( + emitter: &mut Emitter, + value_reg: &str, + scratch_reg: &str, +) { + match emitter.target.arch { + Arch::AArch64 => { + super::abi::emit_load_int_immediate(emitter, scratch_reg, NULL_SENTINEL); + emitter.instruction(&format!("cmp {}, {}", value_reg, scratch_reg)); // does the container carry the in-band null sentinel? + emitter.instruction(&format!("csel {}, xzr, {}, eq", value_reg, value_reg)); // fold the sentinel into the canonical zero container pointer + } + Arch::X86_64 => { + super::abi::emit_load_int_immediate(emitter, scratch_reg, NULL_SENTINEL); + emitter.instruction(&format!("cmp {}, {}", value_reg, scratch_reg)); // does the container carry the in-band null sentinel? + emitter.instruction(&format!("mov {}, 0", scratch_reg)); // materialize the zero replacement without disturbing the comparison flags + emitter.instruction(&format!("cmove {}, {}", value_reg, scratch_reg)); // fold the sentinel into the canonical zero container pointer + } + } +} + /// Branches to `label` when the tagged scalar in the result registers is PHP null /// (tag register == null tag). pub(crate) fn emit_branch_if_tagged_scalar_null(emitter: &mut Emitter, label: &str) { diff --git a/tests/codegen/regressions/arrays.rs b/tests/codegen/regressions/arrays.rs index 4ffe80dd7c..cccbedc7bb 100644 --- a/tests/codegen/regressions/arrays.rs +++ b/tests/codegen/regressions/arrays.rs @@ -1268,3 +1268,238 @@ echo "[" . (str_repeat("x", 0) ?? "bad") . "]"; assert_eq!(out.stdout, "[fallback][][]"); assert_eq!(out.stderr, ""); } + +// --- Issue #556: by-reference foreach over a missing array element --- + +/// Regression for issue #556: a by-reference foreach directly over a missed +/// element warns for the miss and skips the loop body instead of handing the +/// null-container sentinel to the copy-on-write helper. +#[test] +fn test_byref_foreach_over_first_index_miss_skips_loop() { + let out = compile_and_run_capture( + r#" ['q' => 1]]; +foreach ($h['nope'] as &$v) { $v = 2; } +echo 'done'; +"#, + ); + assert!(out.success, "program crashed: {}", out.stderr); + assert_eq!(out.stdout, "done"); + assert!(out.stderr.contains("Warning: Undefined array key \"nope\"")); +} + +/// Control for issue #556: the null-source guards must not disturb ordinary +/// by-reference mutation and aliasing over a real array. +#[test] +fn test_byref_foreach_over_present_source_still_mutates() { + let out = compile_and_run_capture( + r#" ("cmp x0, x9", "b.eq", "[x0, #-12]"), + Arch::X86_64 => ("cmp rdi, r10", "je", "[rdi - 12]"), + }; + let done_label = format!("__rt_{helper}_done"); + let cmp_pos = section + .find(sentinel_cmp) + .unwrap_or_else(|| panic!("{helper}: missing sentinel compare:\n{section}")); + // Scan for the bail line only after the sentinel compare: the plain null + // check earlier in the helper branches to the same done label (on x86_64 + // with the same `je` mnemonic). + let after_cmp = §ion[cmp_pos..]; + let bail_pos = { + let mut offset = cmp_pos; + let mut found = None; + for line in after_cmp.lines() { + if line.contains(bail_branch) && line.contains(&done_label) { + found = Some(offset); + break; + } + offset += line.len() + 1; + } + found.unwrap_or_else(|| panic!("{helper}: missing sentinel bail branch:\n{section}")) + }; + let refcount_pos = section + .find(refcount_load) + .unwrap_or_else(|| panic!("{helper}: missing refcount load:\n{section}")); + assert!( + cmp_pos < bail_pos && bail_pos < refcount_pos, + "{helper}: sentinel guard must precede the refcount load:\n{section}" + ); + } + + // -- IterStart: the sentinel source is folded to zero in the iterator slot -- + let normalize = match target().arch { + Arch::AArch64 => "csel x0, xzr, x0, eq", + Arch::X86_64 => "cmove rax, r10", + }; + assert!( + user_asm.contains(normalize), + "missing iter_start sentinel-to-zero normalization:\n{user_asm}" + ); + + // -- IterNext: the by-reference live-length read keeps its zero-source guard -- + // Look for the label *definition* (a line ending with ':'), not the branch operand. + assert!( + user_asm + .lines() + .any(|line| line.trim_end().ends_with(':') && line.contains("iter_len_null_source")), + "missing iter_next zero-length guard label:\n{user_asm}" + ); + + let _ = fs::remove_dir_all(&dir); +} + +/// Regression for issue #556: a missed read assigned to a local and then iterated +/// by reference exercises the ensure-unique store-back path; the loop is skipped +/// and the origin local still carries its null marker afterwards (a botched fix +/// that normalized the local itself would make `??` keep an empty array instead). +#[test] +fn test_byref_foreach_over_missing_local_source_keeps_local_null() { + let out = compile_and_run_capture( + r#" &$v) { $v = 'changed'; echo 'k=' . $k; } +echo 'done'; +"#, + ); + assert!(out.success, "program crashed: {}", out.stderr); + assert_eq!(out.stdout, "done"); + assert!(out.stderr.contains("Warning: Undefined array key 7")); +} + +/// Guard for issue #556: by-reference foreach still reads the live array length, +/// so elements appended during iteration are visited exactly like PHP. +#[test] +fn test_byref_foreach_still_visits_elements_appended_during_iteration() { + let out = compile_and_run_capture( + r#" Date: Wed, 22 Jul 2026 14:26:13 +0200 Subject: [PATCH 2/2] docs(changelog): move issue 556 fix to Unreleased --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5beb18a8f8..3dfd693fd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ Releases are listed newest first. ## [Unreleased] - Migrated every registry-backed PHP builtin to the backend-neutral EIR boundary. Builtin declarations now own one shared semantic descriptor for validation, result typing, effects, ownership, runtime/bridge requirements, callable policy, and lowering strategy; direct calls and generated callable wrappers emit typed EIR primitives or runtime calls that the target-aware backend materializes uniformly on macOS AArch64, Linux AArch64, and Linux x86_64. The old assembly hooks, opaque builtin opcode, duplicated per-name result/effect/requirement tables, legacy checker/signature fallbacks, and separately maintained callable wrappers have been removed. +- Fixed by-reference `foreach` over a missing array element segfaulting (issue #556): the by-reference path was left out of scope by the #526/#533 read-side hardening, so the null-container sentinel from a missed read was handed to the copy-on-write helpers as a real array pointer and then re-read as a live array header on every iteration. `__rt_array_ensure_unique` and `__rt_hash_ensure_unique` now recognize the sentinel and return it unchanged (hardening every copy-on-write call site), foreach initialization folds a sentinel source to the canonical zero pointer in the iterator's private slot while the user-visible variable stays null, and the by-reference live-length read substitutes zero for a null source. The direct form reports the missing key while the `?? []` form silently iterates its empty default; both skip the loop body and continue instead of crashing. Ordinary by-reference mutation, aliasing, and PHP's visit-appended-elements semantics are unchanged, and heap-debug stays clean with `--ir-opt` enabled or disabled across every supported target. ## [0.26.2] - 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. @@ -46,7 +47,6 @@ Releases are listed newest first. - Fixed owning temporary receivers leaking after object property reads (issues #540 and #516): named, dynamic, and nullsafe EIR property paths now stabilize borrowed string, array, object, and callable results before releasing a Mixed-unboxed or otherwise temporary receiver. Nullsafe short-circuits also release owning nullable receivers on the null branch, while indexed consumers preserve chained reads such as `$object->items[0]`, aliasing, and COW behavior. Heap-debug remains clean with the EIR optimizer enabled or disabled. This closes root cause 4 from issue #540. - Fixed inline array arguments leaking when a source function or instance method returns an independent array (issues #540 and #516): the checker now derives conservative return-to-parameter alias summaries, so EIR suppresses temporary cleanup only for arguments the result can actually reuse. Real passthrough returns, descendant overrides, dynamic/indirect storage, and copy-on-write aliases remain protected; optimizer inlining preserves borrowed parameter/return slots without double cleanup, and heap-debug stays clean with `--ir-opt` enabled or disabled on the shared target-independent path. - Fixed the remaining persistent-worker ownership growth from issues #540 and #516: scalar casts now release consumed owning inputs; builtin metadata identifies the independent storage returned by `rawurldecode()`, `implode()`, `htmlspecialchars()`, and `htmlentities()` so wrappers and direct calls can release stabilized read arguments; Mixed-to-string call conversions are now owned explicitly by EIR and follow the same alias-aware cleanup; and constructor materialization releases temporary boxed-Mixed arguments. The original Ivory workload now has zero live-block growth through 500 requests with EIR optimization enabled or disabled, and its first/last 50-request latency stays flat at approximately 3 ms. -- Fixed by-reference `foreach` over a missing array element segfaulting (issue #556): the by-reference path was left out of scope by the #526/#533 read-side hardening, so the null-container sentinel from a missed read was handed to the copy-on-write helpers as a real array pointer and then re-read as a live array header on every iteration. `__rt_array_ensure_unique` and `__rt_hash_ensure_unique` now recognize the sentinel and return it unchanged (hardening every copy-on-write call site), foreach initialization folds a sentinel source to the canonical zero pointer in the iterator's private slot while the user-visible variable stays null, and the by-reference live-length read substitutes zero for a null source. The direct form reports the missing key while the `?? []` form silently iterates its empty default; both skip the loop body and continue instead of crashing. Ordinary by-reference mutation, aliasing, and PHP's visit-appended-elements semantics are unchanged, and heap-debug stays clean with `--ir-opt` enabled or disabled across every supported target. ## [0.26.1] - Expanded flow-sensitive type narrowing for PHP's common guard patterns: `int|false` and other false-sentinel unions now preserve the literal `false` subtype and narrow to their success type after a divergent `=== false` guard, without incorrectly removing a full `bool` member; `=== null` and `is_null()` guards narrow nullable values; and stable object properties can be narrowed through `instanceof`, ternaries, and throw guards. Property facts are invalidated after writes or receiver rebindings and are not retained across property hooks or `__get`, whose repeated reads may differ.